This commit is contained in:
wt committed 2026-07-30 20:07:51 +07:00
commit 92660a4ba0
228 files changed
+24496

No files matched your search

@@ -0,0 +1,92 @@
import Foundation
struct AccountConfiguration: Codable, Equatable, Hashable, Identifiable, Sendable {
var jid: String
var displayName: String
var resource: String
var manualHost: String?
var manualPort: Int?
var usesDirectTLS: Bool
var id: String { normalizedJID }
var normalizedJID: String {
jid.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
var domain: String? {
let pieces = normalizedJID.split(separator: "@", omittingEmptySubsequences: false)
guard pieces.count == 2, !pieces[1].isEmpty else { return nil }
return String(pieces[1])
}
var effectiveResource: String {
let trimmed = resource.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "Luma" : trimmed
}
init(
jid: String,
displayName: String = "",
resource: String = "Luma",
manualHost: String? = nil,
manualPort: Int? = nil,
usesDirectTLS: Bool = false
) {
self.jid = jid
self.displayName = displayName
self.resource = resource
self.manualHost = manualHost?.nilIfBlank
self.manualPort = manualPort
self.usesDirectTLS = usesDirectTLS
}
func validated() throws -> AccountConfiguration {
let normalized = normalizedJID
let pieces = normalized.split(separator: "@", omittingEmptySubsequences: false)
guard pieces.count == 2,
!pieces[0].isEmpty,
!pieces[1].isEmpty,
!normalized.contains(where: { $0.isWhitespace }) else {
throw AccountValidationError.invalidJID
}
if let manualHost, manualHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
throw AccountValidationError.invalidHost
}
if let manualPort, !(1...65_535).contains(manualPort) {
throw AccountValidationError.invalidPort
}
var copy = self
copy.jid = normalized
copy.manualHost = manualHost?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfBlank
copy.resource = effectiveResource
return copy
}
}
enum AccountValidationError: LocalizedError {
case invalidJID
case invalidHost
case invalidPort
var errorDescription: String? {
switch self {
case .invalidJID:
return "Введите полный JID в формате user@example.org."
case .invalidHost:
return "Адрес сервера не может быть пустым."
case .invalidPort:
return "Порт должен быть числом от 1 до 65535."
}
}
}
private extension String {
var nilIfBlank: String? {
isEmpty ? nil : self
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,15 @@
import Foundation
/// Keeps MAM work below a visible frame-sized burst. A whole decoded pass is
/// still published atomically; these values only bound network and decryption
/// work performed before that publication.
enum ArchiveMessageBatchPolicy {
static let pageSize = 24
static let bootstrapMessageLimit = 40
static let decodeSliceSize = 1
static let maximumBufferedStanzas = 48
/// Leave roughly half of a 60 Hz frame between archived decryptions. This
/// keeps UIScrollView touch handling responsive even when OMEMO work is
/// unusually expensive on older devices.
static let interSliceDelayNanoseconds: UInt64 = 8_000_000
}
@@ -0,0 +1,81 @@
import Foundation
/// Durable MAM position. The archive UID is the primary synchronization
/// anchor; the timestamp is retained as a compatibility fallback for legacy
/// snapshots and servers that have expired an old UID.
struct ArchiveSyncCheckpoint: Codable, Equatable, Sendable {
let timestamp: Date
let cursor: String?
init(timestamp: Date, cursor: String? = nil) {
self.timestamp = timestamp
self.cursor = Self.normalizedCursor(cursor)
}
func advances(over previous: ArchiveSyncCheckpoint?) -> Bool {
guard let previous else { return true }
return timestamp > previous.timestamp || cursor != previous.cursor
}
static func normalizedCursor(_ value: String?) -> String? {
guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty else { return nil }
return value
}
}
struct ArchiveSyncRequestPosition: Equatable, Sendable {
let start: Date?
let after: String?
}
/// Keeps cursor-based catch-up and the timestamp fallback deterministic and
/// independently testable from Martin's callback lifecycle.
enum ArchiveSyncCursorPolicy {
static func requestPosition(
checkpoint: ArchiveSyncCheckpoint?,
resumeAfter: String?,
overlap: TimeInterval
) -> ArchiveSyncRequestPosition {
let after = ArchiveSyncCheckpoint.normalizedCursor(resumeAfter)
?? checkpoint?.cursor
if let after {
return ArchiveSyncRequestPosition(start: nil, after: after)
}
return ArchiveSyncRequestPosition(
start: checkpoint?.timestamp.addingTimeInterval(-overlap),
after: nil
)
}
static func resolvedCheckpoint(
previous: ArchiveSyncCheckpoint?,
cursor: String?,
highWatermark: Date?,
queryStartedAt: Date,
caughtUp: Bool
) -> ArchiveSyncCheckpoint {
let previousTimestamp = previous?.timestamp ?? .distantPast
let boundedHighWatermark = min(highWatermark ?? previousTimestamp, queryStartedAt)
let timestamp = caughtUp
? max(previousTimestamp, queryStartedAt)
: max(previousTimestamp, boundedHighWatermark)
return ArchiveSyncCheckpoint(
timestamp: timestamp,
cursor: ArchiveSyncCheckpoint.normalizedCursor(cursor) ?? previous?.cursor
)
}
static func shouldFallbackFromStoredCursor(
requestedAfter: String?,
checkpoint: ArchiveSyncCheckpoint?,
hasCompletedPage: Bool,
fallbackAlreadyUsed: Bool
) -> Bool {
guard !fallbackAlreadyUsed,
!hasCompletedPage,
let requestedAfter = ArchiveSyncCheckpoint.normalizedCursor(requestedAfter),
requestedAfter == checkpoint?.cursor else { return false }
return true
}
}
@@ -0,0 +1,33 @@
import Foundation
enum ArchiveSyncPageDecision: Equatable, Sendable {
case finished
case next(String)
case invalidCursor
}
/// Validates XEP-0313/RSM cursors so a misbehaving server cannot leave
/// history synchronization spinning forever on the same page.
struct ArchiveSyncPagination: Sendable {
private(set) var seenCursors: Set<String> = []
mutating func decision(
complete: Bool,
lastCursor: String?,
requestedAfter: String?
) -> ArchiveSyncPageDecision {
if complete { return .finished }
guard let cursor = lastCursor?.trimmingCharacters(in: .whitespacesAndNewlines),
!cursor.isEmpty else {
// An incomplete response without an RSM cursor cannot prove that
// the archive is exhausted. Advancing the checkpoint here would
// silently skip the remaining history.
return .invalidCursor
}
guard cursor != requestedAfter,
seenCursors.insert(cursor).inserted else {
return .invalidCursor
}
return .next(cursor)
}
}
@@ -0,0 +1,12 @@
import Foundation
/// Bounds whole-query MAM recovery so a server that keeps returning the same
/// broken page cannot leave the client in an endless restart loop.
enum ArchiveSyncRecoveryPolicy {
static let incrementalOverlap: TimeInterval = 60
static let interPageDelayNanoseconds: UInt64 = 100_000_000
static let queryTimeoutNanoseconds: UInt64 = 12_000_000_000
static let pageApplyTimeoutNanoseconds: UInt64 = 8_000_000_000
static let pageRetryLimit = 1
static let resumeAfterCaptureDelayNanoseconds: UInt64 = 3_000_000_000
}
@@ -0,0 +1,21 @@
import Foundation
/// Limits one foreground MAM pass. A large archive is advanced over several
/// app activations instead of monopolizing the main actor indefinitely.
struct ArchiveSyncWorkBudget: Equatable, Sendable {
static let maximumCompletedPages = 2
static let maximumRuntime: TimeInterval = 2
private(set) var completedPages = 0
let startedAt: Date
init(startedAt: Date = Date()) {
self.startedAt = startedAt
}
mutating func recordCompletedPage(at date: Date = Date()) -> Bool {
completedPages += 1
return completedPages >= Self.maximumCompletedPages
|| date.timeIntervalSince(startedAt) >= Self.maximumRuntime
}
}
@@ -0,0 +1,45 @@
import Foundation
struct AttachmentDraft: Identifiable, Hashable, Sendable {
let id: UUID
let url: URL
let filename: String
let mimeType: String
let kind: ChatMessage.Kind
let duration: TimeInterval?
let byteCount: Int
let thumbnailData: Data?
let isTemporary: Bool
init(
id: UUID = UUID(),
url: URL,
filename: String,
mimeType: String,
kind: ChatMessage.Kind,
duration: TimeInterval? = nil,
byteCount: Int,
thumbnailData: Data? = nil,
isTemporary: Bool = true
) {
self.id = id
self.url = url
self.filename = filename
self.mimeType = mimeType
self.kind = kind
self.duration = duration
self.byteCount = byteCount
self.thumbnailData = thumbnailData
self.isTemporary = isTemporary
}
}
struct AttachmentBatchResult: Sendable {
let sentCount: Int
let failedDrafts: [AttachmentDraft]
let captionSent: Bool
var isComplete: Bool {
failedDrafts.isEmpty
}
}
+114
View File
@@ -0,0 +1,114 @@
import Foundation
enum CallMedia: String, CaseIterable, Hashable, Sendable {
case audio
case video
}
enum CallDirection: String, Equatable, Sendable {
case incoming
case outgoing
}
enum CallPhase: Equatable, Sendable {
case ringing
case connecting
case connected
}
/// Stable value stored with a local call-history message.
enum CallHistoryOutcome: String, Codable, Hashable, Sendable {
case completed
case declined
case missed
case cancelled
case unanswered
case failed
case answeredElsewhere
}
struct CallHistoryMetadata: Codable, Hashable, Sendable {
let isVideo: Bool
let outcome: CallHistoryOutcome
}
/// Internal reason used to turn every call-ending path into one history result.
enum CallTerminationCause: Equatable, Sendable {
case localEnded
case localRejected
case remoteEnded
case remoteRejected
case remoteCancelled
case timedOut
case failed
case connectionDetached
case answeredElsewhere
}
struct CallHistoryEntry: Equatable, Sendable {
let id: String
let peerJID: String
let direction: CallDirection
let isVideo: Bool
let startedAt: Date
let endedAt: Date
let duration: TimeInterval?
let outcome: CallHistoryOutcome
}
/// Pure classification kept outside WebRTC so every termination scenario can
/// be tested without constructing a live peer connection.
enum CallHistoryPolicy {
static func outcome(
direction: CallDirection,
phase: CallPhase,
connectedAt: Date?,
cause: CallTerminationCause
) -> CallHistoryOutcome {
// Once media connected, a later hang-up or transport loss still belongs
// to the successful-call history and keeps its measured duration.
if connectedAt != nil || phase == .connected {
return .completed
}
switch cause {
case .localRejected, .remoteRejected:
return .declined
case .localEnded:
return .cancelled
case .remoteCancelled, .remoteEnded:
guard phase == .ringing else { return .failed }
return direction == .incoming ? .missed : .declined
case .timedOut:
guard phase == .ringing else { return .failed }
return direction == .incoming ? .missed : .unanswered
case .answeredElsewhere:
return .answeredElsewhere
case .failed, .connectionDetached:
return .failed
}
}
}
/// Value-type projection of the live WebRTC/Jingle session used by SwiftUI.
struct CallSnapshot: Identifiable, Equatable, Sendable {
let id: UUID
let peerJID: String
let direction: CallDirection
let media: Set<CallMedia>
let phase: CallPhase
let connectedAt: Date?
let isMuted: Bool
let isCameraEnabled: Bool
let isSpeakerEnabled: Bool
let hasLocalVideo: Bool
let hasRemoteVideo: Bool
var isVideoCall: Bool {
media.contains(.video)
}
var isIncomingRinging: Bool {
direction == .incoming && phase == .ringing
}
}
+319
View File
@@ -0,0 +1,319 @@
import Foundation
struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
enum Direction: String, Codable, Sendable {
case incoming
case outgoing
}
enum Delivery: String, Codable, Sendable {
case sending
case sent
case delivered
case failed
}
enum Security: String, Codable, Sendable {
case omemo
case plaintext
case decryptionFailed
}
enum Kind: String, Codable, Sendable {
case text
case attachment
case photo
case video
case audio
case voice
case videoNote
case location
case system
var isMedia: Bool {
switch self {
case .attachment, .photo, .video, .audio, .voice, .videoNote:
return true
case .text, .location, .system:
return false
}
}
}
let id: String
var conversationID: String
var senderJID: String
var body: String
var timestamp: Date
var direction: Direction
var delivery: Delivery
var security: Security
var kind: Kind
var remoteAttachmentURL: String?
var localFilename: String?
var mimeType: String?
var duration: TimeInterval?
var byteCount: Int?
var encryptionFingerprint: String?
var editedAt: Date?
var replyToID: String?
var replyToJID: String?
var replyPreview: String?
var forwardedFrom: String?
var retractedAt: Date?
var stanzaID: String?
var senderDisplayName: String?
var isGroupMessage: Bool
var callHistory: CallHistoryMetadata?
var reactions: [MessageReaction]
init(
id: String = UUID().uuidString,
conversationID: String,
senderJID: String,
body: String,
timestamp: Date = Date(),
direction: Direction,
delivery: Delivery,
security: Security,
kind: Kind = .text,
remoteAttachmentURL: String? = nil,
localFilename: String? = nil,
mimeType: String? = nil,
duration: TimeInterval? = nil,
byteCount: Int? = nil,
encryptionFingerprint: String? = nil,
editedAt: Date? = nil,
replyToID: String? = nil,
replyToJID: String? = nil,
replyPreview: String? = nil,
forwardedFrom: String? = nil,
retractedAt: Date? = nil,
stanzaID: String? = nil,
senderDisplayName: String? = nil,
isGroupMessage: Bool = false,
callHistory: CallHistoryMetadata? = nil,
reactions: [MessageReaction] = []
) {
self.id = id
self.conversationID = conversationID.lowercased()
self.senderJID = Self.normalizedSenderJID(senderJID)
self.body = body
self.timestamp = timestamp
self.direction = direction
self.delivery = delivery
self.security = security
self.kind = kind
self.remoteAttachmentURL = remoteAttachmentURL
self.localFilename = localFilename
self.mimeType = mimeType
self.duration = duration
self.byteCount = byteCount
self.encryptionFingerprint = encryptionFingerprint
self.editedAt = editedAt
self.replyToID = replyToID
self.replyToJID = replyToJID.map(Self.normalizedSenderJID)
self.replyPreview = replyPreview
self.forwardedFrom = forwardedFrom
self.retractedAt = retractedAt
self.stanzaID = stanzaID
self.senderDisplayName = senderDisplayName
self.isGroupMessage = isGroupMessage
self.callHistory = callHistory
self.reactions = reactions
}
private enum CodingKeys: String, CodingKey {
case id
case conversationID
case senderJID
case body
case timestamp
case direction
case delivery
case security
case kind
case remoteAttachmentURL
case localFilename
case mimeType
case duration
case byteCount
case encryptionFingerprint
case editedAt
case replyToID
case replyToJID
case replyPreview
case forwardedFrom
case retractedAt
case stanzaID
case senderDisplayName
case isGroupMessage
case callHistory
case reactions
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
conversationID = try values.decode(String.self, forKey: .conversationID).lowercased()
senderJID = Self.normalizedSenderJID(try values.decode(String.self, forKey: .senderJID))
body = try values.decode(String.self, forKey: .body)
timestamp = try values.decode(Date.self, forKey: .timestamp)
direction = try values.decode(Direction.self, forKey: .direction)
delivery = try values.decode(Delivery.self, forKey: .delivery)
security = try values.decode(Security.self, forKey: .security)
kind = try values.decode(Kind.self, forKey: .kind)
remoteAttachmentURL = try values.decodeIfPresent(String.self, forKey: .remoteAttachmentURL)
localFilename = try values.decodeIfPresent(String.self, forKey: .localFilename)
mimeType = try values.decodeIfPresent(String.self, forKey: .mimeType)
duration = try values.decodeIfPresent(TimeInterval.self, forKey: .duration)
byteCount = try values.decodeIfPresent(Int.self, forKey: .byteCount)
encryptionFingerprint = try values.decodeIfPresent(String.self, forKey: .encryptionFingerprint)
editedAt = try values.decodeIfPresent(Date.self, forKey: .editedAt)
replyToID = try values.decodeIfPresent(String.self, forKey: .replyToID)
replyToJID = try values.decodeIfPresent(String.self, forKey: .replyToJID).map(Self.normalizedSenderJID)
replyPreview = try values.decodeIfPresent(String.self, forKey: .replyPreview)
forwardedFrom = try values.decodeIfPresent(String.self, forKey: .forwardedFrom)
retractedAt = try values.decodeIfPresent(Date.self, forKey: .retractedAt)
stanzaID = try values.decodeIfPresent(String.self, forKey: .stanzaID)
senderDisplayName = try values.decodeIfPresent(String.self, forKey: .senderDisplayName)
isGroupMessage = try values.decodeIfPresent(Bool.self, forKey: .isGroupMessage) ?? false
callHistory = try values.decodeIfPresent(CallHistoryMetadata.self, forKey: .callHistory)
reactions = try values.decodeIfPresent([MessageReaction].self, forKey: .reactions) ?? []
}
var canBeEdited: Bool {
!isRetracted
&& !isGroupMessage
&& direction == .outgoing
&& kind == .text
&& (delivery == .sent || delivery == .delivered)
}
var isRetracted: Bool {
retractedAt != nil
}
var canBeRepliedTo: Bool {
!isRetracted
&& kind != .system
&& (!isGroupMessage || stanzaID != nil)
}
var canBeForwarded: Bool {
!isRetracted && kind != .system
}
var canBeRetracted: Bool {
direction == .outgoing
&& !isGroupMessage
&& !isRetracted
&& kind != .system
&& (delivery == .sent || delivery == .delivered)
}
var replyIdentifier: String? {
isGroupMessage ? stanzaID : id
}
var quotePreview: String {
if isRetracted { return "Сообщение удалено" }
let value = kind == .text ? body : previewText
let normalized = value
.replacingOccurrences(of: "\n", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard normalized.count > 140 else { return normalized }
return String(normalized.prefix(137)) + "…"
}
var previewText: String {
if isRetracted { return "🚫 Сообщение удалено" }
if let callHistory {
let icon = callHistory.isVideo ? "🎥" : "📞"
return "\(icon) \(callTitle)"
}
switch kind {
case .attachment:
return "📎 \(localFilename ?? "Вложение")"
case .photo:
return "📷 Фото"
case .video:
return "🎬 Видео"
case .audio:
return "🎵 \(localFilename ?? "Аудио")"
case .voice:
return "🎙 Голосовое сообщение"
case .videoNote:
return "⭕️ Видеосообщение"
case .location:
return "📍 Геопозиция"
case .text, .system:
return body
}
}
var callTitle: String {
guard let callHistory else { return body }
let media = callHistory.isVideo ? "видеозвонок" : "аудиозвонок"
switch callHistory.outcome {
case .completed:
return direction == .incoming ? "Входящий \(media)" : "Исходящий \(media)"
case .declined:
return "Отклонённый \(media)"
case .missed:
return "Пропущенный \(media)"
case .cancelled:
return "Отменённый \(media)"
case .unanswered:
return "Нет ответа"
case .failed:
return "Неудачный \(media)"
case .answeredElsewhere:
return "Звонок принят на другом устройстве"
}
}
var callSubtitle: String? {
guard let callHistory else { return nil }
switch callHistory.outcome {
case .completed:
guard let duration else { return "Звонок завершён" }
return "Длительность \(Self.formattedCallDuration(duration))"
case .declined:
return direction == .incoming
? "Вы отклонили звонок"
: "Собеседник отклонил звонок"
case .missed:
return "Вы не ответили"
case .cancelled:
return direction == .outgoing
? "Вы отменили вызов"
: "Собеседник отменил вызов"
case .unanswered:
return "Собеседник не ответил"
case .failed:
return "Соединение не установлено"
case .answeredElsewhere:
return "Ответ с другого устройства"
}
}
private static func formattedCallDuration(_ value: TimeInterval) -> String {
let seconds = max(0, Int(value.rounded()))
if seconds >= 3_600 {
return String(
format: "%d:%02d:%02d",
seconds / 3_600,
(seconds % 3_600) / 60,
seconds % 60
)
}
return String(format: "%d:%02d", seconds / 60, seconds % 60)
}
private static func normalizedSenderJID(_ value: String) -> String {
guard let slash = value.firstIndex(of: "/") else { return value.lowercased() }
let bare = value[..<slash].lowercased()
return bare + String(value[slash...])
}
}
@@ -0,0 +1,20 @@
import CoreGraphics
enum ChatScrollPositionPolicy {
static let nearBottomThreshold: CGFloat = 96
static func isNearBottom(
bottomY: CGFloat,
viewportHeight: CGFloat
) -> Bool {
guard bottomY.isFinite, viewportHeight.isFinite, viewportHeight > 0 else {
return true
}
return bottomY <= viewportHeight + nearBottomThreshold
}
static func isNearBottom(distanceFromBottom: CGFloat) -> Bool {
guard distanceFromBottom.isFinite else { return true }
return distanceFromBottom <= nearBottomThreshold
}
}
@@ -0,0 +1,28 @@
import Foundation
/// A cached presentation row for the selected chat. Building day boundaries
/// once per message mutation avoids walking the full history during every
/// scroll-position or composer-state update.
struct ChatTimelineEntry: Identifiable, Hashable, Sendable {
let message: ChatMessage
let startsNewDay: Bool
var id: String { message.id }
static func make(
from messages: [ChatMessage],
calendar: Calendar = .autoupdatingCurrent
) -> [ChatTimelineEntry] {
var previousTimestamp: Date?
return messages.map { message in
let startsNewDay = previousTimestamp.map {
!calendar.isDate($0, inSameDayAs: message.timestamp)
} ?? true
previousTimestamp = message.timestamp
return ChatTimelineEntry(
message: message,
startsNewDay: startsNewDay
)
}
}
}
@@ -0,0 +1,29 @@
import Foundation
enum ChatTypingState: String, Sendable {
case active
case composing
case paused
case inactive
case gone
}
enum ChatTypingPolicy {
static let pauseDelayNanoseconds: UInt64 = 5_000_000_000
static let remoteExpiryNanoseconds: UInt64 = 8_000_000_000
static func displayText(names: [String], isGroup: Bool) -> String? {
let uniqueNames = Array(Set(names.filter { !$0.isEmpty })).sorted()
guard !uniqueNames.isEmpty else { return nil }
guard isGroup else { return "печатает…" }
switch uniqueNames.count {
case 1:
return "\(uniqueNames[0]) печатает…"
case 2:
return "\(uniqueNames[0]) и \(uniqueNames[1]) печатают…"
default:
return "Несколько участников печатают…"
}
}
}
+122
View File
@@ -0,0 +1,122 @@
import Foundation
struct Conversation: Codable, Identifiable, Hashable, Sendable {
enum Kind: String, Codable, Sendable {
case direct
case group
}
let id: String
var jid: String
var displayName: String
var lastMessage: String
var lastActivity: Date
var unreadCount: Int
var isOnline: Bool
var isPinned: Bool
var encryptionPreference: EncryptionPreference
var kind: Kind
var groupNickname: String?
var isGroupJoined: Bool
var shouldAutojoin: Bool
var occupantCount: Int
var invitedBy: String?
init(
jid: String,
displayName: String? = nil,
lastMessage: String = "",
lastActivity: Date = .distantPast,
unreadCount: Int = 0,
isOnline: Bool = false,
isPinned: Bool = false,
encryptionPreference: EncryptionPreference = .inheritGlobal,
kind: Kind = .direct,
groupNickname: String? = nil,
isGroupJoined: Bool = false,
shouldAutojoin: Bool = false,
occupantCount: Int = 0,
invitedBy: String? = nil
) {
let normalized = jid.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
self.id = normalized
self.jid = normalized
self.displayName = displayName?.nilIfBlank ?? normalized
self.lastMessage = lastMessage
self.lastActivity = lastActivity
self.unreadCount = unreadCount
self.isOnline = isOnline
self.isPinned = isPinned
self.encryptionPreference = encryptionPreference
self.kind = kind
self.groupNickname = groupNickname?.nilIfBlank
self.isGroupJoined = isGroupJoined
self.shouldAutojoin = shouldAutojoin
self.occupantCount = max(0, occupantCount)
self.invitedBy = invitedBy?.nilIfBlank
}
private enum CodingKeys: String, CodingKey {
case id
case jid
case displayName
case lastMessage
case lastActivity
case unreadCount
case isOnline
case isPinned
case encryptionPreference
case kind
case groupNickname
case isGroupJoined
case shouldAutojoin
case occupantCount
case invitedBy
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
jid = try values.decode(String.self, forKey: .jid)
id = try values.decodeIfPresent(String.self, forKey: .id) ?? jid.lowercased()
displayName = try values.decode(String.self, forKey: .displayName)
lastMessage = try values.decode(String.self, forKey: .lastMessage)
lastActivity = try values.decode(Date.self, forKey: .lastActivity)
unreadCount = try values.decode(Int.self, forKey: .unreadCount)
isOnline = try values.decode(Bool.self, forKey: .isOnline)
isPinned = try values.decode(Bool.self, forKey: .isPinned)
encryptionPreference = try values.decodeIfPresent(
EncryptionPreference.self,
forKey: .encryptionPreference
) ?? .inheritGlobal
kind = try values.decodeIfPresent(Kind.self, forKey: .kind) ?? .direct
groupNickname = try values.decodeIfPresent(String.self, forKey: .groupNickname)
isGroupJoined = try values.decodeIfPresent(Bool.self, forKey: .isGroupJoined) ?? false
shouldAutojoin = try values.decodeIfPresent(Bool.self, forKey: .shouldAutojoin) ?? false
occupantCount = max(0, try values.decodeIfPresent(Int.self, forKey: .occupantCount) ?? 0)
invitedBy = try values.decodeIfPresent(String.self, forKey: .invitedBy)
}
var isGroup: Bool { kind == .group }
var initials: String {
let source = displayName == jid ? (jid.split(separator: "@").first.map(String.init) ?? jid) : displayName
let words = source.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
let letters = words.prefix(2).compactMap(\.first)
if letters.isEmpty {
return String(source.prefix(1)).uppercased()
}
return String(letters).uppercased()
}
var colorSeed: UInt64 {
jid.utf8.reduce(14_695_981_039_346_656_037) { partial, byte in
(partial ^ UInt64(byte)) &* 1_099_511_628_211
}
}
}
private extension String {
var nilIfBlank: String? {
trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self
}
}
@@ -0,0 +1,29 @@
import Foundation
enum EncryptionPreference: String, Codable, CaseIterable, Equatable, Hashable, Sendable {
case inheritGlobal
case enabled
case disabled
func resolved(globalEnabled: Bool) -> Bool {
switch self {
case .inheritGlobal:
return globalEnabled
case .enabled:
return true
case .disabled:
return false
}
}
var title: String {
switch self {
case .inheritGlobal:
return "Как в настройках"
case .enabled:
return "Всегда включено"
case .disabled:
return "Выключено для чата"
}
}
}
+61
View File
@@ -0,0 +1,61 @@
import Foundation
struct GeoLocation: Equatable, Sendable {
let latitude: Double
let longitude: Double
let uncertainty: Double?
init?(latitude: Double, longitude: Double, uncertainty: Double? = nil) {
guard latitude.isFinite,
longitude.isFinite,
(-90...90).contains(latitude),
(-180...180).contains(longitude) else { return nil }
if let uncertainty, (!uncertainty.isFinite || uncertainty < 0) {
return nil
}
self.latitude = latitude
self.longitude = longitude
self.uncertainty = uncertainty
}
init?(uri: String) {
let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.lowercased().hasPrefix("geo:") else { return nil }
let payload = trimmed.dropFirst(4)
let components = payload.split(separator: ";", omittingEmptySubsequences: false)
guard let coordinatePart = components.first else { return nil }
let coordinates = coordinatePart.split(separator: ",", omittingEmptySubsequences: false)
guard coordinates.count == 2 || coordinates.count == 3,
let latitude = Double(coordinates[0]),
let longitude = Double(coordinates[1]) else { return nil }
if coordinates.count == 3, Double(coordinates[2]) == nil {
return nil
}
var uncertainty: Double?
for component in components.dropFirst() {
let pair = component.split(separator: "=", maxSplits: 1)
guard pair.count == 2 else { continue }
if pair[0].lowercased() == "u" {
guard let value = Double(pair[1]) else { return nil }
uncertainty = value
}
}
self.init(latitude: latitude, longitude: longitude, uncertainty: uncertainty)
}
var uriString: String {
let coordinates = String(
format: "geo:%.6f,%.6f",
locale: Locale(identifier: "en_US_POSIX"),
latitude,
longitude
)
guard let uncertainty else { return coordinates }
return coordinates + String(
format: ";u=%.0f",
locale: Locale(identifier: "en_US_POSIX"),
uncertainty
)
}
}
+76
View File
@@ -0,0 +1,76 @@
import Foundation
import UniformTypeIdentifiers
enum MediaMetadata {
struct Inferred: Equatable, Sendable {
let kind: ChatMessage.Kind
let mimeType: String?
let duration: TimeInterval?
}
static func transportFilename(
originalFilename: String,
kind: ChatMessage.Kind,
duration: TimeInterval?
) -> String {
let milliseconds = duration.flatMap { value -> Int? in
guard value.isFinite,
value > 0,
value <= Double(Int.max) / 1_000 else { return nil }
return Int((value * 1_000).rounded())
}
guard let milliseconds else { return originalFilename }
switch kind {
case .voice:
return "luma-voice-\(milliseconds)-\(originalFilename)"
case .videoNote:
return "luma-videonote-\(milliseconds)-\(originalFilename)"
default:
return originalFilename
}
}
static func infer(from transportFilename: String) -> Inferred {
let contentType = UTType(
filenameExtension: URL(fileURLWithPath: transportFilename).pathExtension
)
let kind: ChatMessage.Kind
if transportFilename.hasPrefix("luma-voice-") {
kind = .voice
} else if transportFilename.hasPrefix("luma-videonote-") {
kind = .videoNote
} else if contentType?.conforms(to: .image) == true {
kind = .photo
} else if contentType?.conforms(to: .movie) == true {
kind = .video
} else if contentType?.conforms(to: .audio) == true {
kind = .audio
} else {
kind = .attachment
}
return Inferred(
kind: kind,
mimeType: contentType?.preferredMIMEType,
duration: encodedDuration(from: transportFilename)
)
}
private static func encodedDuration(from filename: String) -> TimeInterval? {
let prefix: String
if filename.hasPrefix("luma-voice-") {
prefix = "luma-voice-"
} else if filename.hasPrefix("luma-videonote-") {
prefix = "luma-videonote-"
} else {
return nil
}
let remainder = filename.dropFirst(prefix.count)
guard let separator = remainder.firstIndex(of: "-"),
let milliseconds = Int(remainder[..<separator]),
milliseconds > 0 else { return nil }
return TimeInterval(milliseconds) / 1_000
}
}
@@ -0,0 +1,18 @@
enum MediaPickerRepresentation: Equatable, Sendable {
case photo
case video
}
enum MediaPickerSelectionPolicy {
static func preferredOrder(
supportsImage: Bool,
supportsVideo: Bool
) -> [MediaPickerRepresentation] {
// Live Photos can expose both types. Their still image is the most
// predictable cross-client representation; a video-only asset keeps
// movie first. The second entry is an explicit provider fallback.
supportsVideo && !supportsImage
? [.video, .photo]
: [.photo, .video]
}
}
@@ -0,0 +1,25 @@
import Foundation
/// Tracks overlapping media operations without letting an older operation
/// restore a stale busy state after a newer one has already finished.
struct MediaSendActivityTracker: Sendable {
private(set) var activeTokens: Set<UUID> = []
var isActive: Bool {
!activeTokens.isEmpty
}
mutating func begin() -> UUID {
let token = UUID()
activeTokens.insert(token)
return token
}
mutating func end(_ token: UUID) {
activeTokens.remove(token)
}
mutating func reset() {
activeTokens.removeAll()
}
}
@@ -0,0 +1,8 @@
import Foundation
struct MediaViewerItem: Identifiable, Equatable, Sendable {
let id: String
let url: URL
let kind: ChatMessage.Kind
let title: String
}
+103
View File
@@ -0,0 +1,103 @@
import Foundation
struct MessageReaction: Codable, Identifiable, Hashable, Sendable {
let senderJID: String
var emojis: [String]
var updatedAt: Date
var id: String { senderJID }
init(senderJID: String, emojis: [String], updatedAt: Date = Date()) {
self.senderJID = Self.normalizedJID(senderJID)
self.emojis = MessageReactionPolicy.sanitized(emojis)
self.updatedAt = updatedAt
}
private static func normalizedJID(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
}
struct MessageReactionSummary: Identifiable, Hashable, Sendable {
let emoji: String
let count: Int
let includesOwnReaction: Bool
var id: String { emoji }
}
enum MessageReactionPolicy {
static let quickChoices = ["👍", "❤️", "😂", "😮", "😢", "🔥"]
static func sanitized(_ values: [String]) -> [String] {
var seen: Set<String> = []
return values.compactMap { rawValue in
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard value.count == 1,
value.utf8.count <= 64,
value.unicodeScalars.contains(where: { $0.properties.isEmoji }),
seen.insert(value).inserted else { return nil }
return value
}
}
static func summaries(
reactions: [MessageReaction],
ownJID: String?
) -> [MessageReactionSummary] {
let normalizedOwnJID = ownJID.map(bareJID)
var counts: [String: Int] = [:]
var ownValues: Set<String> = []
for reaction in reactions {
let values = sanitized(reaction.emojis)
for emoji in values {
counts[emoji, default: 0] += 1
}
if normalizedOwnJID == bareJID(reaction.senderJID) {
ownValues.formUnion(values)
}
}
return counts.keys.sorted().map { emoji in
MessageReactionSummary(
emoji: emoji,
count: counts[emoji, default: 0],
includesOwnReaction: ownValues.contains(emoji)
)
}
}
static func bareJID(_ value: String) -> String {
value
.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: true)
.first
.map(String.init)?
.lowercased()
?? value.lowercased()
}
}
extension ChatMessage {
var reactionIdentifier: String? {
isGroupMessage ? stanzaID : id
}
var canBeReactedTo: Bool {
guard !isRetracted,
kind != .system,
direction == .incoming || delivery != .sending else { return false }
return !isGroupMessage || stanzaID != nil
}
func reactionSummaries(ownJID: String?) -> [MessageReactionSummary] {
MessageReactionPolicy.summaries(reactions: reactions, ownJID: ownJID)
}
func reactionEmojis(from senderJID: String) -> [String] {
let sender = MessageReactionPolicy.bareJID(senderJID)
return reactions.first {
MessageReactionPolicy.bareJID($0.senderJID) == sender
}?.emojis ?? []
}
}
@@ -0,0 +1,107 @@
import Foundation
/// Builds and removes the plain-text quote used when a peer does not render
/// XEP-0461 replies. XEP-0428 offsets are Unicode scalar offsets, not UTF-16.
enum MessageReplyFallback {
struct Parsed: Equatable, Sendable {
let body: String
let preview: String?
}
static func make(author: String, preview: String) -> (prefix: String, scalarCount: Int) {
let safeAuthor = author
.replacingOccurrences(of: "\n", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
let quoteLines = preview
.trimmingCharacters(in: .whitespacesAndNewlines)
.split(separator: "\n", omittingEmptySubsequences: false)
.prefix(8)
.map { "> \($0)" }
let prefix = (["> \(safeAuthor) написал(а):"] + quoteLines).joined(separator: "\n") + "\n\n"
return (prefix, prefix.unicodeScalars.count)
}
static func parse(
body: String,
fallbackStart: Int?,
fallbackEnd: Int?
) -> Parsed {
if let fallbackStart,
let fallbackEnd,
let fallback = scalarSubstring(body, start: fallbackStart, end: fallbackEnd),
let stripped = removingScalarRange(body, start: fallbackStart, end: fallbackEnd) {
return Parsed(
body: stripped.trimmingCharacters(in: .whitespacesAndNewlines),
preview: quotePreview(from: fallback)
)
}
return parseLegacyQuote(body) ?? Parsed(body: body, preview: nil)
}
static func parseLegacyQuote(_ body: String) -> Parsed? {
let lines = body.components(separatedBy: .newlines)
guard let first = lines.first,
first.trimmingCharacters(in: .whitespaces).hasPrefix(">") else { return nil }
var quoteLines: [String] = []
var bodyStart = 0
for (index, line) in lines.enumerated() {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix(">") {
quoteLines.append(String(trimmed.dropFirst()).trimmingCharacters(in: .whitespaces))
bodyStart = index + 1
continue
}
if trimmed.isEmpty, !quoteLines.isEmpty {
bodyStart = index + 1
continue
}
break
}
guard !quoteLines.isEmpty, bodyStart < lines.count else { return nil }
let remaining = lines[bodyStart...]
.joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !remaining.isEmpty else { return nil }
return Parsed(body: remaining, preview: normalizedPreview(quoteLines))
}
private static func quotePreview(from fallback: String) -> String? {
let lines = fallback.components(separatedBy: .newlines).compactMap { line -> String? in
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard trimmed.hasPrefix(">") else { return nil }
return String(trimmed.dropFirst()).trimmingCharacters(in: .whitespaces)
}
return normalizedPreview(lines)
}
private static func normalizedPreview(_ lines: [String]) -> String? {
var values = lines.filter { !$0.isEmpty }
if values.count > 1,
let first = values.first?.lowercased(),
first.hasSuffix(":") || first.contains(" wrote") || first.contains("написал") {
values.removeFirst()
}
let value = values.joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return nil }
return value.count > 280 ? String(value.prefix(277)) + "…" : value
}
private static func scalarSubstring(_ value: String, start: Int, end: Int) -> String? {
let scalars = value.unicodeScalars
guard start >= 0, end >= start, end <= scalars.count else { return nil }
let lower = scalars.index(scalars.startIndex, offsetBy: start)
let upper = scalars.index(scalars.startIndex, offsetBy: end)
return String(value[lower..<upper])
}
private static func removingScalarRange(_ value: String, start: Int, end: Int) -> String? {
let scalars = value.unicodeScalars
guard start >= 0, end >= start, end <= scalars.count else { return nil }
let lower = scalars.index(scalars.startIndex, offsetBy: start)
let upper = scalars.index(scalars.startIndex, offsetBy: end)
return String(value[..<lower]) + String(value[upper...])
}
}
@@ -0,0 +1,31 @@
import CoreGraphics
struct MessageReplySwipePolicy {
static let triggerDistance: CGFloat = 64
static let maximumOffset: CGFloat = 76
static let horizontalDominance: CGFloat = 1.4
static func offset(for translation: CGSize) -> CGFloat {
guard isHorizontal(translation) else { return 0 }
let distance = min(abs(translation.width) * 0.72, maximumOffset)
return translation.width < 0 ? -distance : distance
}
static func shouldReply(
translation: CGSize,
predictedEndTranslation: CGSize
) -> Bool {
let candidate = abs(predictedEndTranslation.width) > abs(translation.width)
? predictedEndTranslation
: translation
return isHorizontal(candidate) && abs(candidate.width) >= triggerDistance
}
static func progress(for offset: CGFloat) -> CGFloat {
min(1, abs(offset) / triggerDistance)
}
private static func isHorizontal(_ translation: CGSize) -> Bool {
abs(translation.width) > abs(translation.height) * horizontalDominance
}
}
@@ -0,0 +1,15 @@
import Foundation
enum NotificationPolicy {
static func shouldPresentMessage(
inserted: Bool,
isOutgoing: Bool,
appIsActive: Bool,
selectedConversationID: String?,
conversationID: String
) -> Bool {
guard inserted, !isOutgoing else { return false }
guard appIsActive else { return true }
return selectedConversationID?.lowercased() != conversationID.lowercased()
}
}
+38
View File
@@ -0,0 +1,38 @@
import Foundation
struct OMEMODevice: Identifiable, Hashable, Sendable {
enum Trust: String, Sendable {
case undecided
case trusted
case verified
case compromised
}
let jid: String
let deviceID: Int32
let fingerprint: String
let trust: Trust
let isActive: Bool
let isOwn: Bool
var id: String { "\(jid)|\(deviceID)" }
var formattedFingerprint: String {
fingerprint.chunked(every: 8).joined(separator: " ")
}
}
private extension String {
func chunked(every size: Int) -> [String] {
guard size > 0 else { return [self] }
var chunks: [String] = []
var start = startIndex
while start < endIndex {
let end = index(start, offsetBy: size, limitedBy: endIndex) ?? endIndex
chunks.append(String(self[start..<end]))
start = end
}
return chunks
}
}
@@ -0,0 +1,44 @@
import AVFoundation
import Foundation
/// AVCaptureMovieFileOutput may finish with a non-nil error while explicitly
/// reporting that the movie file itself was completed successfully.
enum VideoNoteRecordingCompletionPolicy {
static func shouldKeepOutput(for error: Error?) -> Bool {
guard let error else { return true }
let value = error as NSError
if value.domain == AVFoundationErrorDomain,
value.code == AVError.Code.maximumDurationReached.rawValue {
return true
}
if let finished = value.userInfo[AVErrorRecordingSuccessfullyFinishedKey] as? Bool {
return finished
}
return (value.userInfo[AVErrorRecordingSuccessfullyFinishedKey] as? NSNumber)?.boolValue
== true
}
/// A camera interruption may still leave a playable partial movie. That
/// file is useful for recovery, but it must not be sent as though the user
/// released the record button. The 60-second capture limit is the only
/// completion that is expected without an explicit stop request.
static func wasRequestedOrReachedLimit(
stopRequested: Bool,
error: Error?,
recordedDuration: TimeInterval,
maximumDuration: TimeInterval
) -> Bool {
if stopRequested { return true }
if let error,
(error as NSError).domain == AVFoundationErrorDomain,
(error as NSError).code == AVError.Code.maximumDurationReached.rawValue {
return true
}
guard recordedDuration.isFinite,
maximumDuration.isFinite,
maximumDuration > 0 else { return false }
return recordedDuration >= maximumDuration - 0.25
}
}
@@ -0,0 +1,38 @@
import Foundation
enum VideoNoteRecordingLifecycle: Equatable, Sendable {
case idle
case prepared
case starting
case recording
case stopping
var isBusy: Bool {
switch self {
case .starting, .recording, .stopping:
return true
case .idle, .prepared:
return false
}
}
static func acceptsCompletion(activeURL: URL?, outputURL: URL) -> Bool {
guard let activeURL else { return false }
return normalizedRecordingPath(activeURL) == normalizedRecordingPath(outputURL)
}
/// AVFoundation can return a temporary file through its canonical macOS
/// path (`/private/var/...`) even when the caller supplied the equivalent
/// user-facing path (`/var/...`). Treat those aliases as one recording so
/// a valid delegate callback is not mistaken for a stale attempt.
private static func normalizedRecordingPath(_ url: URL) -> String {
var path = url.standardizedFileURL.resolvingSymlinksInPath().path
for privateAlias in ["/private/var", "/private/tmp"] {
if path == privateAlias || path.hasPrefix(privateAlias + "/") {
path.removeFirst("/private".count)
break
}
}
return path
}
}
@@ -0,0 +1,48 @@
import Foundation
final class AccountPreferences {
private let defaults: UserDefaults
private let accountKey = "luma.active-account.v1"
private let encryptionSettingsKey = "luma.encryption-settings.v1"
private let chatStateSettingsKey = "luma.chat-state-settings.v1"
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
func load() -> AccountConfiguration? {
guard let data = defaults.data(forKey: accountKey) else { return nil }
return try? JSONDecoder().decode(AccountConfiguration.self, from: data)
}
func save(_ account: AccountConfiguration) throws {
let data = try JSONEncoder().encode(account)
defaults.set(data, forKey: accountKey)
}
func clear() {
defaults.removeObject(forKey: accountKey)
}
func encryptionEnabled(for accountJID: String) -> Bool {
let settings = defaults.dictionary(forKey: encryptionSettingsKey) as? [String: Bool]
return settings?[accountJID.lowercased()] ?? true
}
func setEncryptionEnabled(_ enabled: Bool, for accountJID: String) {
var settings = (defaults.dictionary(forKey: encryptionSettingsKey) as? [String: Bool]) ?? [:]
settings[accountJID.lowercased()] = enabled
defaults.set(settings, forKey: encryptionSettingsKey)
}
func chatStatesEnabled(for accountJID: String) -> Bool {
let settings = defaults.dictionary(forKey: chatStateSettingsKey) as? [String: Bool]
return settings?[accountJID.lowercased()] ?? true
}
func setChatStatesEnabled(_ enabled: Bool, for accountJID: String) {
var settings = (defaults.dictionary(forKey: chatStateSettingsKey) as? [String: Bool]) ?? [:]
settings[accountJID.lowercased()] = enabled
defaults.set(settings, forKey: chatStateSettingsKey)
}
}
@@ -0,0 +1,38 @@
import Foundation
actor AvatarCache {
private let directory: URL
init() {
let fileManager = FileManager.default
let base = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first
?? fileManager.temporaryDirectory
directory = base.appendingPathComponent("Luma/Avatars", isDirectory: true)
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
}
func data(for jid: String) -> Data? {
try? Data(contentsOf: fileURL(for: jid))
}
func store(_ data: Data, for jid: String) throws {
try data.write(to: fileURL(for: jid), options: [.atomic])
#if os(iOS)
try? FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
ofItemAtPath: fileURL(for: jid).path
)
#endif
}
private func fileURL(for jid: String) -> URL {
directory.appendingPathComponent("\(Self.stableHash(jid)).avatar")
}
private static func stableHash(_ value: String) -> String {
let hash = value.lowercased().utf8.reduce(UInt64(14_695_981_039_346_656_037)) { partial, byte in
(partial ^ UInt64(byte)) &* 1_099_511_628_211
}
return String(hash, radix: 16, uppercase: false)
}
}
@@ -0,0 +1,118 @@
import Foundation
actor ChatArchive {
struct Snapshot: Codable, Sendable {
static let currentSchemaVersion = 5
var schemaVersion: Int
var conversations: [Conversation]
var messages: [ChatMessage]
var locallyDeletedMessageIDs: Set<String>
var rosterContactJIDs: Set<String>
var lastSuccessfulMAMSync: Date?
var lastSuccessfulMAMCursor: String?
init(
conversations: [Conversation],
messages: [ChatMessage],
locallyDeletedMessageIDs: Set<String> = [],
rosterContactJIDs: Set<String> = [],
lastSuccessfulMAMSync: Date? = nil,
lastSuccessfulMAMCursor: String? = nil
) {
self.schemaVersion = Self.currentSchemaVersion
self.conversations = conversations
self.messages = messages
self.locallyDeletedMessageIDs = locallyDeletedMessageIDs
self.rosterContactJIDs = rosterContactJIDs
self.lastSuccessfulMAMSync = lastSuccessfulMAMSync
self.lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor(
lastSuccessfulMAMCursor
)
}
private enum CodingKeys: String, CodingKey {
case schemaVersion
case conversations
case messages
case locallyDeletedMessageIDs
case rosterContactJIDs
case lastSuccessfulMAMSync
case lastSuccessfulMAMCursor
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
schemaVersion = try values.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1
conversations = try values.decode([Conversation].self, forKey: .conversations)
messages = try values.decode([ChatMessage].self, forKey: .messages)
locallyDeletedMessageIDs = try values.decodeIfPresent(
Set<String>.self,
forKey: .locallyDeletedMessageIDs
) ?? []
rosterContactJIDs = try values.decodeIfPresent(
Set<String>.self,
forKey: .rosterContactJIDs
) ?? []
lastSuccessfulMAMSync = try values.decodeIfPresent(
Date.self,
forKey: .lastSuccessfulMAMSync
)
lastSuccessfulMAMCursor = ArchiveSyncCheckpoint.normalizedCursor(
try values.decodeIfPresent(String.self, forKey: .lastSuccessfulMAMCursor)
)
}
static let empty = Snapshot(conversations: [], messages: [])
}
private let fileURL: URL
private let encoder: JSONEncoder
private let decoder: JSONDecoder
init(accountJID: String) {
let fileManager = FileManager.default
let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? fileManager.temporaryDirectory
let directory = base.appendingPathComponent("Luma/Accounts", isDirectory: true)
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
fileURL = directory.appendingPathComponent("\(Self.stableHash(accountJID)).json")
encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
encoder.dateEncodingStrategy = .millisecondsSince1970
decoder = JSONDecoder()
decoder.dateDecodingStrategy = .millisecondsSince1970
}
func load() -> Snapshot {
guard let data = try? Data(contentsOf: fileURL),
let snapshot = try? decoder.decode(Snapshot.self, from: data) else {
return .empty
}
return snapshot
}
func save(_ snapshot: Snapshot) throws {
let data = try encoder.encode(snapshot)
try data.write(to: fileURL, options: [.atomic])
#if os(iOS)
try? FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
ofItemAtPath: fileURL.path
)
#endif
}
func erase() throws {
guard FileManager.default.fileExists(atPath: fileURL.path) else { return }
try FileManager.default.removeItem(at: fileURL)
}
private static func stableHash(_ value: String) -> String {
let hash = value.lowercased().utf8.reduce(UInt64(14_695_981_039_346_656_037)) { partial, byte in
(partial ^ UInt64(byte)) &* 1_099_511_628_211
}
return String(hash, radix: 16, uppercase: false)
}
}
@@ -0,0 +1,24 @@
import Foundation
import Security
enum CertificateTrustEvaluator {
/// Evaluates the peer as a TLS server for the XMPP service domain.
///
/// Martin 3.2.4's legacy SocketConnector creates an SSL client-certificate
/// policy in its default path. Luma supplies the correct server policy
/// without weakening hostname or certificate-chain validation.
static func evaluateServerTrust(_ trust: SecTrust, expectedDomain: String) -> Bool {
let policy = SecPolicyCreateSSL(true, expectedDomain as CFString)
guard SecTrustSetPolicies(trust, policy) == errSecSuccess else {
return false
}
var evaluationError: CFError?
let trusted = SecTrustEvaluateWithError(trust, &evaluationError)
if !trusted, let evaluationError {
let description = CFErrorCopyDescription(evaluationError) as String
print("Luma TLS trust failed for \(expectedDomain): \(description)")
}
return trusted
}
}
@@ -0,0 +1,85 @@
import Foundation
import Security
final class CredentialVault {
private let service = "app.luma.chat.xmpp"
func save(password: String, for jid: String) throws {
let account = jid.lowercased()
let passwordData = Data(password.utf8)
let baseQuery: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: account
]
let updateStatus = SecItemUpdate(
baseQuery as CFDictionary,
[kSecValueData: passwordData] as CFDictionary
)
if updateStatus == errSecSuccess {
return
}
guard updateStatus == errSecItemNotFound else {
throw CredentialVaultError.unhandled(updateStatus)
}
var insertion = baseQuery
insertion[kSecValueData] = passwordData
#if !os(macOS)
insertion[kSecAttrAccessible] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
#endif
let addStatus = SecItemAdd(insertion as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw CredentialVaultError.unhandled(addStatus)
}
}
func password(for jid: String) throws -> String? {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: jid.lowercased(),
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecItemNotFound {
return nil
}
guard status == errSecSuccess,
let data = item as? Data,
let value = String(data: data, encoding: .utf8) else {
throw CredentialVaultError.unhandled(status)
}
return value
}
func deletePassword(for jid: String) throws {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: jid.lowercased()
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw CredentialVaultError.unhandled(status)
}
}
}
enum CredentialVaultError: LocalizedError {
case unhandled(OSStatus)
var errorDescription: String? {
switch self {
case .unhandled(let status):
let message = SecCopyErrorMessageString(status, nil) as String?
return message ?? "Не удалось обратиться к Keychain (\(status))."
}
}
}
@@ -0,0 +1,147 @@
import AVFoundation
import Combine
import Foundation
@MainActor
final class AudioMessageRecorder: NSObject, ObservableObject {
struct Recording {
let url: URL
let duration: TimeInterval
}
@Published private(set) var isRecording = false
@Published private(set) var elapsed: TimeInterval = 0
private var recorder: AVAudioRecorder?
private var timer: Timer?
private var startedAt: Date?
func start() async throws {
guard !isRecording else { return }
guard await requestPermission() else { throw AudioRecorderError.permissionDenied }
guard !Task.isCancelled else { throw CancellationError() }
var didStart = false
defer {
#if os(iOS)
if !didStart {
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
#endif
}
#if os(iOS)
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothHFP])
try session.setActive(true)
#endif
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("LumaRecordings", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent("voice-\(UUID().uuidString).m4a")
let settings: [String: Any] = [
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
AVSampleRateKey: 44_100.0,
AVNumberOfChannelsKey: 1,
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
]
let recorder = try AVAudioRecorder(url: url, settings: settings)
recorder.prepareToRecord()
guard recorder.record() else { throw AudioRecorderError.cannotStart }
self.recorder = recorder
startedAt = Date()
elapsed = 0
isRecording = true
didStart = true
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
Task { @MainActor in
guard let self, let startedAt = self.startedAt else { return }
self.elapsed = Date().timeIntervalSince(startedAt)
}
}
}
func finish() throws -> Recording {
guard let recorder, isRecording else { throw AudioRecorderError.notRecording }
let duration = max(recorder.currentTime, elapsed)
let url = recorder.url
recorder.stop()
stopState()
guard duration >= 0.4 else {
try? FileManager.default.removeItem(at: url)
throw AudioRecorderError.tooShort
}
return Recording(url: url, duration: duration)
}
func cancel() {
let url = recorder?.url
recorder?.stop()
stopState()
if let url {
try? FileManager.default.removeItem(at: url)
}
}
private func stopState() {
timer?.invalidate()
timer = nil
recorder = nil
startedAt = nil
elapsed = 0
isRecording = false
#if os(iOS)
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
#endif
}
private func requestPermission() async -> Bool {
#if os(iOS)
let session = AVAudioApplication.shared
switch session.recordPermission {
case .granted:
return true
case .denied:
return false
case .undetermined:
return await withCheckedContinuation { continuation in
AVAudioApplication.requestRecordPermission { continuation.resume(returning: $0) }
}
@unknown default:
return false
}
#else
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized:
return true
case .denied, .restricted:
return false
case .notDetermined:
return await AVCaptureDevice.requestAccess(for: .audio)
@unknown default:
return false
}
#endif
}
}
private enum AudioRecorderError: LocalizedError {
case permissionDenied
case cannotStart
case notRecording
case tooShort
var errorDescription: String? {
switch self {
case .permissionDenied:
return "Разрешите Luma доступ к микрофону в системных настройках."
case .cannotStart:
return "Не удалось начать запись голоса."
case .notRecording:
return "Запись голоса не запущена."
case .tooShort:
return "Голосовое сообщение получилось слишком коротким."
}
}
}
@@ -0,0 +1,49 @@
import Foundation
import ImageIO
import UniformTypeIdentifiers
enum AvatarImageProcessor {
static func pngData(from sourceData: Data) throws -> Data {
guard let source = CGImageSourceCreateWithData(sourceData as CFData, nil),
let image = CGImageSourceCreateThumbnailAtIndex(
source,
0,
[
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: 512
] as CFDictionary
) else {
throw AvatarImageError.invalidImage
}
let output = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(
output,
UTType.png.identifier as CFString,
1,
nil
) else {
throw AvatarImageError.encodingFailed
}
CGImageDestinationAddImage(destination, image, nil)
guard CGImageDestinationFinalize(destination) else {
throw AvatarImageError.encodingFailed
}
return output as Data
}
}
private enum AvatarImageError: LocalizedError {
case invalidImage
case encodingFailed
var errorDescription: String? {
switch self {
case .invalidImage:
return "Не удалось прочитать выбранное изображение."
case .encodingFailed:
return "Не удалось подготовить аватар в формате PNG."
}
}
}
@@ -0,0 +1,35 @@
import Foundation
#if os(iOS)
import UIKit
typealias ChatMediaPlatformImage = UIImage
#elseif os(macOS)
import AppKit
typealias ChatMediaPlatformImage = NSImage
#endif
/// SwiftUI can evaluate every visible bubble repeatedly during scrolling.
/// Keep decoded thumbnails in a bounded native-image cache so those passes do
/// not repeatedly decompress JPEG/HEIC data on the main actor.
@MainActor
enum ChatMediaImageCache {
private static let cache: NSCache<NSString, ChatMediaPlatformImage> = {
let cache = NSCache<NSString, ChatMediaPlatformImage>()
cache.countLimit = 160
cache.totalCostLimit = 48 * 1_024 * 1_024
return cache
}()
static func image(
for message: ChatMessage,
data: Data
) -> ChatMediaPlatformImage? {
let cacheKey = "\(message.conversationID)\u{1F}\(message.id)" as NSString
if let cached = cache.object(forKey: cacheKey) {
return cached
}
guard let image = ChatMediaPlatformImage(data: data) else { return nil }
cache.setObject(image, forKey: cacheKey, cost: data.count)
return image
}
}
@@ -0,0 +1,94 @@
import CoreLocation
import Combine
import Foundation
@MainActor
final class LocationProvider: NSObject, ObservableObject {
@Published private(set) var location: CLLocation?
@Published private(set) var authorizationStatus: CLAuthorizationStatus
@Published private(set) var errorMessage: String?
@Published private(set) var isLocating = false
private let manager: CLLocationManager
override init() {
let manager = CLLocationManager()
self.manager = manager
authorizationStatus = manager.authorizationStatus
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyBest
}
func requestCurrentLocation() {
errorMessage = nil
switch manager.authorizationStatus {
case .notDetermined:
isLocating = true
manager.requestWhenInUseAuthorization()
case .authorizedAlways, .authorizedWhenInUse:
isLocating = true
manager.requestLocation()
case .denied, .restricted:
isLocating = false
errorMessage = "Доступ к геопозиции выключен. Можно выбрать точку на карте вручную."
@unknown default:
isLocating = false
errorMessage = "Не удалось определить статус доступа к геопозиции."
}
}
}
extension LocationProvider: CLLocationManagerDelegate {
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus
Task { @MainActor [weak self] in
guard let self else { return }
authorizationStatus = status
switch status {
case .authorizedAlways, .authorizedWhenInUse:
isLocating = true
self.manager.requestLocation()
case .denied, .restricted:
isLocating = false
errorMessage = "Доступ к геопозиции выключен. Можно выбрать точку на карте вручную."
case .notDetermined:
break
@unknown default:
isLocating = false
}
}
}
nonisolated func locationManager(
_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]
) {
guard let newest = locations.last else { return }
let latitude = newest.coordinate.latitude
let longitude = newest.coordinate.longitude
let altitude = newest.altitude
let horizontalAccuracy = newest.horizontalAccuracy
let verticalAccuracy = newest.verticalAccuracy
let timestamp = newest.timestamp
Task { @MainActor [weak self] in
self?.location = CLLocation(
coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude),
altitude: altitude,
horizontalAccuracy: horizontalAccuracy,
verticalAccuracy: verticalAccuracy,
timestamp: timestamp
)
self?.isLocating = false
self?.errorMessage = nil
}
}
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
let description = error.localizedDescription
Task { @MainActor [weak self] in
self?.isLocating = false
self?.errorMessage = "Не удалось получить текущую геопозицию: \(description)"
}
}
}
+162
View File
@@ -0,0 +1,162 @@
import Foundation
import UniformTypeIdentifiers
struct LoadedMediaFile: Sendable {
let data: Data
let filename: String
let mimeType: String
let inferredKind: ChatMessage.Kind
}
struct StagedMediaFile: Sendable {
let url: URL
let filename: String
let contentTypeIdentifier: String?
let byteCount: Int
}
enum MediaFileIOError: LocalizedError, Sendable {
case coordinatedReadFailed(String)
case emptyFile(String)
case unreadableFile(String)
var errorDescription: String? {
switch self {
case .coordinatedReadFailed(let message):
return "Не удалось получить выбранный файл: \(message)"
case .emptyFile(let filename):
return "Файл «\(filename)» пуст или ещё не загружен из iCloud."
case .unreadableFile(let filename):
return "Не удалось подготовить «\(filename)» для отправки."
}
}
}
/// Serializes potentially large media file reads and writes away from the
/// main actor so finishing a video note cannot freeze the composer.
actor MediaFileIO {
/// Monal immediately copies an item-provider URL into its own cache under
/// NSFileCoordinator before preview or upload work starts. Do the same for
/// every Luma draft so Photos/iCloud temporary URLs can disappear safely.
func stageAttachmentDraft(from source: URL) throws -> StagedMediaFile {
let fileManager = FileManager.default
let directory = fileManager.temporaryDirectory
.appendingPathComponent("LumaAttachmentDrafts", isDirectory: true)
try fileManager.createDirectory(
at: directory,
withIntermediateDirectories: true
)
let accessed = source.startAccessingSecurityScopedResource()
defer {
if accessed { source.stopAccessingSecurityScopedResource() }
}
var coordinatedError: NSError?
var stagedResult: Result<StagedMediaFile, Error>?
let coordinator = NSFileCoordinator(filePresenter: nil)
coordinator.coordinate(
readingItemAt: source,
options: [],
error: &coordinatedError
) { coordinatedURL in
stagedResult = Result {
let originalValues = try? source.resourceValues(
forKeys: [.nameKey, .contentTypeKey]
)
let coordinatedValues = try coordinatedURL.resourceValues(
forKeys: [.fileSizeKey, .contentTypeKey]
)
let sourceName = originalValues?.name ?? source.lastPathComponent
let filename = sourceName.isEmpty
? coordinatedURL.lastPathComponent
: sourceName
let safeComponent = Self.safePathComponent(filename)
let destination = directory.appendingPathComponent(
"\(UUID().uuidString)-\(safeComponent)"
)
do {
try fileManager.copyItem(at: coordinatedURL, to: destination)
let stagedValues = try destination.resourceValues(
forKeys: [.fileSizeKey, .isRegularFileKey]
)
let byteCount = stagedValues.fileSize ?? coordinatedValues.fileSize ?? 0
guard stagedValues.isRegularFile == true,
fileManager.isReadableFile(atPath: destination.path) else {
throw MediaFileIOError.unreadableFile(filename)
}
guard byteCount > 0 else {
throw MediaFileIOError.emptyFile(filename)
}
let contentType = originalValues?.contentType
?? coordinatedValues.contentType
?? UTType(filenameExtension: source.pathExtension)
return StagedMediaFile(
url: destination,
filename: filename,
contentTypeIdentifier: contentType?.identifier,
byteCount: byteCount
)
} catch {
try? fileManager.removeItem(at: destination)
throw error
}
}
}
if let stagedResult {
return try stagedResult.get()
}
throw MediaFileIOError.coordinatedReadFailed(
coordinatedError?.localizedDescription ?? "неизвестная ошибка"
)
}
func load(
from url: URL,
preferredKind: ChatMessage.Kind?
) throws -> LoadedMediaFile {
let values = try url.resourceValues(forKeys: [.nameKey, .contentTypeKey])
let filename = values.name ?? url.lastPathComponent
let contentType = values.contentType ?? UTType(filenameExtension: url.pathExtension)
let kind = preferredKind ?? Self.mediaKind(
for: contentType,
filename: filename
)
return LoadedMediaFile(
data: try Data(contentsOf: url, options: [.mappedIfSafe]),
filename: filename,
mimeType: contentType?.preferredMIMEType ?? "application/octet-stream",
inferredKind: kind
)
}
func write(_ data: Data, to url: URL) throws {
try data.write(to: url, options: [.atomic])
}
private static func safePathComponent(_ value: String) -> String {
let forbidden = CharacterSet(charactersIn: "/\\:")
.union(.controlCharacters)
let cleaned = value.components(separatedBy: forbidden).joined(separator: "_")
return cleaned.isEmpty ? "attachment" : cleaned
}
private static func mediaKind(
for contentType: UTType?,
filename: String
) -> ChatMessage.Kind {
if contentType?.conforms(to: .image) == true { return .photo }
if contentType?.conforms(to: .movie) == true { return .video }
if contentType?.conforms(to: .audio) == true { return .audio }
let fallback = UTType(
filenameExtension: URL(fileURLWithPath: filename).pathExtension
)
if fallback?.conforms(to: .image) == true { return .photo }
if fallback?.conforms(to: .movie) == true { return .video }
if fallback?.conforms(to: .audio) == true { return .audio }
return .attachment
}
}
@@ -0,0 +1,207 @@
import AVFoundation
import Combine
import Foundation
extension Notification.Name {
static let lumaExclusiveMediaPlayback = Notification.Name("app.luma.exclusiveMediaPlayback")
}
@MainActor
final class MediaPlaybackCoordinator: ObservableObject {
@Published private(set) var currentMessageID: String?
@Published private(set) var isPlaying = false
@Published private(set) var currentTime: TimeInterval = 0
@Published private(set) var duration: TimeInterval = 0
@Published private(set) var voiceRate: Double = 1
private var player: AVPlayer?
private var timeObserver: Any?
private var endObserver: NSObjectProtocol?
private var currentIsVoice = false
private var durationTask: Task<Void, Never>?
deinit {
durationTask?.cancel()
if let timeObserver, let player {
player.removeTimeObserver(timeObserver)
}
if let endObserver {
NotificationCenter.default.removeObserver(endObserver)
}
}
func toggle(
messageID: String,
url: URL,
durationHint: TimeInterval?,
isVoice: Bool
) {
if currentMessageID != messageID {
prepare(
messageID: messageID,
url: url,
durationHint: durationHint,
isVoice: isVoice
)
}
guard player != nil else { return }
isPlaying ? pause() : play()
}
func seek(
messageID: String,
url: URL,
durationHint: TimeInterval?,
isVoice: Bool,
fraction: Double
) {
if currentMessageID != messageID {
prepare(
messageID: messageID,
url: url,
durationHint: durationHint,
isVoice: isVoice
)
}
guard let player else { return }
let targetDuration = max(duration, durationHint ?? 0)
guard targetDuration > 0 else { return }
let seconds = max(0, min(1, fraction)) * targetDuration
currentTime = seconds
player.seek(
to: CMTime(seconds: seconds, preferredTimescale: 600),
toleranceBefore: .zero,
toleranceAfter: .zero
)
}
func cycleVoiceRate() {
if voiceRate < 1.25 {
voiceRate = 1.5
} else if voiceRate < 1.75 {
voiceRate = 2
} else {
voiceRate = 1
}
guard currentIsVoice, isPlaying else { return }
player?.playImmediately(atRate: Float(voiceRate))
}
func stop() {
durationTask?.cancel()
durationTask = nil
player?.pause()
removeObservers()
player = nil
currentMessageID = nil
isPlaying = false
currentTime = 0
duration = 0
currentIsVoice = false
}
private func prepare(
messageID: String,
url: URL,
durationHint: TimeInterval?,
isVoice: Bool
) {
stop()
let player = AVPlayer(url: url)
player.actionAtItemEnd = .pause
self.player = player
currentMessageID = messageID
currentIsVoice = isVoice
currentTime = 0
duration = Self.validDuration(durationHint) ?? 0
installObservers(for: player)
if duration == 0 {
durationTask = Task { [weak self] in
let asset = AVURLAsset(url: url)
guard let time = try? await asset.load(.duration),
!Task.isCancelled else { return }
let seconds = time.seconds
guard seconds.isFinite, seconds > 0,
self?.currentMessageID == messageID else { return }
self?.duration = seconds
}
}
}
private func play() {
guard let player, let currentMessageID else { return }
configureAudioSession()
NotificationCenter.default.post(
name: .lumaExclusiveMediaPlayback,
object: currentMessageID
)
if currentIsVoice {
player.playImmediately(atRate: Float(voiceRate))
} else {
player.play()
}
isPlaying = true
}
private func pause() {
player?.pause()
isPlaying = false
}
private func installObservers(for player: AVPlayer) {
timeObserver = player.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.1, preferredTimescale: 600),
queue: .main
) { [weak self, weak player] time in
Task { @MainActor in
guard let self, let player else { return }
let seconds = time.seconds
if seconds.isFinite {
self.currentTime = max(0, seconds)
}
if let itemDuration = player.currentItem?.duration.seconds,
itemDuration.isFinite,
itemDuration > 0 {
self.duration = itemDuration
}
}
}
endObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: player.currentItem,
queue: .main
) { [weak self, weak player] _ in
Task { @MainActor in
player?.seek(to: .zero)
self?.currentTime = 0
self?.isPlaying = false
}
}
}
private func removeObservers() {
if let timeObserver, let player {
player.removeTimeObserver(timeObserver)
}
timeObserver = nil
if let endObserver {
NotificationCenter.default.removeObserver(endObserver)
}
endObserver = nil
}
private func configureAudioSession() {
#if os(iOS)
let session = AVAudioSession.sharedInstance()
let mode: AVAudioSession.Mode = currentIsVoice ? .spokenAudio : .default
try? session.setCategory(.playback, mode: mode, options: [.allowAirPlay, .allowBluetoothA2DP])
try? session.setActive(true)
#endif
}
private static func validDuration(_ value: TimeInterval?) -> TimeInterval? {
guard let value, value.isFinite, value > 0 else { return nil }
return value
}
}
@@ -0,0 +1,195 @@
import AVFoundation
import Foundation
import ImageIO
import UniformTypeIdentifiers
actor MediaPreviewProcessor {
struct Analysis: Sendable {
let duration: TimeInterval?
let thumbnailData: Data?
let waveform: [Float]?
}
static let placeholderWaveform: [Float] = [
0.24, 0.42, 0.30, 0.58, 0.76, 0.48, 0.34, 0.67,
0.88, 0.61, 0.40, 0.73, 0.52, 0.31, 0.64, 0.82,
0.45, 0.69, 0.91, 0.56, 0.38, 0.71, 0.49, 0.79,
0.57, 0.35, 0.62, 0.86, 0.53, 0.72, 0.43, 0.65,
0.84, 0.51, 0.32, 0.59, 0.75, 0.46, 0.68, 0.39,
0.80, 0.55, 0.36, 0.70, 0.47, 0.63, 0.78, 0.44
]
func analyze(
url: URL,
kind: ChatMessage.Kind,
mimeType: String? = nil
) async -> Analysis {
let asset = Self.mediaAsset(url: url, mimeType: mimeType)
let duration: TimeInterval?
if [.video, .audio, .voice, .videoNote].contains(kind) {
duration = await mediaDuration(for: asset)
} else {
duration = nil
}
let thumbnailData: Data?
if kind == .photo {
thumbnailData = imageThumbnailData(for: url)
} else if kind == .video || kind == .videoNote {
thumbnailData = await videoThumbnailData(
for: asset,
duration: duration
)
} else {
thumbnailData = nil
}
let waveform = kind == .voice ? voiceWaveform(for: url) : nil
return Analysis(
duration: duration,
thumbnailData: thumbnailData,
waveform: waveform
)
}
private func mediaDuration(for asset: AVURLAsset) async -> TimeInterval? {
guard let time = try? await asset.load(.duration) else { return nil }
let seconds = time.seconds
return seconds.isFinite && seconds > 0 ? seconds : nil
}
private func videoThumbnailData(
for asset: AVURLAsset,
duration: TimeInterval?
) async -> Data? {
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
generator.maximumSize = CGSize(width: 960, height: 960)
generator.requestedTimeToleranceBefore = CMTime(
seconds: 0.35,
preferredTimescale: 600
)
generator.requestedTimeToleranceAfter = CMTime(
seconds: 0.35,
preferredTimescale: 600
)
let safeDuration = duration.flatMap { value in
value.isFinite && value > 0 ? value : nil
}
let preferredSecond = safeDuration.map {
min(1, max(0.01, $0 * 0.25))
} ?? 0.5
let earlySecond = safeDuration.map {
min(0.05, max(0, $0 * 0.1))
} ?? 0.05
var attemptedMilliseconds: Set<Int> = []
for second in [preferredSecond, earlySecond, 0] {
let key = Int((second * 1_000).rounded())
guard attemptedMilliseconds.insert(key).inserted else { continue }
do {
let result = try await generator.image(
at: CMTime(seconds: second, preferredTimescale: 600)
)
if let data = jpegData(from: result.image) {
return data
}
} catch {
continue
}
}
return nil
}
private func imageThumbnailData(for url: URL) -> Data? {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: 1_200,
kCGImageSourceShouldCacheImmediately: true
]
guard let image = CGImageSourceCreateThumbnailAtIndex(
source,
0,
options as CFDictionary
) else { return nil }
return jpegData(from: image)
}
private func jpegData(from image: CGImage) -> Data? {
let output = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(
output as CFMutableData,
UTType.jpeg.identifier as CFString,
1,
nil
) else { return nil }
let options = [kCGImageDestinationLossyCompressionQuality: 0.82] as CFDictionary
CGImageDestinationAddImage(destination, image, options)
guard CGImageDestinationFinalize(destination) else { return nil }
return output as Data
}
private static func mediaAsset(url: URL, mimeType: String?) -> AVURLAsset {
guard let mimeType, !mimeType.isEmpty else {
return AVURLAsset(url: url)
}
// Supplying the MIME type is important for picker files whose temporary
// extension does not describe the real QuickTime/HEVC container.
return AVURLAsset(
url: url,
options: ["AVURLAssetOutOfBandMIMETypeKey": mimeType]
)
}
private func voiceWaveform(for url: URL, sampleCount: Int = 48) -> [Float]? {
do {
let file = try AVAudioFile(
forReading: url,
commonFormat: .pcmFormatFloat32,
interleaved: false
)
let totalFrames = max(1, file.length)
let capacity: AVAudioFrameCount = 4_096
guard let buffer = AVAudioPCMBuffer(
pcmFormat: file.processingFormat,
frameCapacity: capacity
) else { return nil }
var peaks = Array(repeating: Float.zero, count: sampleCount)
var processedFrames: AVAudioFramePosition = 0
while processedFrames < totalFrames {
let remaining = totalFrames - processedFrames
let requested = AVAudioFrameCount(min(AVAudioFramePosition(capacity), remaining))
try file.read(into: buffer, frameCount: requested)
let frameLength = Int(buffer.frameLength)
guard frameLength > 0, let channels = buffer.floatChannelData else { break }
let channelCount = Int(buffer.format.channelCount)
for frame in 0..<frameLength {
let absoluteFrame = processedFrames + AVAudioFramePosition(frame)
let bin = min(
sampleCount - 1,
Int(absoluteFrame * AVAudioFramePosition(sampleCount) / totalFrames)
)
var peak = Float.zero
for channel in 0..<channelCount {
peak = max(peak, abs(channels[channel][frame]))
}
peaks[bin] = max(peaks[bin], peak)
}
processedFrames += AVAudioFramePosition(frameLength)
}
guard let maximum = peaks.max(), maximum > 0 else {
return Self.placeholderWaveform
}
return peaks.map { peak in
max(0.08, min(1, sqrt(peak / maximum)))
}
} catch {
return Self.placeholderWaveform
}
}
}
@@ -0,0 +1,37 @@
import Foundation
import UserNotifications
final class NotificationCoordinator: NSObject, UNUserNotificationCenterDelegate {
override init() {
super.init()
// UNUserNotificationCenter keeps a weak delegate. AppModel retains this
// coordinator for the lifetime of the application.
UNUserNotificationCenter.current().delegate = self
}
func requestAuthorization() async {
_ = try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound])
}
func showIncomingMessage(id: String, sender: String, body: String, conversationID: String) {
let content = UNMutableNotificationContent()
content.title = sender
content.body = body
content.sound = .default
content.threadIdentifier = conversationID
content.userInfo = ["conversation": conversationID]
let request = UNNotificationRequest(identifier: id, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
}
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
// System notifications are normally hidden while an app is in the
// foreground. Explicitly request the same banner/list/sound treatment
// used when Luma is backgrounded.
[.banner, .list, .sound]
}
}
@@ -0,0 +1,405 @@
import Foundation
struct PhoneWatchSnapshot: Codable {
struct Chat: Codable {
let jid: String
let name: String
let unread: Int
let messages: [Message]
}
struct Message: Codable, Identifiable {
let id: String
let body: String
let timestamp: Date
let outgoing: Bool
let encrypted: Bool
}
let chats: [Chat]
}
struct WatchVoiceMessage: Sendable {
let transferID: String
let jid: String
let filename: String
let duration: TimeInterval?
let data: Data
var stableMessageID: String {
"watch-\(transferID)"
}
}
#if os(iOS) && canImport(WatchConnectivity)
import WatchConnectivity
final class PhoneWatchBridge: NSObject, WCSessionDelegate {
var onReply: ((String, String) -> Void)?
var onVoiceMessage: ((WatchVoiceMessage) -> Void)? {
didSet { deliverPendingVoiceMessages() }
}
private let session: WCSession?
private let snapshotQueue = DispatchQueue(
label: "app.luma.watch-snapshot",
qos: .utility
)
private var latestSnapshot: Data?
private var pendingVoiceMessages: [WatchVoiceMessage] = []
private var deliveredVoiceTransferIDs: Set<String> = []
override init() {
session = WCSession.isSupported() ? .default : nil
super.init()
pendingVoiceMessages = Self.loadPersistedVoiceMessages()
session?.delegate = self
session?.activate()
}
func update(conversations: [Conversation], messages: [ChatMessage]) {
guard let session else { return }
// Copy-on-write snapshots are cheap here; the filtering, sorting and
// JSON encoding are not. Keep that work away from SwiftUI's main run
// loop, especially immediately after a MAM page is committed.
snapshotQueue.async { [weak self, weak session] in
guard let self,
let session,
let data = Self.makeSnapshotData(
conversations: conversations,
messages: messages
),
data != self.latestSnapshot else { return }
self.latestSnapshot = data
self.publishLatestSnapshot(using: session)
}
}
private static func makeSnapshotData(
conversations: [Conversation],
messages: [ChatMessage]
) -> Data? {
let visibleConversations = Array(conversations.prefix(20))
let visibleConversationIDs = Set(visibleConversations.map(\.id))
var recentMessagesByConversation: [String: [ChatMessage]] = [:]
// Keep a bounded, chronologically sorted window. MAM can append an old
// overlapping stanza after a newer live message, so array order alone
// cannot identify the latest messages reliably.
for message in messages where visibleConversationIDs.contains(message.conversationID) {
var recent = recentMessagesByConversation[message.conversationID] ?? []
let insertionIndex = Self.insertionIndex(for: message, in: recent)
recent.insert(message, at: insertionIndex)
if recent.count > 20 {
recent.removeFirst(recent.count - 20)
}
recentMessagesByConversation[message.conversationID] = recent
}
let chats = visibleConversations.map { conversation in
PhoneWatchSnapshot.Chat(
jid: conversation.jid,
name: conversation.displayName,
unread: conversation.unreadCount,
messages: (recentMessagesByConversation[conversation.id] ?? [])
.map {
PhoneWatchSnapshot.Message(
id: $0.id,
body: $0.previewText,
timestamp: $0.timestamp,
outgoing: $0.direction == .outgoing,
encrypted: $0.security == .omemo
)
}
)
}
return try? JSONEncoder.watchEncoder.encode(PhoneWatchSnapshot(chats: chats))
}
private static func insertionIndex(
for message: ChatMessage,
in sortedMessages: [ChatMessage]
) -> Int {
var lowerBound = 0
var upperBound = sortedMessages.count
while lowerBound < upperBound {
let middle = lowerBound + (upperBound - lowerBound) / 2
let existing = sortedMessages[middle]
let existingComesFirst = existing.timestamp < message.timestamp
|| (existing.timestamp == message.timestamp && existing.id < message.id)
if existingComesFirst {
lowerBound = middle + 1
} else {
upperBound = middle
}
}
return lowerBound
}
func session(
_ session: WCSession,
activationDidCompleteWith activationState: WCSessionActivationState,
error: Error?
) {
guard activationState == .activated else { return }
snapshotQueue.async { [weak self, weak session] in
guard let self, let session else { return }
self.publishLatestSnapshot(using: session)
}
}
func sessionDidBecomeInactive(_ session: WCSession) {}
func sessionDidDeactivate(_ session: WCSession) {
session.activate()
}
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
consume(message)
}
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
consume(userInfo)
}
func session(_ session: WCSession, didReceive file: WCSessionFile) {
guard let metadata = file.metadata,
metadata["action"] as? String == "voice",
let transferID = metadata["transferID"] as? String,
UUID(uuidString: transferID) != nil,
let jid = metadata["jid"] as? String,
!jid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
do {
// WCSession owns this URL only for the duration of the callback.
// Copy the bytes before handing the recording to the async upload
// pipeline so the temporary file cannot disappear underneath it.
let data = try Data(contentsOf: file.fileURL)
guard !data.isEmpty else { throw WatchVoiceBridgeError.emptyRecording }
let duration = (metadata["duration"] as? NSNumber)?.doubleValue
let filename = (metadata["filename"] as? String).flatMap { $0.isEmpty ? nil : $0 }
?? file.fileURL.lastPathComponent
let message = WatchVoiceMessage(
transferID: transferID,
jid: jid,
filename: filename,
duration: duration,
data: data
)
try Self.persistVoiceMessage(message)
DispatchQueue.main.async { [weak self] in
self?.deliverVoiceMessage(message)
}
} catch {
reportVoiceResult(
transferID: transferID,
success: false,
error: "iPhone не смог прочитать запись: \(error.localizedDescription)",
jid: jid
)
}
}
func reportVoiceResult(
transferID: String,
success: Bool,
error: String? = nil,
jid explicitJID: String? = nil
) {
guard let session else { return }
let jid = explicitJID ?? Self.persistedVoiceJID(for: transferID)
Self.removePersistedVoiceMessage(transferID: transferID)
deliveredVoiceTransferIDs.remove(transferID)
var payload: [String: Any] = [
"action": "voiceResult",
"transferID": transferID,
"success": success
]
if let jid, !jid.isEmpty {
payload["jid"] = jid
}
if let error, !error.isEmpty {
payload["error"] = error
}
if session.isReachable {
session.sendMessage(payload, replyHandler: nil) { _ in
session.transferUserInfo(payload)
}
} else {
session.transferUserInfo(payload)
}
}
private func consume(_ payload: [String: Any]) {
guard payload["action"] as? String == "reply",
let jid = payload["jid"] as? String,
let text = payload["text"] as? String,
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
onReply?(jid, text)
}
private func publishLatestSnapshot(using session: WCSession) {
guard let latestSnapshot else { return }
try? session.updateApplicationContext(["snapshot": latestSnapshot])
}
private func deliverVoiceMessage(_ message: WatchVoiceMessage) {
if let onVoiceMessage {
guard deliveredVoiceTransferIDs.insert(message.transferID).inserted else { return }
onVoiceMessage(message)
} else if !pendingVoiceMessages.contains(where: { $0.transferID == message.transferID }) {
pendingVoiceMessages.append(message)
}
}
private func deliverPendingVoiceMessages() {
guard let onVoiceMessage, !pendingVoiceMessages.isEmpty else { return }
let pending = pendingVoiceMessages
pendingVoiceMessages.removeAll()
for message in pending where deliveredVoiceTransferIDs.insert(message.transferID).inserted {
onVoiceMessage(message)
}
}
private static func persistVoiceMessage(_ message: WatchVoiceMessage) throws {
let directory = try watchVoiceOutboxDirectory()
let audioFilename = "\(message.transferID).m4a"
let audioURL = directory.appendingPathComponent(audioFilename)
let metadataURL = directory.appendingPathComponent("\(message.transferID).json")
let stored = StoredWatchVoiceMessage(
transferID: message.transferID,
jid: message.jid,
filename: message.filename,
duration: message.duration,
audioFilename: audioFilename,
createdAt: Date()
)
do {
try message.data.write(to: audioURL, options: [.atomic])
try JSONEncoder().encode(stored).write(to: metadataURL, options: [.atomic])
try? FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
ofItemAtPath: audioURL.path
)
try? FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
ofItemAtPath: metadataURL.path
)
} catch {
try? FileManager.default.removeItem(at: audioURL)
try? FileManager.default.removeItem(at: metadataURL)
throw error
}
}
private static func loadPersistedVoiceMessages() -> [WatchVoiceMessage] {
guard let directory = try? watchVoiceOutboxDirectory() else { return [] }
let metadataURLs = (try? FileManager.default.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil
)) ?? []
let expiration = Date().addingTimeInterval(-7 * 24 * 60 * 60)
return metadataURLs
.filter { $0.pathExtension.lowercased() == "json" }
.compactMap { metadataURL in
guard let metadataData = try? Data(contentsOf: metadataURL),
let stored = try? JSONDecoder().decode(
StoredWatchVoiceMessage.self,
from: metadataData
),
stored.createdAt >= expiration else {
removePersistedVoiceMessage(
transferID: metadataURL.deletingPathExtension().lastPathComponent
)
return nil
}
let audioURL = directory.appendingPathComponent(stored.audioFilename)
guard let data = try? Data(contentsOf: audioURL), !data.isEmpty else {
removePersistedVoiceMessage(transferID: stored.transferID)
return nil
}
return WatchVoiceMessage(
transferID: stored.transferID,
jid: stored.jid,
filename: stored.filename,
duration: stored.duration,
data: data
)
}
.sorted { $0.transferID < $1.transferID }
}
private static func persistedVoiceJID(for transferID: String) -> String? {
guard let directory = try? watchVoiceOutboxDirectory(),
let data = try? Data(contentsOf: directory.appendingPathComponent("\(transferID).json")),
let stored = try? JSONDecoder().decode(StoredWatchVoiceMessage.self, from: data) else {
return nil
}
return stored.jid
}
private static func removePersistedVoiceMessage(transferID: String) {
guard UUID(uuidString: transferID) != nil,
let directory = try? watchVoiceOutboxDirectory() else { return }
let metadataURL = directory.appendingPathComponent("\(transferID).json")
if let data = try? Data(contentsOf: metadataURL),
let stored = try? JSONDecoder().decode(StoredWatchVoiceMessage.self, from: data) {
try? FileManager.default.removeItem(
at: directory.appendingPathComponent(stored.audioFilename)
)
} else {
try? FileManager.default.removeItem(
at: directory.appendingPathComponent("\(transferID).m4a")
)
}
try? FileManager.default.removeItem(at: metadataURL)
}
private static func watchVoiceOutboxDirectory() throws -> URL {
let base = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first ?? FileManager.default.temporaryDirectory
let directory = base.appendingPathComponent("Luma/WatchVoiceOutbox", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return directory
}
}
private struct StoredWatchVoiceMessage: Codable {
let transferID: String
let jid: String
let filename: String
let duration: TimeInterval?
let audioFilename: String
let createdAt: Date
}
#else
final class PhoneWatchBridge {
var onReply: ((String, String) -> Void)?
var onVoiceMessage: ((WatchVoiceMessage) -> Void)?
func update(conversations: [Conversation], messages: [ChatMessage]) {}
func reportVoiceResult(transferID: String, success: Bool, error: String? = nil) {}
}
#endif
private extension JSONEncoder {
static var watchEncoder: JSONEncoder {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .millisecondsSince1970
return encoder
}
}
private enum WatchVoiceBridgeError: LocalizedError {
case emptyRecording
var errorDescription: String? {
"Файл голосового сообщения пуст."
}
}
@@ -0,0 +1,930 @@
@preconcurrency import AVFoundation
import Combine
import Foundation
#if os(iOS)
import UIKit
#endif
@MainActor
final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRecordingDelegate {
struct Recording {
let url: URL
let duration: TimeInterval
}
@Published private(set) var isPrepared = false
@Published private(set) var isRecording = false
@Published private(set) var elapsed: TimeInterval = 0
@Published private(set) var isUsingFrontCamera = true
@Published private(set) var isMicrophoneMuted = false
let session = AVCaptureSession()
var onFinished: ((Result<Recording, Error>) -> Void)?
var hasRequiredAuthorization: Bool {
AVCaptureDevice.authorizationStatus(for: .video) == .authorized
&& AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
}
private let movieOutput = AVCaptureMovieFileOutput()
private let sessionQueue = DispatchQueue(
label: "app.luma.video-note.capture-session",
qos: .userInitiated
)
private let fileInspector = VideoNoteFileInspector()
private var cameraInput: AVCaptureDeviceInput?
private var microphoneInput: AVCaptureDeviceInput?
private var timer: Timer?
private var startedAt: Date?
private var discardCurrentRecording = false
private var stopRequested = false
private var requestedMinimumDuration: TimeInterval = 0
private var sessionGeneration = UUID()
private var lifecycle: VideoNoteRecordingLifecycle = .idle
private var activeRecordingURL: URL?
private var stopRequestTask: Task<Void, Never>?
private var startupTimeoutTask: Task<Void, Never>?
private var finalizationTimeoutTask: Task<Void, Never>?
private var fileValidationTask: Task<Void, Never>?
private struct CaptureGraph {
let cameraInput: AVCaptureDeviceInput
let microphoneInput: AVCaptureDeviceInput
let usesFrontCamera: Bool
}
private struct MovieOutputSnapshot: Sendable {
let isRecording: Bool
let recordedDuration: TimeInterval
}
func prepare() async throws {
try await waitForPreviousRecordingToFinish()
if lifecycle == .prepared, isPrepared { return }
guard lifecycle == .idle, !isPrepared else {
throw VideoNoteRecorderError.recordingBusy
}
let generation = UUID()
sessionGeneration = generation
await waitForPendingSessionOperations()
guard !Task.isCancelled, sessionGeneration == generation else {
throw CancellationError()
}
guard await Self.requestAccess(for: .video) else {
throw VideoNoteRecorderError.cameraPermissionDenied
}
guard !Task.isCancelled, sessionGeneration == generation else {
throw CancellationError()
}
guard await Self.requestAccess(for: .audio) else {
throw VideoNoteRecorderError.microphonePermissionDenied
}
guard !Task.isCancelled, sessionGeneration == generation else {
throw CancellationError()
}
var didPrepare = false
defer {
if !didPrepare {
performShutdown()
}
}
#if os(iOS)
try AVAudioSession.sharedInstance().setCategory(
.playAndRecord,
mode: .videoRecording,
options: [.defaultToSpeaker, .allowBluetoothHFP]
)
try AVAudioSession.sharedInstance().setActive(true)
#endif
let graph = try await configureCaptureGraph()
guard !Task.isCancelled, sessionGeneration == generation else {
throw CancellationError()
}
cameraInput = graph.cameraInput
microphoneInput = graph.microphoneInput
isUsingFrontCamera = graph.usesFrontCamera
configureVideoConnection()
try await startCaptureSession(generation: generation)
isPrepared = true
lifecycle = .prepared
didPrepare = true
}
func start() throws {
guard lifecycle == .prepared,
isPrepared,
session.isRunning else { throw VideoNoteRecorderError.notPrepared }
guard !movieOutput.isRecording,
activeRecordingURL == nil else {
throw VideoNoteRecorderError.recordingBusy
}
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("LumaRecordings", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent("video-note-\(UUID().uuidString).mov")
discardCurrentRecording = false
stopRequested = false
requestedMinimumDuration = 0
activeRecordingURL = url
lifecycle = .starting
startedAt = nil
elapsed = 0
isRecording = true
let output = movieOutput
let delegate = self
sessionQueue.async {
output.startRecording(to: url, recordingDelegate: delegate)
}
scheduleStartupTimeout(for: url)
}
func stop() {
requestStop(afterMinimumDuration: VideoNoteStopPolicy.minimumCaptureDuration)
}
func stop(afterMinimumDuration minimumDuration: TimeInterval) async {
requestStop(afterMinimumDuration: minimumDuration)
}
func cancel() {
discardCurrentRecording = true
stopRequested = true
requestedMinimumDuration = 0
switch lifecycle {
case .starting, .recording:
lifecycle = .stopping
stopRequestTask?.cancel()
stopRequestTask = Task { @MainActor [weak self] in
guard let self else { return }
await self.stopMovieOutput()
guard !Task.isCancelled,
self.lifecycle == .stopping,
let url = self.activeRecordingURL else { return }
self.scheduleFinalizationTimeout(for: url)
}
case .stopping:
break
case .idle, .prepared:
performShutdown()
}
}
func switchCamera() async throws {
#if os(iOS)
guard isPrepared,
lifecycle == .prepared,
let currentInput = cameraInput else {
throw VideoNoteRecorderError.notPrepared
}
let nextPosition: AVCaptureDevice.Position = isUsingFrontCamera ? .back : .front
guard let camera = AVCaptureDevice.default(
.builtInWideAngleCamera,
for: .video,
position: nextPosition
) else {
throw VideoNoteRecorderError.alternateCameraUnavailable
}
let nextInput = try AVCaptureDeviceInput(device: camera)
let session = session
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<Void, Error>) in
sessionQueue.async {
session.beginConfiguration()
session.removeInput(currentInput)
guard session.canAddInput(nextInput) else {
if session.canAddInput(currentInput) {
session.addInput(currentInput)
}
session.commitConfiguration()
continuation.resume(throwing: VideoNoteRecorderError.configurationFailed)
return
}
session.addInput(nextInput)
session.commitConfiguration()
continuation.resume()
}
}
cameraInput = nextInput
isUsingFrontCamera = nextPosition == .front
configureVideoConnection()
#else
throw VideoNoteRecorderError.alternateCameraUnavailable
#endif
}
func toggleMicrophoneMuted() {
guard isPrepared, let connection = movieOutput.connection(with: .audio) else { return }
isMicrophoneMuted.toggle()
connection.isEnabled = !isMicrophoneMuted
}
func shutdown() {
if lifecycle.isBusy || movieOutput.isRecording {
cancel()
return
}
performShutdown()
}
nonisolated func fileOutput(
_ output: AVCaptureFileOutput,
didStartRecordingTo fileURL: URL,
from connections: [AVCaptureConnection]
) {
Task { @MainActor [weak self] in
self?.recordingDidStart(url: fileURL)
}
}
nonisolated func fileOutput(
_ output: AVCaptureFileOutput,
didFinishRecordingTo outputFileURL: URL,
from connections: [AVCaptureConnection],
error: Error?
) {
Task { @MainActor [weak self] in
self?.recordingDidFinish(url: outputFileURL, error: error)
}
}
private func requestStop(afterMinimumDuration minimumDuration: TimeInterval) {
guard isRecording,
lifecycle == .starting || lifecycle == .recording else { return }
stopRequested = true
requestedMinimumDuration = max(
requestedMinimumDuration,
max(0, minimumDuration)
)
scheduleStopAfterActualStartIfNeeded()
}
private func recordingDidStart(url: URL) {
guard VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: activeRecordingURL,
outputURL: url
), isRecording,
lifecycle == .starting || lifecycle == .stopping else { return }
startupTimeoutTask?.cancel()
startupTimeoutTask = nil
if startedAt == nil {
startedAt = Date()
}
if lifecycle == .stopping {
// Cancellation can be requested while startRecording is still
// crossing the capture queue. If the first stop observed
// isRecording == false, stop again after the real didStart rather
// than leaving an orphaned capture running until the watchdog.
stopRequestTask?.cancel()
stopRequestTask = Task { @MainActor [weak self] in
guard let self else { return }
await self.stopMovieOutput()
guard !Task.isCancelled,
self.lifecycle == .stopping,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
) else { return }
self.scheduleFinalizationTimeout(for: url)
}
return
}
lifecycle = .recording
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
Task { @MainActor in
guard let self, let startedAt = self.startedAt else { return }
self.elapsed = min(
VideoNoteStopPolicy.maximumCaptureDuration,
Date().timeIntervalSince(startedAt)
)
}
}
scheduleStopAfterActualStartIfNeeded()
}
private func scheduleStopAfterActualStartIfNeeded() {
guard stopRequested,
lifecycle == .recording,
startedAt != nil,
let url = activeRecordingURL else { return }
stopRequestTask?.cancel()
let minimumDuration = requestedMinimumDuration
let mediaDeadline = Date().addingTimeInterval(
VideoNoteStopPolicy.recordedDurationWaitTimeout
)
stopRequestTask = Task { @MainActor [weak self] in
while true {
guard !Task.isCancelled,
let self,
self.lifecycle == .recording,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
) else { return }
let snapshot = await self.movieOutputSnapshot()
guard !Task.isCancelled,
self.lifecycle == .recording,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
) else { return }
if !snapshot.isRecording {
// AVFoundation stopped without the user's stop reaching
// the output. Wait for didFinish, but never let this state
// remain without a finalization watchdog.
self.lifecycle = .stopping
self.scheduleFinalizationTimeout(for: url)
return
}
let remaining = VideoNoteStopPolicy.remainingRecordedDuration(
recordedDuration: snapshot.recordedDuration,
minimumDuration: minimumDuration
)
if remaining <= 0 || Date() >= mediaDeadline { break }
do {
try await Task.sleep(
nanoseconds: min(
VideoNoteStopPolicy.recordedDurationPollNanoseconds,
UInt64((remaining * 1_000_000_000).rounded(.up))
)
)
} catch {
return
}
}
guard !Task.isCancelled,
let self,
self.lifecycle == .recording,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
) else { return }
self.lifecycle = .stopping
await self.stopMovieOutput()
guard !Task.isCancelled,
self.lifecycle == .stopping,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
) else { return }
self.scheduleFinalizationTimeout(for: url)
}
}
private func recordingDidFinish(url: URL, error: Error?) {
guard VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: activeRecordingURL,
outputURL: url
) else {
// A delayed callback from an earlier attempt must not tear down a
// newer capture graph. Do not delete it here: macOS can spell the
// same temporary URL through a filesystem alias, and preserving a
// doubtful callback is safer than deleting the active movie.
return
}
stopRequestTask?.cancel()
stopRequestTask = nil
startupTimeoutTask?.cancel()
startupTimeoutTask = nil
finalizationTimeoutTask?.cancel()
finalizationTimeoutTask = nil
timer?.invalidate()
timer = nil
isRecording = false
lifecycle = .stopping
let fallbackDuration = measuredWallClockDuration
let shouldDiscard = discardCurrentRecording
let wasStopRequested = stopRequested
if shouldDiscard {
try? FileManager.default.removeItem(at: url)
finishAndReset(nil)
return
}
fileValidationTask?.cancel()
fileValidationTask = Task { @MainActor [weak self] in
guard let self else { return }
let inspection = await self.fileInspector.inspect(
url: url,
fallbackDuration: fallbackDuration
)
guard !Task.isCancelled,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
) else { return }
self.fileValidationTask = nil
if self.discardCurrentRecording {
try? FileManager.default.removeItem(at: url)
self.finishAndReset(nil)
return
}
if let inspection,
VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit(
stopRequested: wasStopRequested,
error: error,
recordedDuration: inspection.duration,
maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration
),
VideoNoteStopPolicy.isValidFinalDuration(inspection.duration) {
self.finishAndReset(.success(Recording(
url: url,
duration: inspection.duration
)))
return
}
try? FileManager.default.removeItem(at: url)
let failure: Error
if !wasStopRequested,
!VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit(
stopRequested: false,
error: error,
recordedDuration: inspection?.duration ?? fallbackDuration,
maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration
) {
failure = VideoNoteRecorderError.recordingInterrupted
} else if !VideoNoteRecordingCompletionPolicy.shouldKeepOutput(for: error), let error {
failure = error
} else {
failure = VideoNoteRecorderError.emptyRecording
}
self.finishAndReset(.failure(failure))
}
}
private var measuredWallClockDuration: TimeInterval {
guard let startedAt else { return max(0, elapsed) }
return max(elapsed, Date().timeIntervalSince(startedAt))
}
private func stopMovieOutput() async {
let output = movieOutput
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
sessionQueue.async {
if output.isRecording {
output.stopRecording()
}
continuation.resume()
}
}
}
private func movieOutputSnapshot() async -> MovieOutputSnapshot {
let output = movieOutput
return await withCheckedContinuation { continuation in
sessionQueue.async {
let seconds = CMTimeGetSeconds(output.recordedDuration)
continuation.resume(returning: MovieOutputSnapshot(
isRecording: output.isRecording,
recordedDuration: seconds.isFinite && seconds > 0 ? seconds : 0
))
}
}
}
private func flushCaptureSessionForFinalization() async {
let output = movieOutput
let session = session
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
sessionQueue.async {
if output.isRecording {
output.stopRecording()
}
if session.isRunning {
session.stopRunning()
}
continuation.resume()
}
}
}
private func scheduleStartupTimeout(for url: URL) {
startupTimeoutTask?.cancel()
startupTimeoutTask = Task { @MainActor [weak self] in
try? await Task.sleep(
nanoseconds: VideoNoteStopPolicy.startupTimeoutNanoseconds
)
guard !Task.isCancelled,
let self,
self.lifecycle == .starting,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
) else { return }
self.lifecycle = .stopping
await self.stopMovieOutput()
guard !Task.isCancelled,
self.lifecycle == .stopping,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
) else { return }
self.scheduleFinalizationTimeout(for: url)
}
}
private func scheduleFinalizationTimeout(for url: URL) {
finalizationTimeoutTask?.cancel()
finalizationTimeoutTask = Task { @MainActor [weak self] in
try? await Task.sleep(
nanoseconds: VideoNoteStopPolicy.finalizationTimeoutNanoseconds
)
guard !Task.isCancelled,
let self,
self.lifecycle == .stopping,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
) else { return }
// A few macOS camera drivers stop the file output but delay its
// delegate callback until the capture session itself is flushed.
// This is recovery-only; the normal path still waits for
// didFinishRecording before touching the session.
await self.flushCaptureSessionForFinalization()
try? await Task.sleep(
nanoseconds: VideoNoteStopPolicy.finalizationRecoveryGraceNanoseconds
)
guard !Task.isCancelled,
self.lifecycle == .stopping,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
) else { return }
let fallbackDuration = self.measuredWallClockDuration
let inspection = await self.fileInspector.inspect(
url: url,
fallbackDuration: fallbackDuration
)
guard !Task.isCancelled,
self.lifecycle == .stopping,
VideoNoteRecordingLifecycle.acceptsCompletion(
activeURL: self.activeRecordingURL,
outputURL: url
) else { return }
self.finalizationTimeoutTask = nil
if self.discardCurrentRecording {
try? FileManager.default.removeItem(at: url)
self.finishAndReset(nil)
} else if let inspection,
VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit(
stopRequested: self.stopRequested,
error: nil,
recordedDuration: inspection.duration,
maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration
),
VideoNoteStopPolicy.isValidFinalDuration(inspection.duration) {
// Some macOS camera drivers finalize the .mov but omit the
// delegate callback. A playable file is more authoritative
// than the missing callback and remains safe to send.
self.finishAndReset(.success(Recording(
url: url,
duration: inspection.duration
)))
} else if !self.stopRequested {
try? FileManager.default.removeItem(at: url)
self.finishAndReset(.failure(VideoNoteRecorderError.recordingInterrupted))
} else {
try? FileManager.default.removeItem(at: url)
self.finishAndReset(.failure(VideoNoteRecorderError.finalizationTimedOut))
}
}
}
private func finishAndReset(_ result: Result<Recording, Error>?) {
fileValidationTask = nil
let completion = onFinished
performShutdown()
if let result {
completion?(result)
}
}
private func performShutdown() {
sessionGeneration = UUID()
stopRequestTask?.cancel()
stopRequestTask = nil
startupTimeoutTask?.cancel()
startupTimeoutTask = nil
finalizationTimeoutTask?.cancel()
finalizationTimeoutTask = nil
fileValidationTask?.cancel()
fileValidationTask = nil
timer?.invalidate()
timer = nil
resetCaptureGraph()
lifecycle = .idle
activeRecordingURL = nil
discardCurrentRecording = false
stopRequested = false
requestedMinimumDuration = 0
startedAt = nil
elapsed = 0
isPrepared = false
isRecording = false
isUsingFrontCamera = true
isMicrophoneMuted = false
#if os(iOS)
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
#endif
}
private func waitForPreviousRecordingToFinish() async throws {
guard lifecycle != .starting, lifecycle != .recording else {
throw VideoNoteRecorderError.recordingBusy
}
let deadline = Date().addingTimeInterval(10)
while lifecycle == .stopping, Date() < deadline {
try await Task.sleep(nanoseconds: 25_000_000)
}
guard lifecycle != .stopping else {
throw VideoNoteRecorderError.finalizationTimedOut
}
}
private func configureVideoConnection() {
guard let connection = movieOutput.connection(with: .video) else { return }
#if os(iOS)
configureInteroperableVideoCodec(for: connection)
#endif
if connection.isVideoMirroringSupported {
connection.automaticallyAdjustsVideoMirroring = false
connection.isVideoMirrored = isUsingFrontCamera
}
#if os(iOS)
if connection.isVideoOrientationSupported {
connection.videoOrientation = Self.currentVideoOrientation
}
#endif
}
#if os(iOS)
private func configureInteroperableVideoCodec(for connection: AVCaptureConnection) {
guard movieOutput.availableVideoCodecTypes.contains(.h264) else { return }
movieOutput.setOutputSettings(
[AVVideoCodecKey: AVVideoCodecType.h264],
for: connection
)
}
#endif
private func resetCaptureGraph() {
let session = session
sessionQueue.async {
if session.isRunning {
session.stopRunning()
}
guard !session.inputs.isEmpty || !session.outputs.isEmpty else { return }
session.beginConfiguration()
session.inputs.forEach { session.removeInput($0) }
session.outputs.forEach { session.removeOutput($0) }
session.commitConfiguration()
}
cameraInput = nil
microphoneInput = nil
}
private func waitForPendingSessionOperations() async {
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
sessionQueue.async {
continuation.resume()
}
}
}
private func configureCaptureGraph() async throws -> CaptureGraph {
let session = session
let movieOutput = movieOutput
return try await withCheckedThrowingContinuation { continuation in
sessionQueue.async {
session.beginConfiguration()
do {
session.sessionPreset = session.canSetSessionPreset(.vga640x480)
? .vga640x480
: .medium
#if os(iOS)
let camera = AVCaptureDevice.default(
.builtInWideAngleCamera,
for: .video,
position: .front
) ?? AVCaptureDevice.default(for: .video)
#else
let camera = AVCaptureDevice.default(for: .video)
#endif
guard let camera else {
throw VideoNoteRecorderError.cameraUnavailable
}
let cameraInput = try AVCaptureDeviceInput(device: camera)
guard session.canAddInput(cameraInput) else {
throw VideoNoteRecorderError.configurationFailed
}
session.addInput(cameraInput)
guard let microphone = AVCaptureDevice.default(for: .audio) else {
throw VideoNoteRecorderError.microphoneUnavailable
}
let microphoneInput = try AVCaptureDeviceInput(device: microphone)
guard session.canAddInput(microphoneInput) else {
throw VideoNoteRecorderError.configurationFailed
}
session.addInput(microphoneInput)
guard session.canAddOutput(movieOutput) else {
throw VideoNoteRecorderError.configurationFailed
}
session.addOutput(movieOutput)
movieOutput.maxRecordedDuration = CMTime(
seconds: VideoNoteStopPolicy.maximumCaptureDuration,
preferredTimescale: 600
)
session.commitConfiguration()
continuation.resume(returning: CaptureGraph(
cameraInput: cameraInput,
microphoneInput: microphoneInput,
usesFrontCamera: camera.position == .front
))
} catch {
session.commitConfiguration()
continuation.resume(throwing: error)
}
}
}
}
private func startCaptureSession(generation: UUID) async throws {
let session = session
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
sessionQueue.async {
if !session.isRunning {
session.startRunning()
}
continuation.resume()
}
}
guard !Task.isCancelled,
sessionGeneration == generation,
session.isRunning else {
throw CancellationError()
}
}
private static func requestAccess(for mediaType: AVMediaType) async -> Bool {
switch AVCaptureDevice.authorizationStatus(for: mediaType) {
case .authorized:
return true
case .denied, .restricted:
return false
case .notDetermined:
return await AVCaptureDevice.requestAccess(for: mediaType)
@unknown default:
return false
}
}
}
private actor VideoNoteFileInspector {
struct Inspection: Sendable {
let duration: TimeInterval
}
func inspect(url: URL, fallbackDuration: TimeInterval) async -> Inspection? {
let retryDelays: [UInt64] = [0, 120_000_000, 300_000_000]
for delay in retryDelays {
if delay > 0 {
try? await Task.sleep(nanoseconds: delay)
}
guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]),
values.isRegularFile == true,
let size = values.fileSize,
size > 1_024 else { continue }
let asset = AVURLAsset(url: url)
guard let duration = try? await asset.load(.duration),
duration.seconds.isFinite,
duration.seconds > 0.04,
let tracks = try? await asset.loadTracks(withMediaType: .video),
!tracks.isEmpty else { continue }
return Inspection(duration: duration.seconds)
}
// Keep a non-empty file only when AVFoundation reported enough wall
// clock time to make a useful fallback. This branch covers a small set
// of macOS camera drivers that delay movie metadata longer than the
// delegate callback itself.
guard fallbackDuration > 0.25,
let values = try? url.resourceValues(forKeys: [.fileSizeKey]),
let size = values.fileSize,
size > 16_384 else { return nil }
return Inspection(duration: fallbackDuration)
}
}
enum VideoNoteStopPolicy {
static let minimumCaptureDuration: TimeInterval = 1.2
static let minimumValidFileDuration: TimeInterval = 0.8
static let maximumCaptureDuration: TimeInterval = 60
static let recordedDurationWaitTimeout: TimeInterval = 5
static let recordedDurationPollNanoseconds: UInt64 = 80_000_000
static let startupTimeoutNanoseconds: UInt64 = 6_000_000_000
static let finalizationTimeoutNanoseconds: UInt64 = 12_000_000_000
static let finalizationRecoveryGraceNanoseconds: UInt64 = 1_000_000_000
static func remainingRecordedDuration(
recordedDuration: TimeInterval,
minimumDuration: TimeInterval
) -> TimeInterval {
max(0, max(0, minimumDuration) - max(0, recordedDuration))
}
static func isValidFinalDuration(_ duration: TimeInterval) -> Bool {
duration.isFinite && duration >= minimumValidFileDuration
}
}
#if os(iOS)
private extension VideoNoteRecorder {
static var currentVideoOrientation: AVCaptureVideoOrientation {
switch UIDevice.current.orientation {
case .portraitUpsideDown:
return .portraitUpsideDown
case .landscapeLeft:
return .landscapeRight
case .landscapeRight:
return .landscapeLeft
default:
return .portrait
}
}
}
#endif
private enum VideoNoteRecorderError: LocalizedError {
case cameraPermissionDenied
case microphonePermissionDenied
case cameraUnavailable
case alternateCameraUnavailable
case microphoneUnavailable
case configurationFailed
case notPrepared
case recordingBusy
case recordingInterrupted
case finalizationTimedOut
case emptyRecording
var errorDescription: String? {
switch self {
case .cameraPermissionDenied:
return "Разрешите Luma доступ к камере в системных настройках."
case .microphonePermissionDenied:
return "Разрешите Luma доступ к микрофону в системных настройках."
case .cameraUnavailable:
return "Камера недоступна."
case .alternateCameraUnavailable:
return "Вторая камера недоступна."
case .microphoneUnavailable:
return "Микрофон недоступен."
case .configurationFailed:
return "Не удалось настроить запись видеосообщения."
case .notPrepared:
return "Камера ещё не готова."
case .recordingBusy:
return "Предыдущая запись ещё завершается. Подождите секунду и повторите."
case .recordingInterrupted:
return "Камера прервала запись раньше времени. Видеосообщение не отправлено. Повторите запись."
case .finalizationTimedOut:
return "Камера не смогла завершить видеофайл. Повторите запись."
case .emptyRecording:
return "Камера создала пустой или нечитаемый видеофайл. Повторите запись."
}
}
}
@@ -0,0 +1,293 @@
import Foundation
import SwiftUI
#if os(iOS)
import UIKit
private typealias DraftThumbnailImage = UIImage
#elseif os(macOS)
import AppKit
private typealias DraftThumbnailImage = NSImage
#endif
@MainActor
struct AttachmentPreviewView: View {
@ObservedObject var model: AppModel
let conversation: Conversation
@Binding var drafts: [AttachmentDraft]
@Environment(\.dismiss) private var dismiss
@State private var caption = ""
@State private var isSending = false
@State private var sendStatusMessage: String?
@State private var previewItem: MediaViewerItem?
@State private var thumbnailImages: [UUID: DraftThumbnailImage] = [:]
private let columns = [GridItem(.adaptive(minimum: 128, maximum: 190), spacing: 18)]
var body: some View {
NavigationStack {
VStack(spacing: 0) {
ScrollView {
LazyVGrid(columns: columns, spacing: 22) {
ForEach(drafts) { draft in
draftCard(draft)
}
}
.padding(16)
}
Divider()
VStack(spacing: 10) {
TextField("Добавить подпись…", text: $caption, axis: .vertical)
.lineLimit(1...4)
.textFieldStyle(.plain)
.padding(.horizontal, 13)
.padding(.vertical, 10)
.background(Color.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 16))
.disabled(isSending)
if let sendStatusMessage {
Label(sendStatusMessage, systemImage: "arrow.clockwise.circle.fill")
.font(.caption)
.foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading)
}
HStack {
Text(summary)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button {
send()
} label: {
Label(isSending ? "Отправка…" : "Отправить", systemImage: "arrow.up.circle.fill")
.fontWeight(.semibold)
}
.buttonStyle(.borderedProminent)
.disabled(drafts.isEmpty || isSending)
}
}
.padding(14)
.background(.ultraThinMaterial)
}
.navigationTitle("Перед отправкой")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Отмена") { cancel() }
.disabled(isSending)
}
}
}
#if os(macOS)
.frame(minWidth: 560, minHeight: 560)
#endif
.interactiveDismissDisabled(isSending || previewItem != nil)
.task(id: drafts.map(\.id)) {
await cacheDraftThumbnails()
}
.overlay {
if let previewItem {
MediaViewer(item: previewItem) {
self.previewItem = nil
}
.zIndex(100)
}
}
.onDisappear {
guard !isSending, !drafts.isEmpty else { return }
model.discardAttachmentDrafts(drafts)
drafts = []
}
}
private func draftCard(_ draft: AttachmentDraft) -> some View {
VStack(alignment: .leading, spacing: 7) {
ZStack(alignment: .topTrailing) {
Button {
presentPreview(for: draft)
} label: {
ZStack {
Color.secondary.opacity(0.1)
thumbnail(for: draft)
.padding(6)
if draft.kind == .video {
Image(systemName: "play.fill")
.font(.title3.weight(.bold))
.foregroundStyle(.white)
.padding(11)
.background(.black.opacity(0.45), in: Circle())
}
}
.frame(maxWidth: .infinity)
.aspectRatio(1, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 15, style: .continuous)
.stroke(Color.secondary.opacity(0.13), lineWidth: 1)
}
}
.buttonStyle(.plain)
.disabled(!canPreview(draft) || isSending)
.accessibilityLabel("Предпросмотр \(draft.filename)")
Button {
remove(draft)
} label: {
Image(systemName: "xmark")
.font(.caption.bold())
.foregroundStyle(.white)
.frame(width: 25, height: 25)
.background(.black.opacity(0.62), in: Circle())
}
.buttonStyle(.plain)
.padding(7)
.accessibilityLabel("Убрать \(draft.filename)")
}
Text(draft.filename)
.font(.caption.weight(.semibold))
.lineLimit(1)
Text(detail(for: draft))
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.padding(9)
.background(.background, in: RoundedRectangle(cornerRadius: 19, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 19, style: .continuous)
.stroke(Color.secondary.opacity(0.15), lineWidth: 1)
}
.shadow(color: .black.opacity(0.045), radius: 5, y: 2)
}
@ViewBuilder
private func thumbnail(for draft: AttachmentDraft) -> some View {
#if os(iOS)
if let image = thumbnailImages[draft.id] {
Image(uiImage: image)
.resizable()
.scaledToFit()
} else {
filePlaceholder(for: draft)
}
#elseif os(macOS)
if let image = thumbnailImages[draft.id] {
Image(nsImage: image)
.resizable()
.scaledToFit()
} else {
filePlaceholder(for: draft)
}
#endif
}
private func filePlaceholder(for draft: AttachmentDraft) -> some View {
ZStack {
LinearGradient(
colors: [Color.accentColor.opacity(0.18), Color.secondary.opacity(0.08)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Image(systemName: icon(for: draft.kind))
.font(.system(size: 38, weight: .medium))
.foregroundStyle(.tint)
}
}
private var summary: String {
let bytes = drafts.reduce(0) { $0 + $1.byteCount }
return "\(drafts.count) · \(ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file))"
}
private func detail(for draft: AttachmentDraft) -> String {
var values = [ByteCountFormatter.string(fromByteCount: Int64(draft.byteCount), countStyle: .file)]
if let duration = draft.duration {
let seconds = max(0, Int(duration.rounded()))
values.insert(String(format: "%d:%02d", seconds / 60, seconds % 60), at: 0)
}
return values.joined(separator: " · ")
}
private func icon(for kind: ChatMessage.Kind) -> String {
switch kind {
case .photo: return "photo.fill"
case .video: return "video.fill"
case .audio: return "music.note"
case .voice: return "waveform"
case .videoNote: return "video.circle.fill"
case .attachment: return "doc.fill"
case .location: return "location.fill"
case .text, .system: return "doc"
}
}
private func remove(_ draft: AttachmentDraft) {
model.discardAttachmentDrafts([draft])
drafts.removeAll { $0.id == draft.id }
thumbnailImages.removeValue(forKey: draft.id)
if drafts.isEmpty { dismiss() }
}
private func cacheDraftThumbnails() async {
let retainedIDs = Set(drafts.map(\.id))
thumbnailImages = thumbnailImages.filter { retainedIDs.contains($0.key) }
for draft in drafts where thumbnailImages[draft.id] == nil {
guard let data = draft.thumbnailData,
let image = DraftThumbnailImage(data: data) else { continue }
thumbnailImages[draft.id] = image
await Task.yield()
}
}
private func canPreview(_ draft: AttachmentDraft) -> Bool {
draft.kind == .photo || draft.kind == .video
}
private func presentPreview(for draft: AttachmentDraft) {
guard canPreview(draft), FileManager.default.fileExists(atPath: draft.url.path) else { return }
previewItem = MediaViewerItem(
id: draft.id.uuidString,
url: draft.url,
kind: draft.kind,
title: draft.filename
)
}
private func cancel() {
model.discardAttachmentDrafts(drafts)
drafts = []
dismiss()
}
private func send() {
guard !isSending, !drafts.isEmpty else { return }
let outgoingDrafts = drafts
sendStatusMessage = nil
isSending = true
Task {
let result = await model.sendAttachmentBatch(
outgoingDrafts,
caption: caption,
to: conversation.jid
)
isSending = false
if result.failedDrafts.isEmpty {
drafts = []
dismiss()
} else {
drafts = result.failedDrafts
if result.sentCount > 0 {
sendStatusMessage = "Отправлено: \(result.sentCount). Не удалось: \(result.failedDrafts.count). Нажмите «Отправить», чтобы повторить оставшиеся."
} else {
sendStatusMessage = "Не удалось отправить. Файлы сохранены здесь — проверьте соединение и повторите попытку."
}
}
}
}
}
+243
View File
@@ -0,0 +1,243 @@
import Foundation
import SwiftUI
import WebRTC
@MainActor
struct CallView: View {
@ObservedObject var model: AppModel
let call: CallSnapshot
private var conversation: Conversation {
model.conversations.first(where: { $0.id == call.peerJID.lowercased() })
?? Conversation(jid: call.peerJID, displayName: model.displayName(for: call.peerJID))
}
private var remoteTrack: RTCVideoTrack? {
model.remoteCallVideoTrack(for: call.id)
}
private var localTrack: RTCVideoTrack? {
model.localCallVideoTrack(for: call.id)
}
var body: some View {
ZStack {
background
VStack(spacing: 24) {
callHeader
.padding(.top, 22)
Spacer(minLength: 12)
if remoteTrack == nil {
contactIdentity
}
Spacer(minLength: 20)
controls
.padding(.bottom, 28)
}
.padding(.horizontal, 24)
if call.isVideoCall, let localTrack, call.isCameraEnabled {
RTCVideoRendererView(track: localTrack, mirrored: true)
.frame(width: 118, height: 168)
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 22, style: .continuous)
.stroke(.white.opacity(0.24), lineWidth: 1)
}
.shadow(color: .black.opacity(0.38), radius: 16, y: 8)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
.padding(.top, 92)
.padding(.trailing, 18)
}
}
.foregroundStyle(.white)
.accessibilityElement(children: .contain)
}
@ViewBuilder
private var background: some View {
if call.isVideoCall, let remoteTrack {
RTCVideoRendererView(track: remoteTrack)
.ignoresSafeArea()
.overlay {
LinearGradient(
colors: [
.black.opacity(0.48),
.clear,
.black.opacity(0.62)
],
startPoint: .top,
endPoint: .bottom
)
}
} else {
LinearGradient(
colors: [Color(red: 0.11, green: 0.17, blue: 0.24), .black],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.overlay {
Circle()
.fill(Color.accentColor.opacity(0.24))
.frame(width: 440, height: 440)
.blur(radius: 90)
.offset(x: 170, y: -260)
}
.ignoresSafeArea()
}
}
private var callHeader: some View {
VStack(spacing: 5) {
Text(conversation.displayName)
.font(.headline)
.lineLimit(1)
TimelineView(.periodic(from: .now, by: 1)) { context in
Text(statusText(at: context.date))
.font(.subheadline.monospacedDigit())
.foregroundStyle(.white.opacity(0.72))
}
}
.frame(maxWidth: .infinity)
}
private var contactIdentity: some View {
VStack(spacing: 18) {
AvatarView(
conversation: conversation,
imageData: model.avatarData(for: call.peerJID),
size: 132
)
.overlay {
Circle().stroke(.white.opacity(0.14), lineWidth: 1)
}
.shadow(color: .black.opacity(0.32), radius: 28, y: 14)
Text(conversation.displayName)
.font(.system(size: 30, weight: .semibold, design: .rounded))
.multilineTextAlignment(.center)
.lineLimit(2)
Text(call.isVideoCall ? "Видеозвонок Luma" : "Аудиозвонок Luma")
.font(.subheadline)
.foregroundStyle(.white.opacity(0.66))
}
}
@ViewBuilder
private var controls: some View {
if call.isIncomingRinging {
HStack(spacing: 52) {
CallControlButton(
title: "Отклонить",
systemImage: "phone.down.fill",
color: .red,
action: model.rejectCall
)
CallControlButton(
title: "Ответить",
systemImage: call.isVideoCall ? "video.fill" : "phone.fill",
color: .green
) {
Task { await model.answerCall() }
}
}
} else {
VStack(spacing: 22) {
HStack(spacing: call.isVideoCall ? 10 : 24) {
CallControlButton(
title: call.isMuted ? "Включить" : "Микрофон",
systemImage: call.isMuted ? "mic.slash.fill" : "mic.fill",
color: call.isMuted ? .white.opacity(0.34) : .white.opacity(0.18)
) {
model.setCallMuted(!call.isMuted)
}
#if os(iOS)
CallControlButton(
title: "Динамик",
systemImage: call.isSpeakerEnabled ? "speaker.wave.3.fill" : "speaker.fill",
color: call.isSpeakerEnabled ? .white.opacity(0.34) : .white.opacity(0.18)
) {
model.setCallSpeakerEnabled(!call.isSpeakerEnabled)
}
#endif
if call.isVideoCall {
CallControlButton(
title: call.isCameraEnabled ? "Камера" : "Включить",
systemImage: call.isCameraEnabled ? "video.fill" : "video.slash.fill",
color: call.isCameraEnabled ? .white.opacity(0.18) : .white.opacity(0.34)
) {
model.setCallCameraEnabled(!call.isCameraEnabled)
}
CallControlButton(
title: "Повернуть",
systemImage: "camera.rotate.fill",
color: .white.opacity(0.18),
action: model.switchCallCamera
)
}
}
CallControlButton(
title: "Завершить",
systemImage: "phone.down.fill",
color: .red,
action: model.endCall
)
}
}
}
private func statusText(at date: Date) -> String {
switch call.phase {
case .ringing:
return call.direction == .incoming ? "Входящий звонок" : "Вызов…"
case .connecting:
return "Соединение…"
case .connected:
guard let connectedAt = call.connectedAt else { return "Соединено" }
let total = max(0, Int(date.timeIntervalSince(connectedAt)))
let hours = total / 3_600
let minutes = (total % 3_600) / 60
let seconds = total % 60
return hours > 0
? String(format: "%d:%02d:%02d", hours, minutes, seconds)
: String(format: "%02d:%02d", minutes, seconds)
}
}
}
private struct CallControlButton: View {
let title: String
let systemImage: String
let color: Color
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: 8) {
Circle()
.fill(color)
.frame(width: 58, height: 58)
.overlay {
Image(systemName: systemImage)
.font(.system(size: 24, weight: .semibold))
}
Text(title)
.font(.caption)
.lineLimit(1)
}
.frame(minWidth: 58)
}
.buttonStyle(.plain)
.foregroundStyle(.white)
.accessibilityLabel(title)
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,307 @@
import Foundation
import SwiftUI
@MainActor
struct AudioMessagePlayer: View {
@ObservedObject var model: AppModel
@ObservedObject private var playback: MediaPlaybackCoordinator
let message: ChatMessage
let onFallbackTap: () -> Void
init(
model: AppModel,
message: ChatMessage,
onFallbackTap: @escaping () -> Void
) {
self.model = model
_playback = ObservedObject(wrappedValue: model.audioPlayback)
self.message = message
self.onFallbackTap = onFallbackTap
}
var body: some View {
Group {
if message.kind == .voice {
voicePlayer
} else {
musicPlayer
}
}
.frame(width: 252, alignment: .leading)
.task(id: message.remoteAttachmentURL) {
await model.prepareMediaPreview(message)
}
.accessibilityElement(children: .contain)
}
private var voicePlayer: some View {
HStack(spacing: 10) {
playButton
VStack(alignment: .leading, spacing: 3) {
WaveformScrubber(
samples: model.audioWaveform(for: message),
progress: progress,
activeColor: accent,
inactiveColor: secondaryAccent,
onSeek: seek
)
.frame(height: 30)
HStack(spacing: 7) {
Text(voiceTimeText)
.font(.caption2.monospacedDigit())
.foregroundStyle(secondaryText)
Spacer()
Button(action: playback.cycleVoiceRate) {
Text(rateText)
.font(.caption2.monospacedDigit().weight(.bold))
.foregroundStyle(accent)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(accent.opacity(0.14), in: Capsule())
}
.buttonStyle(.plain)
.accessibilityLabel("Скорость воспроизведения \(rateText)")
}
}
}
}
private var musicPlayer: some View {
HStack(spacing: 11) {
playButton
VStack(alignment: .leading, spacing: 4) {
Text(message.localFilename ?? "Аудиофайл")
.font(.subheadline.weight(.semibold))
.foregroundStyle(primaryText)
.lineLimit(1)
MediaProgressScrubber(
progress: progress,
activeColor: accent,
inactiveColor: secondaryAccent,
onSeek: seek
)
.frame(height: 12)
HStack(spacing: 6) {
Text(audioTimeText)
if let byteCount = message.byteCount {
Text("·")
Text(ByteCountFormatter.string(
fromByteCount: Int64(byteCount),
countStyle: .file
))
}
}
.font(.caption2.monospacedDigit())
.foregroundStyle(secondaryText)
}
}
}
private var playButton: some View {
Button(action: togglePlayback) {
ZStack {
Circle()
.fill(accent.opacity(message.direction == .outgoing ? 0.2 : 0.14))
if model.isMediaPreviewLoading(message) && localURL == nil {
ProgressView()
.tint(accent)
.controlSize(.small)
} else {
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 18, weight: .bold))
.foregroundStyle(accent)
.offset(x: isPlaying ? 0 : 1)
}
}
.frame(width: 44, height: 44)
}
.buttonStyle(.plain)
.accessibilityLabel(isPlaying ? "Пауза" : "Воспроизвести")
}
private var localURL: URL? {
model.mediaPreviewURL(for: message)
}
private var isCurrent: Bool {
playback.currentMessageID == message.id
}
private var isPlaying: Bool {
isCurrent && playback.isPlaying
}
private var totalDuration: TimeInterval {
let value = isCurrent && playback.duration > 0
? playback.duration
: (message.duration ?? 0)
return max(0, value)
}
private var displayedTime: TimeInterval {
isCurrent ? playback.currentTime : 0
}
private var progress: Double {
guard isCurrent, totalDuration > 0 else { return 0 }
return max(0, min(1, playback.currentTime / totalDuration))
}
private var voiceTimeText: String {
if isCurrent, playback.currentTime > 0 {
return "\(MediaTimeFormatter.string(displayedTime)) / \(MediaTimeFormatter.string(totalDuration))"
}
return MediaTimeFormatter.string(totalDuration)
}
private var audioTimeText: String {
"\(MediaTimeFormatter.string(displayedTime)) / \(MediaTimeFormatter.string(totalDuration))"
}
private var rateText: String {
if playback.voiceRate < 1.25 { return "1×" }
if playback.voiceRate < 1.75 { return "1.5×" }
return "2×"
}
private var accent: Color {
message.direction == .outgoing ? .white : .accentColor
}
private var secondaryAccent: Color {
message.direction == .outgoing ? .white.opacity(0.30) : Color.secondary.opacity(0.24)
}
private var primaryText: Color {
message.direction == .outgoing ? .white : .primary
}
private var secondaryText: Color {
message.direction == .outgoing ? .white.opacity(0.78) : .secondary
}
private func togglePlayback() {
guard let localURL else {
Task {
await model.prepareMediaPreview(message)
guard let downloadedURL = model.mediaPreviewURL(for: message) else {
onFallbackTap()
return
}
playback.toggle(
messageID: message.id,
url: downloadedURL,
durationHint: message.duration,
isVoice: message.kind == .voice
)
}
return
}
playback.toggle(
messageID: message.id,
url: localURL,
durationHint: message.duration,
isVoice: message.kind == .voice
)
}
private func seek(_ fraction: Double) {
guard let localURL else { return }
playback.seek(
messageID: message.id,
url: localURL,
durationHint: message.duration,
isVoice: message.kind == .voice,
fraction: fraction
)
}
}
private struct WaveformScrubber: View {
let samples: [Float]
let progress: Double
let activeColor: Color
let inactiveColor: Color
let onSeek: (Double) -> Void
var body: some View {
GeometryReader { geometry in
HStack(alignment: .center, spacing: 2) {
ForEach(Array(samples.enumerated()), id: \.offset) { index, sample in
Capsule()
.fill(sampleProgress(index) <= progress ? activeColor : inactiveColor)
.frame(maxWidth: .infinity)
.frame(height: max(3, geometry.size.height * CGFloat(sample)))
}
}
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
onSeek(fraction(at: value.location.x, width: geometry.size.width))
}
)
}
.accessibilityLabel("Позиция голосового сообщения")
.accessibilityValue("\(Int(progress * 100)) процентов")
}
private func sampleProgress(_ index: Int) -> Double {
guard samples.count > 1 else { return 0 }
return Double(index) / Double(samples.count - 1)
}
}
private struct MediaProgressScrubber: View {
let progress: Double
let activeColor: Color
let inactiveColor: Color
let onSeek: (Double) -> Void
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
Capsule().fill(inactiveColor).frame(height: 3)
Capsule()
.fill(activeColor)
.frame(width: geometry.size.width * CGFloat(progress), height: 3)
Circle()
.fill(activeColor)
.frame(width: 9, height: 9)
.offset(x: max(0, geometry.size.width * CGFloat(progress) - 4.5))
}
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
onSeek(fraction(at: value.location.x, width: geometry.size.width))
}
)
}
.accessibilityLabel("Позиция аудиофайла")
.accessibilityValue("\(Int(progress * 100)) процентов")
}
}
private func fraction(at x: CGFloat, width: CGFloat) -> Double {
guard width > 0 else { return 0 }
return max(0, min(1, Double(x / width)))
}
enum MediaTimeFormatter {
static func string(_ duration: TimeInterval) -> String {
guard duration.isFinite else { return "0:00" }
let seconds = max(0, Int(duration.rounded(.down)))
if seconds >= 3_600 {
return String(format: "%d:%02d:%02d", seconds / 3_600, (seconds / 60) % 60, seconds % 60)
}
return String(format: "%d:%02d", seconds / 60, seconds % 60)
}
}
@@ -0,0 +1,92 @@
import Foundation
import SwiftUI
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
struct AvatarView: View {
let conversation: Conversation
var imageData: Data?
var size: CGFloat = 50
var body: some View {
ZStack(alignment: .bottomTrailing) {
avatarContent
.frame(width: size, height: size)
.clipShape(Circle())
if isActive {
Circle()
.fill(.green)
.frame(width: size * 0.25, height: size * 0.25)
.overlay(Circle().stroke(.background, lineWidth: 2.5))
}
}
.accessibilityLabel(accessibilityText)
}
@ViewBuilder
private var avatarContent: some View {
#if os(iOS)
if let imageData, let image = UIImage(data: imageData) {
Image(uiImage: image)
.resizable()
.scaledToFill()
} else {
fallback
}
#elseif os(macOS)
if let imageData, let image = NSImage(data: imageData) {
Image(nsImage: image)
.resizable()
.scaledToFill()
} else {
fallback
}
#endif
}
private var fallback: some View {
Circle()
.fill(
LinearGradient(
colors: [baseColor, baseColor.opacity(0.68)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.overlay {
if conversation.isGroup {
Image(systemName: "person.3.fill")
.font(.system(size: size * 0.31, weight: .semibold))
.foregroundStyle(.white)
} else {
Text(conversation.initials)
.font(.system(size: size * 0.35, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
}
}
}
private var isActive: Bool {
conversation.isGroup ? conversation.isGroupJoined : conversation.isOnline
}
private var accessibilityText: String {
if conversation.isGroup {
return "\(conversation.displayName), \(conversation.isGroupJoined ? "подключено" : "не подключено")"
}
return "\(conversation.displayName), \(conversation.isOnline ? "в сети" : "не в сети")"
}
private var baseColor: Color {
Color(
hue: Double(conversation.colorSeed % 360) / 360,
saturation: 0.68,
brightness: 0.88
)
}
}
@@ -0,0 +1,52 @@
import SwiftUI
/// Short faded curve under the compact source bubble in a collapsed reply.
struct ReplyThreadCurve: Shape {
let sourceDirection: ChatMessage.Direction
func path(in rect: CGRect) -> Path {
var path = Path()
if sourceDirection == .incoming {
path.move(to: CGPoint(x: rect.minX, y: rect.minY))
path.addCurve(
to: CGPoint(x: rect.maxX, y: rect.maxY),
control1: CGPoint(x: rect.minX + 5, y: rect.maxY * 0.78),
control2: CGPoint(x: rect.midX, y: rect.maxY)
)
} else {
path.move(to: CGPoint(x: rect.maxX, y: rect.minY))
path.addCurve(
to: CGPoint(x: rect.minX, y: rect.maxY),
control1: CGPoint(x: rect.maxX - 5, y: rect.maxY * 0.78),
control2: CGPoint(x: rect.midX, y: rect.maxY)
)
}
return path
}
}
/// Bracket displayed beside a source message and several replies in focus mode.
struct ReplyThreadBracket: Shape {
let opensToTrailing: Bool
func path(in rect: CGRect) -> Path {
let edgeX = opensToTrailing ? rect.maxX : rect.minX
let spineX = opensToTrailing ? rect.minX : rect.maxX
let radius = min(26, rect.width)
var path = Path()
path.move(to: CGPoint(x: edgeX, y: rect.minY))
path.addCurve(
to: CGPoint(x: spineX, y: rect.minY + radius),
control1: CGPoint(x: spineX, y: rect.minY),
control2: CGPoint(x: spineX, y: rect.minY + radius * 0.45)
)
path.addLine(to: CGPoint(x: spineX, y: rect.maxY - radius))
path.addCurve(
to: CGPoint(x: edgeX, y: rect.maxY),
control1: CGPoint(x: spineX, y: rect.maxY - radius * 0.45),
control2: CGPoint(x: spineX, y: rect.maxY)
)
return path
}
}
@@ -0,0 +1,54 @@
import SwiftUI
struct ConnectionBanner: View {
@ObservedObject var model: AppModel
var body: some View {
switch model.connectionStatus {
case .connected:
if model.isArchiveSyncing || !model.isOMEMOReady {
banner(
icon: model.isArchiveSyncing ? "arrow.triangle.2.circlepath" : "lock.rotation",
text: model.isArchiveSyncing ? "Синхронизация истории…" : "Публикация ключей OMEMO…",
color: .blue,
progress: true
)
}
case .connecting:
banner(icon: "antenna.radiowaves.left.and.right", text: "Подключение…", color: .orange, progress: true)
case .disconnected(let reason):
HStack(spacing: 8) {
Image(systemName: "wifi.exclamationmark")
Text(reason ?? "Нет соединения")
.lineLimit(1)
Spacer()
Button("Повторить") { Task { await model.reconnect() } }
.buttonStyle(.borderless)
.fontWeight(.semibold)
}
.font(.caption)
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.red.opacity(0.88))
}
}
private func banner(icon: String, text: String, color: Color, progress: Bool) -> some View {
HStack(spacing: 8) {
if progress {
ProgressView().controlSize(.mini).tint(.white)
} else {
Image(systemName: icon)
}
Text(text)
Spacer()
}
.font(.caption)
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(color.opacity(0.88))
}
}
@@ -0,0 +1,60 @@
import Foundation
import SwiftUI
struct ConversationRow: View {
let conversation: Conversation
let imageData: Data?
let isEncrypted: Bool
var body: some View {
HStack(spacing: 12) {
AvatarView(conversation: conversation, imageData: imageData)
VStack(alignment: .leading, spacing: 5) {
HStack {
Text(conversation.displayName)
.font(.headline)
.lineLimit(1)
Spacer(minLength: 8)
if conversation.lastActivity != .distantPast {
Text(conversation.lastActivity, format: .dateTime.hour().minute())
.font(.caption)
.foregroundStyle(.secondary)
}
}
HStack(spacing: 7) {
if conversation.isGroup {
Image(systemName: "person.3.fill")
.font(.caption2)
.foregroundStyle(.secondary)
} else if !conversation.lastMessage.isEmpty {
Image(systemName: isEncrypted ? "lock.fill" : "lock.open")
.font(.caption2)
.foregroundStyle(isEncrypted ? Color.secondary : Color.orange)
}
Text(conversation.lastMessage.isEmpty ? conversation.jid : conversation.lastMessage)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer(minLength: 8)
if conversation.isGroup, conversation.occupantCount > 0 {
Text("\(conversation.occupantCount)")
.font(.caption2)
.foregroundStyle(.secondary)
}
if conversation.unreadCount > 0 {
Text("\(min(conversation.unreadCount, 99))")
.font(.caption2.bold())
.foregroundStyle(.white)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(.tint, in: Capsule())
}
}
}
}
.contentShape(Rectangle())
.padding(.vertical, 2)
}
}
@@ -0,0 +1,73 @@
import AVFoundation
import SwiftUI
#if os(iOS)
import UIKit
struct InlineVideoPlayer: UIViewRepresentable {
let player: AVPlayer
func makeUIView(context: Context) -> PlayerView {
let view = PlayerView()
view.playerLayer.player = player
return view
}
func updateUIView(_ uiView: PlayerView, context: Context) {
uiView.playerLayer.player = player
}
final class PlayerView: UIView {
override class var layerClass: AnyClass { AVPlayerLayer.self }
var playerLayer: AVPlayerLayer {
layer as! AVPlayerLayer
}
override init(frame: CGRect) {
super.init(frame: frame)
playerLayer.videoGravity = .resizeAspectFill
}
required init?(coder: NSCoder) {
nil
}
}
}
#elseif os(macOS)
import AppKit
struct InlineVideoPlayer: NSViewRepresentable {
let player: AVPlayer
func makeNSView(context: Context) -> PlayerView {
let view = PlayerView()
view.playerLayer.player = player
return view
}
func updateNSView(_ nsView: PlayerView, context: Context) {
nsView.playerLayer.player = player
}
final class PlayerView: NSView {
let playerLayer = AVPlayerLayer()
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
wantsLayer = true
layer = playerLayer
playerLayer.videoGravity = .resizeAspectFill
}
required init?(coder: NSCoder) {
nil
}
override func layout() {
super.layout()
playerLayer.frame = bounds
}
}
}
#endif
@@ -0,0 +1,63 @@
import MapKit
import SwiftUI
struct LocationMessagePreview: View {
let location: GeoLocation
var body: some View {
Button(action: openInMaps) {
ZStack(alignment: .bottomLeading) {
Map(initialPosition: cameraPosition, interactionModes: []) {
Marker("Геопозиция", coordinate: coordinate)
.tint(.red)
}
.allowsHitTesting(false)
HStack(spacing: 7) {
Image(systemName: "location.fill")
VStack(alignment: .leading, spacing: 1) {
Text("Геопозиция")
.font(.subheadline.weight(.semibold))
Text("Открыть в Картах")
.font(.caption)
.opacity(0.82)
}
}
.foregroundStyle(.white)
.padding(.horizontal, 11)
.padding(.vertical, 8)
.background(.black.opacity(0.58), in: RoundedRectangle(cornerRadius: 11))
.padding(9)
}
.frame(width: 252, height: 170)
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.stroke(.primary.opacity(0.1), lineWidth: 1)
)
}
.buttonStyle(.plain)
.accessibilityLabel("Геопозиция")
.accessibilityHint("Открывает точку в Картах")
}
private var coordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: location.latitude, longitude: location.longitude)
}
private var cameraPosition: MapCameraPosition {
.region(
MKCoordinateRegion(
center: coordinate,
latitudinalMeters: 1_500,
longitudinalMeters: 1_500
)
)
}
private func openInMaps() {
let item = MKMapItem(placemark: MKPlacemark(coordinate: coordinate))
item.name = "Геопозиция из Luma"
item.openInMaps()
}
}
@@ -0,0 +1,363 @@
import AVFoundation
import AVKit
import SwiftUI
#if os(iOS)
import UIKit
private typealias MediaViewerPlatformImage = UIImage
#elseif os(macOS)
import AppKit
private typealias MediaViewerPlatformImage = NSImage
#endif
enum MediaViewerDismissGesturePolicy {
private static let verticalDominance: CGFloat = 1.15
static func isVertical(_ translation: CGSize) -> Bool {
let verticalDistance = abs(translation.height)
let horizontalDistance = abs(translation.width)
return verticalDistance >= 8 && verticalDistance > horizontalDistance * verticalDominance
}
static func shouldDismiss(
translation: CGSize,
predictedEndTranslation: CGSize,
containerHeight: CGFloat
) -> Bool {
guard isVertical(translation) else { return false }
let distanceThreshold = min(140, max(88, containerHeight * 0.14))
if abs(translation.height) >= distanceThreshold {
return true
}
return isVertical(predictedEndTranslation)
&& abs(predictedEndTranslation.height) >= distanceThreshold * 1.35
}
}
@MainActor
struct MediaViewer: View {
let item: MediaViewerItem
let onClose: () -> Void
@State private var dragOffset: CGFloat = 0
@State private var isTrackingDismissGesture = false
@State private var isCompletingDismissal = false
@State private var isPhotoZoomed = false
var body: some View {
GeometryReader { geometry in
ZStack {
Color.black
.opacity(backgroundOpacity(in: geometry.size.height))
.ignoresSafeArea()
ZStack {
viewerContent
.ignoresSafeArea(edges: .bottom)
VStack(spacing: 0) {
HStack {
Text(item.title)
.font(.headline)
.foregroundStyle(.white)
.lineLimit(1)
Spacer()
}
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 8)
.background(
LinearGradient(
colors: [.black.opacity(0.72), .clear],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
)
Spacer()
}
}
.offset(y: dragOffset)
.scaleEffect(contentScale(in: geometry.size.height))
}
.contentShape(Rectangle())
.simultaneousGesture(
verticalDismissGesture(in: geometry.size),
including: .all
)
.onChange(of: isPhotoZoomed) { _, isZoomed in
guard isZoomed else { return }
resetDismissGesture(animated: true)
}
.accessibilityAction(.escape) {
closeImmediately()
}
}
#if os(macOS)
.onExitCommand {
closeImmediately()
}
#endif
}
private var canTrackDismissGesture: Bool {
!isPhotoZoomed && !isCompletingDismissal
}
private func verticalDismissGesture(in size: CGSize) -> some Gesture {
DragGesture(minimumDistance: 8, coordinateSpace: .global)
.onChanged { value in
guard canTrackDismissGesture else { return }
if !isTrackingDismissGesture {
guard MediaViewerDismissGesturePolicy.isVertical(value.translation) else { return }
isTrackingDismissGesture = true
}
dragOffset = value.translation.height
}
.onEnded { value in
guard isTrackingDismissGesture else { return }
isTrackingDismissGesture = false
guard canTrackDismissGesture else {
resetDismissGesture(animated: true)
return
}
if MediaViewerDismissGesturePolicy.shouldDismiss(
translation: value.translation,
predictedEndTranslation: value.predictedEndTranslation,
containerHeight: size.height
) {
completeDismissal(
direction: dismissalDirection(for: value),
containerHeight: size.height
)
} else {
resetDismissGesture(animated: true)
}
}
}
private func dismissalDirection(for value: DragGesture.Value) -> CGFloat {
let projected = value.predictedEndTranslation.height
let distance = abs(projected) > abs(value.translation.height)
? projected
: value.translation.height
return distance < 0 ? -1 : 1
}
private func completeDismissal(direction: CGFloat, containerHeight: CGFloat) {
guard !isCompletingDismissal else { return }
isCompletingDismissal = true
withAnimation(.easeOut(duration: 0.18)) {
dragOffset = direction * max(700, containerHeight * 1.15)
}
Task { @MainActor in
try? await Task.sleep(nanoseconds: 150_000_000)
onClose()
}
}
private func resetDismissGesture(animated: Bool) {
isTrackingDismissGesture = false
if animated {
withAnimation(.spring(response: 0.3, dampingFraction: 0.84)) {
dragOffset = 0
}
} else {
dragOffset = 0
}
}
private func closeImmediately() {
guard !isCompletingDismissal else { return }
isCompletingDismissal = true
onClose()
}
private func dismissalProgress(in containerHeight: CGFloat) -> CGFloat {
min(1, abs(dragOffset) / max(1, containerHeight * 0.62))
}
private func backgroundOpacity(in containerHeight: CGFloat) -> Double {
Double(1 - dismissalProgress(in: containerHeight) * 0.72)
}
private func contentScale(in containerHeight: CGFloat) -> CGFloat {
1 - dismissalProgress(in: containerHeight) * 0.045
}
@ViewBuilder
private var viewerContent: some View {
switch item.kind {
case .photo:
ZoomablePhotoView(url: item.url, isZoomed: $isPhotoZoomed)
case .video:
FullWindowVideoPlayer(url: item.url)
default:
ContentUnavailableView(
"Не удалось открыть медиа",
systemImage: "exclamationmark.triangle"
)
.foregroundStyle(.white)
}
}
}
@MainActor
private struct FullWindowVideoPlayer: View {
let url: URL
@State private var player: AVPlayer?
init(url: URL) {
self.url = url
}
var body: some View {
VideoPlayer(player: player)
.task(id: url) {
player?.pause()
let nextPlayer = AVPlayer(url: url)
player = nextPlayer
nextPlayer.play()
}
.onDisappear {
player?.pause()
player = nil
}
}
}
@MainActor
private struct ZoomablePhotoView: View {
let url: URL
@Binding var isZoomed: Bool
@State private var imageValue: MediaViewerPlatformImage?
@State private var hasFinishedLoading = false
@State private var scale: CGFloat = 1
@State private var offset: CGSize = .zero
@GestureState private var gestureScale: CGFloat = 1
@GestureState private var gestureOffset: CGSize = .zero
var body: some View {
GeometryReader { geometry in
ZStack {
Color.black
if let imageValue {
image(imageValue)
.scaledToFit()
.frame(width: geometry.size.width, height: geometry.size.height)
.scaleEffect(effectiveScale)
.offset(effectiveOffset)
.gesture(dragGesture)
.simultaneousGesture(magnifyGesture)
.onTapGesture(count: 2, perform: toggleZoom)
} else if hasFinishedLoading {
invalidImage
} else {
ProgressView()
.tint(.white)
.controlSize(.large)
}
}
.clipped()
}
.onChange(of: effectiveScale) { _, value in
isZoomed = value > 1.01
}
.onDisappear {
isZoomed = false
}
.task(id: url) {
imageValue = nil
hasFinishedLoading = false
let data = await Task.detached(priority: .userInitiated) {
try? Data(contentsOf: url, options: [.mappedIfSafe])
}.value
guard !Task.isCancelled else { return }
imageValue = data.flatMap { MediaViewerPlatformImage(data: $0) }
hasFinishedLoading = true
}
}
private var effectiveScale: CGFloat {
min(5, max(1, scale * gestureScale))
}
private var effectiveOffset: CGSize {
guard effectiveScale > 1 else { return .zero }
return CGSize(
width: offset.width + gestureOffset.width,
height: offset.height + gestureOffset.height
)
}
@ViewBuilder
private func image(_ value: MediaViewerPlatformImage) -> some View {
#if os(iOS)
Image(uiImage: value)
.resizable()
#elseif os(macOS)
Image(nsImage: value)
.resizable()
#endif
}
private var invalidImage: some View {
ContentUnavailableView(
"Фото повреждено",
systemImage: "photo.badge.exclamationmark"
)
.foregroundStyle(.white)
}
private var dragGesture: some Gesture {
DragGesture()
.updating($gestureOffset) { value, state, _ in
guard effectiveScale > 1 else { return }
state = value.translation
}
.onEnded { value in
guard effectiveScale > 1 else {
offset = .zero
return
}
offset.width += value.translation.width
offset.height += value.translation.height
}
}
private var magnifyGesture: some Gesture {
MagnifyGesture()
.updating($gestureScale) { value, state, _ in
state = value.magnification
}
.onEnded { value in
scale = min(5, max(1, scale * value.magnification))
if scale == 1 {
offset = .zero
}
}
}
private func toggleZoom() {
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) {
if scale > 1 {
scale = 1
offset = .zero
} else {
scale = 2
}
}
}
}
@@ -0,0 +1,737 @@
import SwiftUI
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
@MainActor
struct MessageBubble: View {
let model: AppModel
let message: ChatMessage
let isSelectionMode: Bool
let isSelected: Bool
let onAttachmentTap: () -> Void
let onEdit: () -> Void
let onReply: () -> Void
let onForward: () -> Void
let onRetry: () -> Void
let onToggleSelection: () -> Void
let onBeginSelection: () -> Void
let onRetract: () -> Void
let onDelete: () -> Void
let onReplyTap: (String) -> Void
let onReact: (String) -> Void
@State private var replySwipeOffset: CGFloat = 0
@State private var replySwipeArmed = false
var body: some View {
ZStack {
replySwipeIndicator
messageContainer
.offset(x: replySwipeOffset)
}
.frame(maxWidth: .infinity)
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
.contentShape(Rectangle())
.onTapGesture {
guard isSelectionMode else { return }
onToggleSelection()
}
.padding(
.vertical,
message.kind == .photo || message.kind == .video ? 7 : 2
)
.simultaneousGesture(
replySwipeGesture,
including: !isSelectionMode && message.canBeRepliedTo ? .gesture : .none
)
.onChange(of: isSelectionMode) { _, selecting in
guard selecting else { return }
replySwipeOffset = 0
replySwipeArmed = false
}
.contextMenu {
if !isSelectionMode {
if message.canBeReactedTo {
Menu {
ForEach(MessageReactionPolicy.quickChoices, id: \.self) { emoji in
Button(emoji) { onReact(emoji) }
}
} label: {
Label("Реакция", systemImage: "face.smiling")
}
}
if message.canBeRepliedTo {
Button(action: onReply) {
Label("Ответить", systemImage: "arrowshape.turn.up.left")
}
}
if message.canBeForwarded {
Button(action: onForward) {
Label("Переслать", systemImage: "arrowshape.turn.up.right")
}
}
if model.canRetryMediaMessage(message) {
Button(action: onRetry) {
Label("Повторить отправку", systemImage: "arrow.clockwise")
}
}
if message.canBeEdited {
Button(action: onEdit) {
Label("Редактировать", systemImage: "pencil")
}
}
Button(action: onBeginSelection) {
Label("Выбрать", systemImage: "checkmark.circle")
}
Divider()
Button(role: .destructive, action: onDelete) {
Label("Удалить у меня", systemImage: "trash")
}
if message.canBeRetracted {
Button(role: .destructive, action: onRetract) {
Label("Удалить у всех", systemImage: "trash.slash")
}
}
}
}
.accessibilityLabel(Text(selectionAccessibilityLabel))
.accessibilityAction(named: Text(isSelected ? "Убрать из выбранных" : "Выбрать")) {
if isSelectionMode {
onToggleSelection()
} else {
onBeginSelection()
}
}
}
private var messageContainer: some View {
HStack(spacing: 2) {
if isSelectionMode {
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.font(.system(size: 23, weight: .semibold))
.foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
.frame(width: 34)
.padding(.leading, 7)
.accessibilityHidden(true)
}
VStack(spacing: 0) {
if hasReplyReference {
compactReplyReference
}
messageRow
}
.allowsHitTesting(!isSelectionMode)
}
}
private var replySwipeIndicator: some View {
HStack {
if replySwipeOffset >= 0 {
replySwipeIcon
Spacer()
} else {
Spacer()
replySwipeIcon
}
}
.padding(.horizontal, 18)
.allowsHitTesting(false)
.opacity(replySwipeOffset == 0 ? 0 : 1)
}
private var replySwipeIcon: some View {
Image(systemName: replySwipeArmed
? "arrowshape.turn.up.left.fill"
: "arrowshape.turn.up.left")
.font(.system(size: 19, weight: .semibold))
.foregroundStyle(Color.accentColor)
.scaleEffect(
CGFloat(0.8) + CGFloat(0.2) * MessageReplySwipePolicy.progress(for: replySwipeOffset)
)
.opacity(Double(
CGFloat(0.35) + CGFloat(0.65) * MessageReplySwipePolicy.progress(for: replySwipeOffset)
))
}
private var replySwipeGesture: some Gesture {
DragGesture(minimumDistance: 20)
.onChanged { value in
let offset = MessageReplySwipePolicy.offset(for: value.translation)
guard offset != 0 || replySwipeOffset != 0 else {
// A vertical timeline pan must not publish per-cell state
// changes or compete with UIScrollView's own recognizer.
return
}
replySwipeOffset = offset
let armed = MessageReplySwipePolicy.shouldReply(
translation: value.translation,
predictedEndTranslation: value.translation
)
if armed, !replySwipeArmed {
replySwipeFeedback()
}
replySwipeArmed = armed
}
.onEnded { value in
let shouldReply = MessageReplySwipePolicy.shouldReply(
translation: value.translation,
predictedEndTranslation: value.predictedEndTranslation
)
if replySwipeOffset != 0 || replySwipeArmed {
withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) {
replySwipeOffset = 0
replySwipeArmed = false
}
}
if shouldReply {
onReply()
}
}
}
private func replySwipeFeedback() {
#if os(iOS)
UIImpactFeedbackGenerator(style: .light).impactOccurred()
#endif
}
private var selectionAccessibilityLabel: String {
guard isSelectionMode else { return message.previewText }
return "\(message.previewText), \(isSelected ? "выбрано" : "не выбрано")"
}
private var messageRow: some View {
HStack(alignment: .bottom, spacing: 8) {
if message.direction == .outgoing { Spacer(minLength: sideSpacerMinimum) }
VStack(alignment: message.direction == .outgoing ? .trailing : .leading, spacing: 4) {
if message.isGroupMessage, message.direction == .incoming {
Text(message.senderDisplayName ?? model.displayName(for: message.senderJID))
.font(.caption.weight(.semibold))
.foregroundStyle(senderColor)
.padding(.horizontal, 8)
}
messageContent
if !reactionSummaries.isEmpty {
reactionStrip
}
if hasReplyReference {
replyWarningIndicators
} else {
standardMetadata
}
}
if message.direction == .incoming { Spacer(minLength: sideSpacerMinimum) }
}
.padding(.horizontal, 12)
.contentShape(Rectangle())
.onTapGesture {
guard let targetID = message.replyToID,
message.kind == .text || message.kind == .system else { return }
onReplyTap(targetID)
}
}
private var sideSpacerMinimum: CGFloat {
message.kind.isMedia ? 16 : 54
}
@ViewBuilder
private var replyWarningIndicators: some View {
if message.security != .omemo || message.delivery == .failed {
HStack(spacing: 4) {
if message.security != .omemo {
Image(systemName: message.security == .plaintext ? "lock.open" : "exclamationmark.shield")
.foregroundStyle(message.security == .plaintext ? .orange : .red)
}
if message.delivery == .failed {
Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(.red)
}
}
.font(.caption2)
.padding(.horizontal, 4)
}
}
private var standardMetadata: some View {
HStack(spacing: 4) {
if message.callHistory == nil, message.security != .omemo {
Image(systemName: message.security == .plaintext ? "lock.open" : "exclamationmark.shield")
.foregroundStyle(message.security == .plaintext ? .orange : .red)
}
if message.editedAt != nil {
Text("изм.")
}
Text(message.timestamp, format: .dateTime.hour().minute())
if message.direction == .outgoing, message.callHistory == nil {
Image(systemName: deliveryIcon)
.foregroundStyle(message.delivery == .failed ? .red : .secondary)
}
}
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.horizontal, 4)
}
@ViewBuilder
private var messageContent: some View {
if message.isRetracted {
HStack(spacing: 7) {
Image(systemName: "trash.slash")
Text("Сообщение удалено")
.italic()
}
.font(.subheadline)
.padding(.horizontal, 13)
.padding(.vertical, 9)
.foregroundStyle(message.direction == .outgoing ? Color.white.opacity(0.86) : Color.secondary)
.background(bubbleBackground)
.clipShape(messageBubbleShape)
} else {
regularMessageContent
}
}
@ViewBuilder
private var regularMessageContent: some View {
switch message.kind {
case .videoNote:
VideoNotePreview(
model: model,
message: message,
onFallbackTap: onAttachmentTap
)
case .video:
VideoAttachmentPreview(
model: model,
message: message,
onFallbackTap: onAttachmentTap
)
case .audio, .voice:
AudioMessagePlayer(
model: model,
message: message,
onFallbackTap: onAttachmentTap
)
.padding(.horizontal, 13)
.padding(.vertical, 10)
.background(bubbleBackground)
.clipShape(messageBubbleShape)
case .photo:
PhotoAttachmentPreview(
model: model,
message: message,
onFallbackTap: onAttachmentTap
)
case .location:
if let location = GeoLocation(uri: message.body) {
LocationMessagePreview(location: location)
} else {
Text(message.body)
.lumaTextSelection()
.padding(.horizontal, 13)
.padding(.vertical, 9)
.foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary)
.background(bubbleBackground)
.clipShape(messageBubbleShape)
}
case .attachment:
Button(action: onAttachmentTap) {
mediaContent
}
.buttonStyle(.plain)
.padding(.horizontal, 13)
.padding(.vertical, 9)
.foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary)
.background(bubbleBackground)
.clipShape(messageBubbleShape)
case .text, .system:
if let callHistory = message.callHistory {
callHistoryContent(callHistory)
} else {
Text(message.body)
.lumaTextSelection()
.padding(.horizontal, 13)
.padding(.vertical, 9)
.foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary)
.background(bubbleBackground)
.clipShape(messageBubbleShape)
}
}
}
private func callHistoryContent(_ history: CallHistoryMetadata) -> some View {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(callAccentColor(for: history).opacity(
message.direction == .outgoing ? 0.28 : 0.14
))
Image(systemName: history.isVideo ? "video.fill" : "phone.fill")
.font(.system(size: 17, weight: .semibold))
Image(systemName: message.direction == .outgoing ? "arrow.up.right" : "arrow.down.left")
.font(.system(size: 8, weight: .heavy))
.padding(3)
.background(Circle().fill(callDirectionBadgeBackground))
.offset(x: 14, y: 14)
}
.frame(width: 42, height: 42)
.foregroundStyle(callIconForeground(for: history))
VStack(alignment: .leading, spacing: 3) {
Text(message.callTitle)
.font(.subheadline.weight(.semibold))
.lineLimit(2)
if let subtitle = message.callSubtitle {
Text(subtitle)
.font(.caption)
.opacity(0.78)
.lineLimit(1)
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.frame(maxWidth: 300, alignment: .leading)
.foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary)
.background(bubbleBackground)
.clipShape(messageBubbleShape)
.accessibilityElement(children: .combine)
.accessibilityLabel(Text(message.callTitle))
}
private func callAccentColor(for history: CallHistoryMetadata) -> Color {
switch history.outcome {
case .completed:
return .green
case .declined, .missed, .unanswered, .failed:
return .red
case .cancelled, .answeredElsewhere:
return .secondary
}
}
private func callIconForeground(for history: CallHistoryMetadata) -> Color {
message.direction == .outgoing ? .white : callAccentColor(for: history)
}
private var callDirectionBadgeBackground: Color {
message.direction == .outgoing
? Color(red: 0.13, green: 0.57, blue: 0.94)
: Color.secondary.opacity(0.16)
}
private var reactionSummaries: [MessageReactionSummary] {
message.reactionSummaries(ownJID: model.account?.normalizedJID)
}
private var reactionStrip: some View {
HStack(spacing: 5) {
ForEach(reactionSummaries) { summary in
Button {
onReact(summary.emoji)
} label: {
HStack(spacing: 3) {
Text(summary.emoji)
Text(String(summary.count))
.font(.caption2.weight(.semibold))
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
summary.includesOwnReaction
? Color.accentColor.opacity(0.2)
: Color.secondary.opacity(0.12),
in: Capsule()
)
.overlay {
Capsule().stroke(
summary.includesOwnReaction
? Color.accentColor.opacity(0.55)
: Color.secondary.opacity(0.16),
lineWidth: 0.8
)
}
}
.buttonStyle(.plain)
.accessibilityLabel("\(summary.emoji), реакций: \(summary.count)")
}
}
.padding(.horizontal, 4)
}
private var hasReplyReference: Bool {
message.replyToID != nil || message.replyPreview != nil
}
private var compactReplyReference: some View {
Group {
if let targetID = message.replyToID {
Button {
onReplyTap(targetID)
} label: {
compactReplyLayout
}
.buttonStyle(.plain)
.accessibilityLabel("Открыть ветку ответа")
} else {
compactReplyLayout
}
}
.opacity(0.56)
.padding(.bottom, -2)
.task(id: replyTarget?.remoteAttachmentURL) {
if let replyTarget,
replyTarget.kind == .photo
|| replyTarget.kind == .video
|| replyTarget.kind == .videoNote {
await model.prepareMediaPreview(replyTarget)
}
}
}
private var compactReplyLayout: some View {
HStack {
if compactTargetDirection == .outgoing {
Spacer(minLength: 58)
}
VStack(
alignment: compactTargetDirection == .outgoing ? .trailing : .leading,
spacing: -1
) {
compactSourceBubble
ReplyThreadCurve(sourceDirection: compactTargetDirection)
.stroke(
compactReplyColor.opacity(0.72),
style: StrokeStyle(lineWidth: 2.4, lineCap: .round)
)
.frame(width: 58, height: 34)
.offset(x: compactTargetDirection == .outgoing ? -18 : 18)
}
if compactTargetDirection == .incoming {
Spacer(minLength: 58)
}
}
.padding(.horizontal, 18)
.frame(maxWidth: .infinity)
}
private var compactSourceBubble: some View {
HStack(spacing: 7) {
compactReplyThumbnail
Text(replyText)
.font(.callout.weight(.medium))
.foregroundStyle(compactReplyColor)
.lineLimit(1)
.multilineTextAlignment(.leading)
}
.padding(.leading, compactTargetDirection == .incoming ? 12 : 10)
.padding(.trailing, compactTargetDirection == .outgoing ? 12 : 10)
.padding(.vertical, 7)
.frame(maxWidth: 230, minHeight: 34, alignment: .leading)
.background(Color.primary.opacity(0.018))
.clipShape(RoundedRectangle(cornerRadius: 17, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 17, style: .continuous)
.stroke(compactReplyColor.opacity(0.75), lineWidth: 1.25)
}
}
@ViewBuilder
private var compactReplyThumbnail: some View {
if let target = replyTarget, target.kind != .text, target.kind != .system {
ZStack {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Color.primary.opacity(0.08))
replyThumbnailImage(for: target)
}
.frame(width: 27, height: 27)
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
}
}
@ViewBuilder
private func replyThumbnailImage(for target: ChatMessage) -> some View {
if let data = model.mediaThumbnail(for: target) {
#if os(iOS)
if let image = ChatMediaImageCache.image(for: target, data: data) {
Image(uiImage: image)
.resizable()
.scaledToFill()
} else {
Image(systemName: replyMediaIcon(for: target.kind))
.foregroundStyle(compactReplyColor)
}
#elseif os(macOS)
if let image = ChatMediaImageCache.image(for: target, data: data) {
Image(nsImage: image)
.resizable()
.scaledToFill()
} else {
Image(systemName: replyMediaIcon(for: target.kind))
.foregroundStyle(compactReplyColor)
}
#endif
} else {
Image(systemName: replyMediaIcon(for: target.kind))
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(compactReplyColor)
}
}
private var replyTarget: ChatMessage? {
message.replyToID.flatMap { model.message(withID: $0, in: message.conversationID) }
}
private var compactTargetDirection: ChatMessage.Direction {
if let direction = replyTarget?.direction { return direction }
return message.direction == .outgoing ? .incoming : .outgoing
}
private var compactReplyColor: Color {
compactTargetDirection == .outgoing
? Color(red: 0.18, green: 0.59, blue: 0.98)
: Color.secondary
}
private var replyText: String {
replyTarget?.quotePreview ?? message.replyPreview ?? "Исходное сообщение недоступно"
}
private var mediaContent: some View {
HStack(spacing: 11) {
ZStack {
Circle()
.fill(.white.opacity(message.direction == .outgoing ? 0.22 : 0.12))
.frame(width: message.kind == .videoNote ? 58 : 38, height: message.kind == .videoNote ? 58 : 38)
Image(systemName: mediaIcon)
.font(.system(size: message.kind == .videoNote ? 24 : 17))
}
VStack(alignment: .leading, spacing: 2) {
Text(mediaTitle)
.font(.subheadline.weight(.semibold))
.lineLimit(2)
Text(mediaSubtitle)
.font(.caption)
.opacity(0.8)
}
}
}
private var mediaTitle: String {
switch message.kind {
case .photo: return "Фото"
case .video: return "Видео"
case .audio: return message.localFilename ?? "Аудио"
case .voice: return "Голосовое сообщение"
case .videoNote: return "Видеосообщение"
case .attachment: return message.localFilename ?? message.body
case .location: return "Геопозиция"
case .text, .system: return message.body
}
}
private var mediaSubtitle: String {
if message.remoteAttachmentURL == nil {
return "Подготовка…"
}
var parts: [String] = []
if let duration = message.duration {
let seconds = max(0, Int(duration.rounded()))
parts.append(String(format: "%d:%02d", seconds / 60, seconds % 60))
}
if let byteCount = message.byteCount {
parts.append(ByteCountFormatter.string(fromByteCount: Int64(byteCount), countStyle: .file))
}
return parts.isEmpty ? "Нажмите для просмотра" : parts.joined(separator: " · ")
}
private var mediaIcon: String {
switch message.kind {
case .attachment: return "doc.fill"
case .photo: return "photo.fill"
case .video: return "play.rectangle.fill"
case .audio: return "music.note"
case .voice: return "waveform"
case .videoNote: return "video.circle.fill"
case .location: return "location.fill"
case .text, .system: return "doc.fill"
}
}
private func replyMediaIcon(for kind: ChatMessage.Kind) -> String {
switch kind {
case .photo: return "photo.fill"
case .video: return "play.fill"
case .audio: return "music.note"
case .voice: return "waveform"
case .videoNote: return "video.circle.fill"
case .attachment: return "doc.fill"
case .location: return "location.fill"
case .text, .system: return "quote.bubble.fill"
}
}
@ViewBuilder
private var bubbleBackground: some View {
if message.direction == .outgoing {
LinearGradient(
colors: [Color(red: 0.13, green: 0.51, blue: 0.94), Color(red: 0.12, green: 0.63, blue: 0.94)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
} else {
Color.secondary.opacity(0.13)
}
}
private var messageBubbleShape: RoundedRectangle {
RoundedRectangle(cornerRadius: 18, style: .continuous)
}
private var deliveryIcon: String {
switch message.delivery {
case .sending: return "clock"
case .sent: return "checkmark"
case .delivered: return "checkmark.circle.fill"
case .failed: return "exclamationmark.circle.fill"
}
}
private var senderColor: Color {
color(for: message.senderJID)
}
private func color(for jid: String) -> Color {
let seed = jid.utf8.reduce(UInt64(2_166_136_261)) { value, byte in
(value ^ UInt64(byte)) &* 16_777_619
}
return Color(
hue: Double(seed % 360) / 360,
saturation: 0.72,
brightness: 0.78
)
}
}
private extension View {
@ViewBuilder
func lumaTextSelection() -> some View {
#if os(macOS)
textSelection(.enabled)
#else
self
#endif
}
}
@@ -0,0 +1,133 @@
import SwiftUI
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
@MainActor
struct PhotoAttachmentPreview: View {
@ObservedObject var model: AppModel
let message: ChatMessage
let onFallbackTap: () -> Void
private let islandWidth: CGFloat = 268
var body: some View {
Button(action: openViewer) {
ZStack {
Color.secondary.opacity(0.12)
thumbnail
.padding(6)
if model.isMediaPreviewLoading(message) {
ProgressView()
.tint(.white)
.controlSize(.large)
.padding(15)
.background(.black.opacity(0.34), in: Circle())
}
VStack {
HStack {
Spacer()
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.caption.weight(.bold))
.foregroundStyle(.white)
.padding(7)
.background(.black.opacity(0.42), in: Circle())
}
Spacer()
HStack(spacing: 6) {
if let filename = message.localFilename {
Text(filename)
.lineLimit(1)
}
Spacer(minLength: 6)
Text(sizeText)
.monospacedDigit()
}
.font(.caption2.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(.black.opacity(0.48), in: Capsule())
}
.padding(9)
}
.frame(width: islandWidth, height: islandHeight)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(Color.secondary.opacity(0.18), lineWidth: 1)
)
.contentShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
}
.buttonStyle(.plain)
.padding(.vertical, 5)
.task(id: message.remoteAttachmentURL) {
await model.prepareMediaPreview(message)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Фото, \(sizeText)")
.accessibilityHint("Открывает фото на весь экран")
}
@ViewBuilder
private var thumbnail: some View {
if let data = model.mediaThumbnail(for: message) {
#if os(iOS)
if let image = ChatMediaImageCache.image(for: message, data: data) {
Image(uiImage: image)
.resizable()
.scaledToFit()
}
#elseif os(macOS)
if let image = ChatMediaImageCache.image(for: message, data: data) {
Image(nsImage: image)
.resizable()
.scaledToFit()
}
#endif
} else {
Image(systemName: "photo.fill")
.font(.system(size: 42, weight: .medium))
.foregroundStyle(.secondary)
}
}
private var sizeText: String {
guard let byteCount = message.byteCount else { return "Фото" }
return ByteCountFormatter.string(fromByteCount: Int64(byteCount), countStyle: .file)
}
private var islandHeight: CGFloat {
let contentWidth = islandWidth - 12
let naturalHeight = contentWidth / thumbnailAspectRatio + 12
return min(max(naturalHeight, 164), 328)
}
private var thumbnailAspectRatio: CGFloat {
guard let data = model.mediaThumbnail(for: message) else { return 4 / 3 }
#if os(iOS)
guard let image = ChatMediaImageCache.image(for: message, data: data),
image.size.height > 0 else { return 4 / 3 }
return max(0.45, min(2.2, image.size.width / image.size.height))
#elseif os(macOS)
guard let image = ChatMediaImageCache.image(for: message, data: data),
image.size.height > 0 else { return 4 / 3 }
return max(0.45, min(2.2, image.size.width / image.size.height))
#endif
}
private func openViewer() {
Task {
await model.presentMediaViewer(message)
if model.mediaViewerItem?.id != message.id,
message.remoteAttachmentURL == nil {
onFallbackTap()
}
}
}
}
@@ -0,0 +1,99 @@
import SwiftUI
import WebRTC
#if os(iOS)
import UIKit
struct RTCVideoRendererView: UIViewRepresentable {
let track: RTCVideoTrack?
var mirrored = false
var contentMode: UIView.ContentMode = .scaleAspectFill
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeUIView(context: Context) -> RTCMTLVideoView {
let view = RTCMTLVideoView(frame: .zero)
view.videoContentMode = contentMode
view.backgroundColor = .black
context.coordinator.update(track: track, renderer: view)
return view
}
func updateUIView(_ view: RTCMTLVideoView, context: Context) {
view.videoContentMode = contentMode
view.transform = mirrored
? CGAffineTransform(scaleX: -1, y: 1)
: .identity
context.coordinator.update(track: track, renderer: view)
}
static func dismantleUIView(_ view: RTCMTLVideoView, coordinator: Coordinator) {
coordinator.detach(renderer: view)
}
final class Coordinator {
private weak var track: RTCVideoTrack?
func update(track newTrack: RTCVideoTrack?, renderer: RTCMTLVideoView) {
guard track !== newTrack else { return }
track?.remove(renderer)
track = newTrack
newTrack?.add(renderer)
}
func detach(renderer: RTCMTLVideoView) {
track?.remove(renderer)
track = nil
}
}
}
#elseif os(macOS)
import AppKit
struct RTCVideoRendererView: NSViewRepresentable {
let track: RTCVideoTrack?
var mirrored = false
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeNSView(context: Context) -> RTCMTLNSVideoView {
let view = RTCMTLNSVideoView(frame: .zero)
view.wantsLayer = true
view.layer?.backgroundColor = NSColor.black.cgColor
context.coordinator.update(track: track, renderer: view)
return view
}
func updateNSView(_ view: RTCMTLNSVideoView, context: Context) {
view.layer?.setAffineTransform(
mirrored ? CGAffineTransform(scaleX: -1, y: 1) : .identity
)
context.coordinator.update(track: track, renderer: view)
}
static func dismantleNSView(_ view: RTCMTLNSVideoView, coordinator: Coordinator) {
coordinator.detach(renderer: view)
}
final class Coordinator {
private weak var track: RTCVideoTrack?
func update(track newTrack: RTCVideoTrack?, renderer: RTCMTLNSVideoView) {
guard track !== newTrack else { return }
track?.remove(renderer)
track = newTrack
newTrack?.add(renderer)
}
func detach(renderer: RTCMTLNSVideoView) {
track?.remove(renderer)
track = nil
}
}
}
#endif
@@ -0,0 +1,347 @@
import Foundation
import SwiftUI
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
/// iMessage-style focus mode for one source message and its reply thread.
@MainActor
struct ReplyThreadOverlay: View {
@ObservedObject var model: AppModel
let rootMessage: ChatMessage
let replies: [ChatMessage]
let selectedReplyID: String
let onDismiss: () -> Void
@Environment(\.colorScheme) private var colorScheme
@State private var isVisible = false
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .topTrailing) {
Rectangle()
.fill(.ultraThinMaterial)
.ignoresSafeArea()
Color.black
.opacity(colorScheme == .dark ? 0.72 : 0.62)
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture(perform: onDismiss)
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 14) {
Text(
rootMessage.timestamp,
format: .dateTime
.weekday(.abbreviated)
.month(.abbreviated)
.day()
.hour()
.minute()
)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white.opacity(0.56))
.padding(.bottom, 2)
threadContent(
maximumBubbleWidth: min(520, geometry.size.width * 0.76)
)
}
.frame(maxWidth: min(760, geometry.size.width))
.padding(.horizontal, 18)
.padding(.top, max(58, geometry.safeAreaInsets.top + 30))
.padding(.bottom, max(34, geometry.safeAreaInsets.bottom + 20))
.frame(maxWidth: .infinity)
}
.scrollIndicators(.hidden)
.onAppear {
isVisible = true
DispatchQueue.main.async {
withAnimation(.easeOut(duration: 0.28)) {
proxy.scrollTo(selectedReplyID, anchor: .center)
}
}
}
}
Button(action: onDismiss) {
Image(systemName: "xmark")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(.white.opacity(0.82))
.frame(width: 32, height: 32)
.background(.white.opacity(0.12), in: Circle())
}
.buttonStyle(.plain)
.keyboardShortcut(.cancelAction)
.accessibilityLabel("Закрыть ветку ответов")
.padding(.top, max(12, geometry.safeAreaInsets.top + 6))
.padding(.trailing, 14)
}
.opacity(isVisible ? 1 : 0)
.scaleEffect(isVisible ? 1 : 0.985)
.animation(.easeOut(duration: 0.22), value: isVisible)
}
.transition(.opacity.combined(with: .scale(scale: 0.985)))
}
private func threadContent(maximumBubbleWidth: CGFloat) -> some View {
let showsBracket = replies.count > 1
let bracketOnLeadingEdge = rootMessage.direction == .outgoing
return VStack(spacing: 9) {
ReplyThreadMessageRow(
model: model,
message: rootMessage,
maximumBubbleWidth: maximumBubbleWidth,
isSelected: false,
showsDeliveryStatus: false
)
.id(rootMessage.id)
if showsBracket {
replyCountRow
}
ForEach(replies) { reply in
ReplyThreadMessageRow(
model: model,
message: reply,
maximumBubbleWidth: maximumBubbleWidth,
isSelected: reply.id == selectedReplyID,
showsDeliveryStatus: reply.id == selectedReplyID
)
.id(reply.id)
}
}
.padding(.leading, showsBracket && bracketOnLeadingEdge ? 43 : 0)
.padding(.trailing, showsBracket && !bracketOnLeadingEdge ? 43 : 0)
.overlay(
alignment: bracketOnLeadingEdge ? .leading : .trailing
) {
if showsBracket {
ReplyThreadBracket(opensToTrailing: bracketOnLeadingEdge)
.stroke(
Color.white.opacity(0.25),
style: StrokeStyle(lineWidth: 3, lineCap: .round)
)
.frame(width: 38)
.padding(.vertical, 28)
.allowsHitTesting(false)
}
}
}
private var replyCountRow: some View {
HStack {
if rootMessage.direction == .outgoing { Spacer(minLength: 40) }
Text(replyCountText)
.font(.subheadline.weight(.semibold))
.foregroundStyle(Color(red: 0.16, green: 0.59, blue: 1))
.padding(.horizontal, 13)
if rootMessage.direction == .incoming { Spacer(minLength: 40) }
}
}
private var replyCountText: String {
let value = replies.count
let mod10 = value % 10
let mod100 = value % 100
let noun: String
if mod10 == 1, mod100 != 11 {
noun = "ответ"
} else if (2...4).contains(mod10), !(12...14).contains(mod100) {
noun = "ответа"
} else {
noun = "ответов"
}
return "\(value) \(noun)"
}
}
@MainActor
private struct ReplyThreadMessageRow: View {
@ObservedObject var model: AppModel
let message: ChatMessage
let maximumBubbleWidth: CGFloat
let isSelected: Bool
let showsDeliveryStatus: Bool
var body: some View {
VStack(
alignment: message.direction == .outgoing ? .trailing : .leading,
spacing: 4
) {
if message.isGroupMessage, message.direction == .incoming {
Text(message.senderDisplayName ?? model.displayName(for: message.senderJID))
.font(.caption.weight(.semibold))
.foregroundStyle(.white.opacity(0.62))
.padding(.horizontal, 12)
}
HStack(alignment: .bottom) {
if message.direction == .outgoing { Spacer(minLength: 44) }
focusedBubble
.frame(maxWidth: maximumBubbleWidth, alignment: message.direction == .outgoing ? .trailing : .leading)
.shadow(
color: isSelected ? Color.blue.opacity(0.5) : Color.clear,
radius: isSelected ? 22 : 0,
y: isSelected ? 8 : 0
)
.scaleEffect(isSelected ? 1.018 : 1)
if message.direction == .incoming { Spacer(minLength: 44) }
}
if showsDeliveryStatus, message.direction == .outgoing {
Text(deliveryText)
.font(.caption.weight(.semibold))
.foregroundStyle(.white.opacity(0.54))
.padding(.trailing, 13)
}
}
.animation(.spring(response: 0.34, dampingFraction: 0.78), value: isSelected)
.task(id: message.remoteAttachmentURL) {
if isVisualMedia {
await model.prepareMediaPreview(message)
}
}
}
@ViewBuilder
private var focusedBubble: some View {
if isVisualMedia {
visualMediaBubble
} else {
HStack(spacing: 10) {
if let icon = nonVisualMediaIcon {
Image(systemName: icon)
.font(.system(size: 18, weight: .semibold))
}
Text(displayText)
.font(.body)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.leading, message.direction == .incoming ? 17 : 14)
.padding(.trailing, message.direction == .outgoing ? 17 : 14)
.padding(.vertical, 11)
.foregroundStyle(.white)
.background(focusedBubbleBackground)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
}
}
private var visualMediaBubble: some View {
VStack(alignment: .leading, spacing: 0) {
ZStack {
Color.white.opacity(0.08)
mediaThumbnail
if message.kind != .photo {
Image(systemName: "play.fill")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(.white)
.frame(width: 52, height: 52)
.background(.black.opacity(0.42), in: Circle())
}
}
.frame(width: min(maximumBubbleWidth, 430), height: 190)
.clipped()
Text(message.previewText)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, 15)
.padding(.vertical, 11)
}
.background(focusedBubbleBackground)
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
}
@ViewBuilder
private var mediaThumbnail: some View {
if let data = model.mediaThumbnail(for: message) {
#if os(iOS)
if let image = ChatMediaImageCache.image(for: message, data: data) {
Image(uiImage: image)
.resizable()
.scaledToFill()
} else {
mediaPlaceholder
}
#elseif os(macOS)
if let image = ChatMediaImageCache.image(for: message, data: data) {
Image(nsImage: image)
.resizable()
.scaledToFill()
} else {
mediaPlaceholder
}
#endif
} else {
mediaPlaceholder
}
}
private var mediaPlaceholder: some View {
Image(systemName: message.kind == .photo ? "photo.fill" : "video.fill")
.font(.system(size: 42, weight: .medium))
.foregroundStyle(.white.opacity(0.62))
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
private var focusedBubbleBackground: some View {
if message.direction == .outgoing {
LinearGradient(
colors: [
Color(red: 0.12, green: 0.51, blue: 0.96),
Color(red: 0.10, green: 0.63, blue: 1)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
} else {
LinearGradient(
colors: [Color.white.opacity(0.34), Color.white.opacity(0.23)],
startPoint: .top,
endPoint: .bottom
)
}
}
private var isVisualMedia: Bool {
!message.isRetracted
&& (message.kind == .photo || message.kind == .video || message.kind == .videoNote)
}
private var nonVisualMediaIcon: String? {
switch message.kind {
case .attachment: return "doc.fill"
case .audio: return "music.note"
case .voice: return "waveform"
case .location: return "location.fill"
case .text, .system, .photo, .video, .videoNote: return nil
}
}
private var displayText: String {
message.isRetracted ? "Сообщение удалено" : message.body
}
private var deliveryText: String {
let state: String
switch message.delivery {
case .sending: state = "Отправка"
case .sent: state = "Отправлено"
case .delivered: state = "Доставлено"
case .failed: state = "Не отправлено"
}
let value = Calendar.current.isDateInToday(message.timestamp)
? message.timestamp.formatted(date: .omitted, time: .shortened)
: message.timestamp.formatted(date: .numeric, time: .omitted)
return "\(state) \(value)"
}
}
@@ -0,0 +1,151 @@
import SwiftUI
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
@MainActor
struct VideoAttachmentPreview: View {
@ObservedObject var model: AppModel
let message: ChatMessage
let onFallbackTap: () -> Void
private let islandWidth: CGFloat = 268
var body: some View {
Button(action: openViewer) {
ZStack {
LinearGradient(
colors: [
Color(red: 0.06, green: 0.17, blue: 0.29),
Color(red: 0.09, green: 0.43, blue: 0.64)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
thumbnail
.padding(6)
if model.isMediaPreviewLoading(message) {
ProgressView()
.tint(.white)
.controlSize(.large)
} else {
Image(systemName: "play.fill")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(.white)
.padding(15)
.background(.black.opacity(0.38), in: Circle())
}
VStack {
HStack {
Spacer()
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.caption.weight(.bold))
.foregroundStyle(.white)
.padding(7)
.background(.black.opacity(0.42), in: Circle())
}
Spacer()
HStack(spacing: 6) {
if let filename = message.localFilename {
Text(filename)
.lineLimit(1)
}
Spacer(minLength: 6)
Text(metadataText)
.monospacedDigit()
}
.font(.caption2.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(.black.opacity(0.48), in: Capsule())
}
.padding(9)
}
.frame(width: islandWidth, height: islandHeight)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(Color.secondary.opacity(0.18), lineWidth: 1)
)
.contentShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
}
.buttonStyle(.plain)
.padding(.vertical, 5)
.task(id: message.remoteAttachmentURL) {
await model.prepareMediaPreview(message)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Видео, \(metadataText)")
.accessibilityHint("Открывает видео на весь экран")
}
@ViewBuilder
private var thumbnail: some View {
if let data = model.mediaThumbnail(for: message) {
#if os(iOS)
if let image = ChatMediaImageCache.image(for: message, data: data) {
Image(uiImage: image)
.resizable()
.scaledToFit()
}
#elseif os(macOS)
if let image = ChatMediaImageCache.image(for: message, data: data) {
Image(nsImage: image)
.resizable()
.scaledToFit()
}
#endif
} else {
Image(systemName: "film.fill")
.font(.system(size: 38, weight: .medium))
.foregroundStyle(.white.opacity(0.65))
}
}
private var metadataText: String {
var parts: [String] = []
if let duration = message.duration {
parts.append(MediaTimeFormatter.string(duration))
}
if let byteCount = message.byteCount {
parts.append(ByteCountFormatter.string(fromByteCount: Int64(byteCount), countStyle: .file))
}
return parts.isEmpty ? "Видео" : parts.joined(separator: " · ")
}
private var islandHeight: CGFloat {
let contentWidth = islandWidth - 12
let naturalHeight = contentWidth / thumbnailAspectRatio + 12
return min(max(naturalHeight, 164), 250)
}
private var thumbnailAspectRatio: CGFloat {
guard let data = model.mediaThumbnail(for: message) else { return 16 / 9 }
#if os(iOS)
guard let image = ChatMediaImageCache.image(for: message, data: data),
image.size.height > 0 else { return 16 / 9 }
return max(0.7, min(2.2, image.size.width / image.size.height))
#elseif os(macOS)
guard let image = ChatMediaImageCache.image(for: message, data: data),
image.size.height > 0 else { return 16 / 9 }
return max(0.7, min(2.2, image.size.width / image.size.height))
#endif
}
private func openViewer() {
Task {
await model.presentMediaViewer(message)
if model.mediaViewerItem?.id != message.id,
message.remoteAttachmentURL == nil {
onFallbackTap()
}
}
}
}
@@ -0,0 +1,155 @@
import AVFoundation
import SwiftUI
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
@MainActor
struct VideoNotePreview: View {
@ObservedObject var model: AppModel
let message: ChatMessage
let onFallbackTap: () -> Void
@State private var player: AVPlayer?
@State private var isPlaying = false
private var diameter: CGFloat { isPlaying ? 176 : 144 }
var body: some View {
ZStack {
LinearGradient(
colors: [
Color(red: 0.08, green: 0.30, blue: 0.56),
Color(red: 0.07, green: 0.64, blue: 0.83)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
thumbnail
if let player {
InlineVideoPlayer(player: player)
}
if previewURL == nil, model.isMediaPreviewLoading(message) {
ProgressView()
.tint(.white)
.controlSize(.large)
} else if !isPlaying {
Image(systemName: "play.fill")
.font(.system(size: 24, weight: .bold))
.foregroundStyle(.white)
.padding(17)
.background(.black.opacity(0.34), in: Circle())
}
VStack {
Spacer()
HStack {
Spacer()
Text(durationText)
.font(.caption2.monospacedDigit().weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(.black.opacity(0.48), in: Capsule())
}
.padding(12)
}
}
.frame(width: diameter, height: diameter)
.animation(.spring(response: 0.32, dampingFraction: 0.82), value: isPlaying)
.clipShape(Circle())
.overlay(Circle().stroke(.white.opacity(0.18), lineWidth: 1))
.contentShape(Circle())
.onTapGesture(perform: togglePlayback)
.task(id: message.remoteAttachmentURL) {
await model.prepareMediaPreview(message)
configurePlayerIfNeeded()
}
.onChange(of: previewURL) { _, _ in
configurePlayerIfNeeded()
}
.onReceive(NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime)) { notification in
guard let item = notification.object as? AVPlayerItem,
item === player?.currentItem else { return }
player?.seek(to: .zero)
isPlaying = false
}
.onReceive(NotificationCenter.default.publisher(for: .lumaExclusiveMediaPlayback)) { notification in
guard let activeID = notification.object as? String,
activeID != message.id else { return }
player?.pause()
isPlaying = false
}
.onDisappear {
player?.pause()
isPlaying = false
}
.accessibilityLabel("Видеосообщение, \(durationText)")
.accessibilityHint("Нажмите для воспроизведения или паузы")
}
private var previewURL: URL? {
model.mediaPreviewURL(for: message)
}
@ViewBuilder
private var thumbnail: some View {
if let data = model.mediaThumbnail(for: message) {
#if os(iOS)
if let image = ChatMediaImageCache.image(for: message, data: data) {
Image(uiImage: image)
.resizable()
.scaledToFill()
}
#elseif os(macOS)
if let image = ChatMediaImageCache.image(for: message, data: data) {
Image(nsImage: image)
.resizable()
.scaledToFill()
}
#endif
} else {
Image(systemName: "video.fill")
.font(.system(size: 34, weight: .medium))
.foregroundStyle(.white.opacity(0.72))
}
}
private var durationText: String {
let seconds = max(0, Int((message.duration ?? 0).rounded()))
return String(format: "%d:%02d", seconds / 60, seconds % 60)
}
private func configurePlayerIfNeeded() {
guard player == nil, let previewURL else { return }
let player = AVPlayer(url: previewURL)
player.actionAtItemEnd = .pause
self.player = player
}
private func togglePlayback() {
guard previewURL != nil else {
onFallbackTap()
return
}
configurePlayerIfNeeded()
guard let player else { return }
if isPlaying {
player.pause()
} else {
model.audioPlayback.stop()
NotificationCenter.default.post(
name: .lumaExclusiveMediaPlayback,
object: message.id
)
player.play()
}
isPlaying.toggle()
}
}
@@ -0,0 +1,149 @@
import SwiftUI
struct EncryptionDevicesView: View {
@ObservedObject var model: AppModel
let conversation: Conversation
@Environment(\.dismiss) private var dismiss
@State private var devices: [OMEMODevice] = []
var body: some View {
NavigationStack {
List {
Section {
Label(
statusTitle,
systemImage: statusIcon
)
.foregroundStyle(statusColor)
} footer: {
Text("Luma использует trust-on-first-use. Сверьте отпечаток по другому каналу и отметьте устройство как проверенное.")
}
if devices.isEmpty {
Section("Устройства контакта") {
ContentUnavailableView(
"Ключи ещё не получены",
systemImage: "key.horizontal",
description: Text("Они появятся после первой попытки отправки или получения OMEMO-сообщения.")
)
}
} else {
Section("Устройства контакта") {
ForEach(devices) { device in
DeviceRow(device: device) {
model.setDeviceVerified(
device.trust != .verified,
jid: device.jid,
deviceID: device.deviceID
)
reload()
}
}
}
}
if let fingerprint = model.ownFingerprint {
Section("Это устройство") {
Text(fingerprint.chunked(every: 8).joined(separator: " "))
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
}
}
}
.navigationTitle("Шифрование")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Готово") { dismiss() }
}
}
.onAppear(perform: reload)
}
#if os(macOS)
.frame(minWidth: 480, minHeight: 500)
#endif
}
private func reload() {
devices = model.devices(for: conversation.jid).filter { !$0.isOwn }
}
private var statusTitle: String {
guard model.encryptionEnabled(for: conversation.jid) else { return "OMEMO выключено для этого чата" }
return model.isOMEMOReady ? "OMEMO активно" : "OMEMO подготавливается"
}
private var statusIcon: String {
guard model.encryptionEnabled(for: conversation.jid) else { return "lock.open" }
return model.isOMEMOReady ? "lock.shield.fill" : "lock.rotation"
}
private var statusColor: Color {
model.encryptionEnabled(for: conversation.jid) && model.isOMEMOReady ? .green : .orange
}
}
private struct DeviceRow: View {
let device: OMEMODevice
let toggleVerification: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Label("Устройство \(device.deviceID)", systemImage: device.isActive ? "iphone" : "iphone.slash")
.font(.headline)
Spacer()
Button(action: toggleVerification) {
Label(trustTitle, systemImage: trustIcon)
}
.buttonStyle(.bordered)
.tint(trustColor)
}
Text(device.formattedFingerprint)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
.padding(.vertical, 5)
}
private var trustTitle: String {
switch device.trust {
case .verified: return "Проверено"
case .trusted: return "Доверено"
case .undecided: return "Проверить"
case .compromised: return "Скомпрометировано"
}
}
private var trustIcon: String {
switch device.trust {
case .verified: return "checkmark.seal.fill"
case .trusted: return "checkmark.shield"
case .undecided: return "questionmark.diamond"
case .compromised: return "exclamationmark.triangle.fill"
}
}
private var trustColor: Color {
switch device.trust {
case .verified: return .green
case .trusted: return .blue
case .undecided: return .orange
case .compromised: return .red
}
}
}
private extension String {
func chunked(every size: Int) -> [String] {
guard size > 0 else { return [self] }
var result: [String] = []
var cursor = startIndex
while cursor < endIndex {
let end = index(cursor, offsetBy: size, limitedBy: endIndex) ?? endIndex
result.append(String(self[cursor..<end]))
cursor = end
}
return result
}
}
+137
View File
@@ -0,0 +1,137 @@
import SwiftUI
@MainActor
struct ForwardMessageView: View {
@ObservedObject var model: AppModel
let messages: [ChatMessage]
let onComplete: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var destinationJID = ""
@State private var isForwarding = false
init(
model: AppModel,
messages: [ChatMessage],
onComplete: @escaping () -> Void = {}
) {
_model = ObservedObject(wrappedValue: model)
self.messages = messages.filter(\.canBeForwarded)
self.onComplete = onComplete
}
var body: some View {
NavigationStack {
List {
Section(messages.count == 1 ? "Пересылаемое сообщение" : "Пересылаемые сообщения") {
Label(forwardingSummary, systemImage: "arrowshape.turn.up.right.fill")
.fontWeight(.semibold)
ForEach(Array(messages.prefix(3))) { message in
Text(message.previewText)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
if messages.count > 3 {
Text("И ещё \(messages.count - 3)…")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if !model.conversations.isEmpty {
Section("Чаты") {
ForEach(model.conversations) { conversation in
Button {
forward(to: conversation.jid)
} label: {
HStack(spacing: 11) {
AvatarView(
conversation: conversation,
imageData: model.avatarData(for: conversation.jid),
size: 34
)
VStack(alignment: .leading, spacing: 2) {
Text(conversation.displayName)
.foregroundStyle(.primary)
Text(conversation.jid)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "paperplane.fill")
.foregroundStyle(.tint)
}
}
.buttonStyle(.plain)
.disabled(isForwarding)
}
}
}
Section("Другой адрес") {
TextField("user@example.org", text: $destinationJID)
.disableAutocorrection(true)
Button {
forward(to: destinationJID)
} label: {
Label("Переслать", systemImage: "paperplane.fill")
}
.disabled(
isForwarding
|| destinationJID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
)
}
}
.navigationTitle(messages.count == 1 ? "Переслать" : "Переслать сообщения")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Отмена") { dismiss() }
}
}
.overlay {
if isForwarding {
ZStack {
Color.black.opacity(0.08).ignoresSafeArea()
ProgressView("Пересылка…")
.padding(18)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
}
}
}
}
#if os(macOS)
.frame(minWidth: 420, minHeight: 520)
#endif
}
private func forward(to jid: String) {
guard !isForwarding, !messages.isEmpty else { return }
isForwarding = true
Task {
let succeeded = await model.forwardMessages(messages, to: jid)
isForwarding = false
if succeeded {
onComplete()
dismiss()
}
}
}
private var forwardingSummary: String {
let count = messages.count
let remainder100 = count % 100
let remainder10 = count % 10
let word: String
if (11...14).contains(remainder100) {
word = "сообщений"
} else if remainder10 == 1 {
word = "сообщение"
} else if (2...4).contains(remainder10) {
word = "сообщения"
} else {
word = "сообщений"
}
return "\(count) \(word)"
}
}
+121
View File
@@ -0,0 +1,121 @@
import Foundation
import SwiftUI
@MainActor
struct GroupInfoView: View {
@ObservedObject var model: AppModel
let conversation: Conversation
@Environment(\.dismiss) private var dismiss
@State private var nickname: String
@State private var invitees = ""
@State private var isJoining = false
@State private var isInviting = false
init(model: AppModel, conversation: Conversation) {
self.model = model
self.conversation = conversation
_nickname = State(initialValue: conversation.groupNickname ?? model.suggestedGroupNickname)
}
var body: some View {
NavigationStack {
Form {
Section("Групповой чат") {
LabeledContent("Комната", value: conversation.jid)
LabeledContent("Состояние", value: liveConversation.isGroupJoined ? "Подключено" : "Не подключено")
LabeledContent("Участников", value: "\(liveConversation.occupantCount)")
TextField("Ваш псевдоним", text: $nickname)
.disabled(liveConversation.isGroupJoined)
}
if !liveConversation.isGroupJoined {
Section {
Button {
join()
} label: {
Label(liveConversation.invitedBy == nil ? "Войти в комнату" : "Принять приглашение", systemImage: "person.2.fill")
}
.disabled(nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isJoining)
} footer: {
if let inviter = liveConversation.invitedBy {
Text("Приглашение от \(inviter)")
}
}
} else {
Section("Пригласить участников") {
TextField("user@example.org, ещё@example.org", text: $invitees, axis: .vertical)
.lineLimit(2...4)
Button {
invite()
} label: {
if isInviting {
ProgressView()
} else {
Label("Отправить приглашения", systemImage: "person.badge.plus")
}
}
.disabled(parsedInvitees.isEmpty || isInviting)
}
Section {
Button(role: .destructive) {
model.leaveGroup(jid: conversation.jid)
dismiss()
} label: {
Label("Покинуть комнату", systemImage: "rectangle.portrait.and.arrow.right")
}
}
}
Section {
Label(
model.encryptionEnabled(for: conversation.jid)
? "Для комнаты включено OMEMO. Нужны неанонимный MUC и OMEMO-устройства у всех участников."
: "OMEMO для этой комнаты выключено: сообщения передаются открытым текстом внутри TLS.",
systemImage: model.encryptionEnabled(for: conversation.jid) ? "lock.fill" : "lock.open"
)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationTitle(conversation.displayName)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Готово") { dismiss() }
}
}
}
#if os(macOS)
.frame(minWidth: 480, minHeight: 500)
#endif
}
private var liveConversation: Conversation {
model.conversations.first(where: { $0.id == conversation.id }) ?? conversation
}
private var parsedInvitees: [String] {
invitees.components(separatedBy: CharacterSet(charactersIn: ",;\n \t"))
.filter { !$0.isEmpty }
}
private func join() {
isJoining = true
Task {
await model.joinGroup(jid: conversation.jid, nickname: nickname)
isJoining = false
}
}
private func invite() {
let outgoing = parsedInvitees
guard !outgoing.isEmpty else { return }
isInviting = true
Task {
await model.inviteMembers(outgoing, to: conversation.jid)
invitees = ""
isInviting = false
}
}
}
+148
View File
@@ -0,0 +1,148 @@
import CoreLocation
import Combine
import Foundation
import MapKit
import SwiftUI
@MainActor
struct LocationPickerView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var locationProvider = LocationProvider()
@State private var cameraPosition: MapCameraPosition = .automatic
@State private var selectedCoordinate: CLLocationCoordinate2D?
let onSend: (GeoLocation) -> Void
var body: some View {
NavigationStack {
ZStack {
Map(position: $cameraPosition) {
UserAnnotation()
}
.mapStyle(.standard(elevation: .realistic))
.onMapCameraChange(frequency: .onEnd) { context in
selectedCoordinate = context.region.center
}
Image(systemName: "mappin.circle.fill")
.font(.system(size: 42, weight: .semibold))
.symbolRenderingMode(.palette)
.foregroundStyle(.white, Color.accentColor)
.shadow(color: .black.opacity(0.28), radius: 4, y: 2)
.offset(y: -20)
.allowsHitTesting(false)
VStack {
if let message = locationProvider.errorMessage {
Text(message)
.font(.caption)
.multilineTextAlignment(.center)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.regularMaterial, in: Capsule())
.padding()
}
Spacer()
HStack {
Spacer()
Button(action: centerOnCurrentLocation) {
Group {
if locationProvider.isLocating {
ProgressView()
} else {
Image(systemName: "location.fill")
}
}
.frame(width: 44, height: 44)
.background(.regularMaterial, in: Circle())
.shadow(color: .black.opacity(0.16), radius: 5, y: 2)
}
.buttonStyle(.plain)
.accessibilityLabel("Показать мою геопозицию")
}
.padding(.horizontal, 16)
.padding(.bottom, 10)
}
}
.safeAreaInset(edge: .bottom) {
VStack(spacing: 10) {
Text(coordinateText)
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
Button(action: sendSelection) {
Label("Отправить эту геопозицию", systemImage: "location.fill")
.font(.headline)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color.accentColor, in: RoundedRectangle(cornerRadius: 14))
}
.buttonStyle(.plain)
.disabled(selectedCoordinate == nil)
}
.padding(16)
.background(.regularMaterial)
}
.navigationTitle("Геопозиция")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Отмена") { dismiss() }
}
}
}
.onAppear {
locationProvider.requestCurrentLocation()
}
.onReceive(locationProvider.$location.compactMap { $0 }) { location in
selectedCoordinate = location.coordinate
cameraPosition = .region(
MKCoordinateRegion(
center: location.coordinate,
latitudinalMeters: 1_200,
longitudinalMeters: 1_200
)
)
}
}
private var coordinateText: String {
guard let selectedCoordinate else { return "Переместите карту, чтобы выбрать точку" }
return String(
format: "%.6f, %.6f",
locale: Locale(identifier: "en_US_POSIX"),
selectedCoordinate.latitude,
selectedCoordinate.longitude
)
}
private func centerOnCurrentLocation() {
if let location = locationProvider.location {
selectedCoordinate = location.coordinate
cameraPosition = .region(
MKCoordinateRegion(
center: location.coordinate,
latitudinalMeters: 1_200,
longitudinalMeters: 1_200
)
)
} else {
locationProvider.requestCurrentLocation()
}
}
private func sendSelection() {
guard let selectedCoordinate,
let location = GeoLocation(
latitude: selectedCoordinate.latitude,
longitude: selectedCoordinate.longitude
) else { return }
onSend(location)
dismiss()
}
}
+149
View File
@@ -0,0 +1,149 @@
import SwiftUI
struct LoginView: View {
@ObservedObject var model: AppModel
@State private var jid = ""
@State private var password = ""
@State private var displayName = ""
@State private var resource = "Luma"
@State private var showAdvanced = false
@State private var manualHost = ""
@State private var manualPort = ""
@State private var directTLS = false
var body: some View {
ZStack {
LinearGradient(
colors: [
Color(red: 0.08, green: 0.38, blue: 0.78),
Color(red: 0.12, green: 0.62, blue: 0.96),
Color(red: 0.44, green: 0.84, blue: 0.96)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
Circle()
.fill(.white.opacity(0.12))
.frame(width: 420, height: 420)
.offset(x: -210, y: -260)
ScrollView {
VStack(spacing: 28) {
brand
loginCard
}
.frame(maxWidth: 460)
.padding(.horizontal, 24)
.padding(.vertical, 48)
.frame(maxWidth: .infinity)
}
}
}
private var brand: some View {
VStack(spacing: 14) {
ZStack {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.fill(.white)
.frame(width: 88, height: 88)
.shadow(color: .black.opacity(0.16), radius: 22, y: 10)
Image(systemName: "bubble.left.and.bubble.right.fill")
.font(.system(size: 39, weight: .semibold))
.foregroundStyle(
LinearGradient(
colors: [.blue, .cyan],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
Text("Luma")
.font(.system(size: 42, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text("Приватный XMPP без привязки к одному серверу")
.font(.subheadline)
.foregroundStyle(.white.opacity(0.86))
.multilineTextAlignment(.center)
}
}
private var loginCard: some View {
VStack(spacing: 18) {
VStack(alignment: .leading, spacing: 7) {
Text("XMPP-аккаунт")
.font(.headline)
TextField("you@example.org", text: $jid)
.textFieldStyle(.roundedBorder)
.disableAutocorrection(true)
}
VStack(alignment: .leading, spacing: 7) {
Text("Пароль")
.font(.headline)
SecureField("Пароль", text: $password)
.textFieldStyle(.roundedBorder)
}
DisclosureGroup("Дополнительные настройки", isExpanded: $showAdvanced) {
VStack(spacing: 14) {
TextField("Отображаемое имя", text: $displayName)
.textFieldStyle(.roundedBorder)
TextField("Ресурс устройства", text: $resource)
.textFieldStyle(.roundedBorder)
TextField("Сервер вручную (необязательно)", text: $manualHost)
.textFieldStyle(.roundedBorder)
TextField(directTLS ? "Порт, обычно 5223" : "Порт, обычно 5222", text: $manualPort)
.textFieldStyle(.roundedBorder)
Toggle("Direct TLS", isOn: $directTLS)
Text("Без ручного адреса Luma использует DNS SRV и автоматически выбирает STARTTLS/direct TLS.")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.top, 14)
}
Button {
Task { await signIn() }
} label: {
HStack {
if model.isAuthenticating {
ProgressView().controlSize(.small)
}
Text(model.isAuthenticating ? "Подключение…" : "Войти")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.frame(height: 34)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(model.isAuthenticating)
Label("Сообщения отправляются с OMEMO. Пароль хранится только в Keychain.", systemImage: "lock.shield.fill")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(26)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
.shadow(color: .black.opacity(0.14), radius: 24, y: 12)
}
private func signIn() async {
let port = Int(manualPort.trimmingCharacters(in: .whitespacesAndNewlines))
let account = AccountConfiguration(
jid: jid,
displayName: displayName,
resource: resource,
manualHost: manualHost,
manualPort: port,
usesDirectTLS: directTLS
)
await model.signIn(account: account, password: password)
}
}
+268
View File
@@ -0,0 +1,268 @@
import Foundation
import SwiftUI
struct MainChatView: View {
private enum SidebarMode: String, CaseIterable {
case chats
case contacts
var title: String {
switch self {
case .chats: return "Чаты"
case .contacts: return "Контакты"
}
}
}
@ObservedObject var model: AppModel
@State private var searchText = ""
@State private var sidebarMode: SidebarMode = .chats
@State private var showingNewChat = false
@State private var showingNewGroup = false
@State private var showingSettings = false
private var filteredConversations: [Conversation] {
guard !searchText.isEmpty else { return model.conversations }
return model.conversations.filter(matchesSearch)
}
private var filteredRosterContacts: [Conversation] {
model.conversations
.filter { !$0.isGroup && model.rosterContactJIDs.contains($0.id) }
.filter { searchText.isEmpty || matchesSearch($0) }
.sorted(by: contactSort)
}
private var filteredGroupContacts: [Conversation] {
model.conversations
.filter { $0.isGroup }
.filter { searchText.isEmpty || matchesSearch($0) }
.sorted(by: contactSort)
}
private var hasStoredContacts: Bool {
!model.rosterContactJIDs.isEmpty || model.conversations.contains { $0.isGroup }
}
var body: some View {
NavigationSplitView {
VStack(spacing: 0) {
ConnectionBanner(model: model)
Picker("Раздел", selection: $sidebarMode) {
Label("Чаты", systemImage: "bubble.left.and.bubble.right.fill")
.tag(SidebarMode.chats)
Label("Контакты", systemImage: "person.2.fill")
.tag(SidebarMode.contacts)
}
.pickerStyle(.segmented)
.padding(.horizontal, 12)
.padding(.vertical, 8)
switch sidebarMode {
case .chats:
chatList
case .contacts:
contactsList
}
}
.navigationTitle(sidebarMode.title)
.searchable(
text: $searchText,
prompt: sidebarMode == .chats ? "Поиск чатов" : "Поиск контактов"
)
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button { showingSettings = true } label: {
Image(systemName: "gearshape")
}
Button { showingNewGroup = true } label: {
Image(systemName: "person.3.fill")
}
.accessibilityLabel("Новый групповой чат")
.help("Новый групповой чат")
Button { showingNewChat = true } label: {
Image(systemName: "square.and.pencil")
}
.accessibilityLabel("Новый личный чат")
.help("Новый личный чат")
}
}
} detail: {
if let conversation = model.selectedConversation {
ChatView(model: model, conversation: conversation)
.id(conversation.id)
} else {
WelcomeDetailView()
}
}
.navigationSplitViewStyle(.balanced)
.onChange(of: model.selectedConversationID) { _, id in
guard let id else { return }
model.selectConversation(id: id)
}
.sheet(isPresented: $showingNewChat) {
NewChatView(model: model)
}
.sheet(isPresented: $showingNewGroup) {
NewGroupView(model: model)
}
.sheet(isPresented: $showingSettings) {
SettingsView(model: model)
}
}
private var chatList: some View {
List(selection: $model.selectedConversationID) {
ForEach(filteredConversations) { conversation in
ConversationRow(
conversation: conversation,
imageData: model.avatarData(for: conversation.jid),
isEncrypted: model.encryptionEnabled(for: conversation.jid)
)
.tag(conversation.id)
.listRowInsets(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12))
}
}
.listStyle(.plain)
.overlay {
if filteredConversations.isEmpty {
ContentUnavailableView(
searchText.isEmpty ? "Нет чатов" : "Ничего не найдено",
systemImage: searchText.isEmpty ? "bubble.left" : "magnifyingglass",
description: Text(
searchText.isEmpty
? "Создайте личный или групповой чат."
: "Попробуйте другой JID или имя."
)
)
}
}
}
private var contactsList: some View {
List(selection: $model.selectedConversationID) {
if !filteredRosterContacts.isEmpty {
Section {
ForEach(filteredRosterContacts) { conversation in
ContactRow(
conversation: conversation,
imageData: model.avatarData(for: conversation.jid)
)
.tag(conversation.id)
.listRowInsets(EdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12))
}
} header: {
Label("Люди из roster", systemImage: "person.2.fill")
}
}
if !filteredGroupContacts.isEmpty {
Section {
ForEach(filteredGroupContacts) { conversation in
ContactRow(
conversation: conversation,
imageData: model.avatarData(for: conversation.jid)
)
.tag(conversation.id)
.listRowInsets(EdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12))
}
} header: {
Label("Групповые чаты", systemImage: "person.3.fill")
}
}
}
.listStyle(.plain)
.overlay {
if filteredRosterContacts.isEmpty && filteredGroupContacts.isEmpty {
ContentUnavailableView(
searchText.isEmpty ? "Нет контактов" : "Ничего не найдено",
systemImage: searchText.isEmpty ? "person.2" : "magnifyingglass",
description: Text(contactListDescription)
)
}
}
}
private var contactListDescription: String {
if !searchText.isEmpty {
return "Попробуйте другой JID или имя."
}
if !hasStoredContacts, model.connectionStatus != .connected {
return "Подключитесь к серверу, чтобы загрузить roster Prosody и групповые комнаты."
}
return "Добавьте человека в roster или создайте групповой чат."
}
private func matchesSearch(_ conversation: Conversation) -> Bool {
conversation.displayName.localizedCaseInsensitiveContains(searchText)
|| conversation.jid.localizedCaseInsensitiveContains(searchText)
}
private func contactSort(_ lhs: Conversation, _ rhs: Conversation) -> Bool {
lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending
}
}
private struct ContactRow: View {
let conversation: Conversation
let imageData: Data?
var body: some View {
HStack(spacing: 12) {
AvatarView(conversation: conversation, imageData: imageData, size: 42)
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
Text(conversation.displayName)
.font(.headline)
.lineLimit(1)
if !conversation.isGroup, conversation.isOnline {
Circle()
.fill(Color.green)
.frame(width: 7, height: 7)
.accessibilityLabel("В сети")
}
}
if conversation.isGroup {
HStack(spacing: 4) {
Image(systemName: "person.3.fill")
Text(groupSubtitle)
}
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text(conversation.jid)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 8)
Image(systemName: "chevron.right")
.font(.caption.bold())
.foregroundStyle(.tertiary)
}
.contentShape(Rectangle())
}
private var groupSubtitle: String {
guard conversation.occupantCount > 0 else { return conversation.jid }
return "\(conversation.occupantCount) участн. • \(conversation.jid)"
}
}
private struct WelcomeDetailView: View {
var body: some View {
ZStack {
Color.secondary.opacity(0.04).ignoresSafeArea()
ContentUnavailableView {
Label("Luma", systemImage: "bubble.left.and.bubble.right.fill")
} description: {
Text("Выберите чат или начните новый защищённый диалог.")
}
}
}
}
+47
View File
@@ -0,0 +1,47 @@
import SwiftUI
struct NewChatView: View {
@ObservedObject var model: AppModel
@Environment(\.dismiss) private var dismiss
@State private var jid = ""
@State private var name = ""
@State private var addToRoster = true
var body: some View {
NavigationStack {
Form {
Section("Контакт") {
TextField("user@example.org", text: $jid)
.disableAutocorrection(true)
TextField("Имя (необязательно)", text: $name)
}
Section {
Toggle("Добавить в roster", isOn: $addToRoster)
} footer: {
Text("Начать чат можно с любым полным JID. Для шифрования у контакта должно быть опубликовано хотя бы одно OMEMO-устройство.")
}
}
.navigationTitle("Новый чат")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Отмена") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Начать") {
model.openConversation(
jid: jid,
name: name.isEmpty ? nil : name,
addToRoster: addToRoster
)
if model.errorMessage == nil { dismiss() }
}
.fontWeight(.semibold)
.disabled(jid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
#if os(macOS)
.frame(minWidth: 380, minHeight: 300)
#endif
}
}
+94
View File
@@ -0,0 +1,94 @@
import Foundation
import SwiftUI
@MainActor
struct NewGroupView: View {
@ObservedObject var model: AppModel
@Environment(\.dismiss) private var dismiss
@State private var roomJID = ""
@State private var name = ""
@State private var nickname = ""
@State private var invitees = ""
@State private var isJoining = false
var body: some View {
NavigationStack {
Form {
GroupBox {
VStack(alignment: .leading, spacing: 12) {
TextField("room@conference.example.org", text: $roomJID)
.disableAutocorrection(true)
TextField("Название (необязательно)", text: $name)
TextField("Ваш псевдоним", text: $nickname)
}
} label: {
Text("Комната")
.font(.headline)
}
GroupBox {
VStack(alignment: .leading, spacing: 8) {
TextField("alice@example.org, bob@example.org", text: $invitees, axis: .vertical)
.lineLimit(2...5)
Text("Укажите JID через запятую или с новой строки. Если комнаты ещё нет, Prosody создаст её с настройками по умолчанию.")
.font(.footnote)
.foregroundStyle(.secondary)
}
} label: {
Text("Пригласить")
.font(.headline)
}
Label(
"Новая комната настраивается как неанонимная и доступная участникам, чтобы поддерживать групповое OMEMO. Режим шифрования можно изменить в меню замка внутри чата.",
systemImage: "lock.shield.fill"
)
.font(.footnote)
.foregroundStyle(.secondary)
}
.navigationTitle("Новая группа")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Отмена") { dismiss() }
.disabled(isJoining)
}
ToolbarItem(placement: .confirmationAction) {
Button("Создать / войти") { join() }
.fontWeight(.semibold)
.disabled(!canJoin || isJoining)
}
}
.onAppear {
if nickname.isEmpty { nickname = model.suggestedGroupNickname }
}
}
#if os(macOS)
.frame(minWidth: 480, minHeight: 470)
#endif
}
private var canJoin: Bool {
!roomJID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private func join() {
isJoining = true
Task {
await model.createOrJoinGroup(
roomJID: roomJID,
name: name.isEmpty ? nil : name,
nickname: nickname,
invitees: parsedInvitees
)
isJoining = false
if model.errorMessage == nil { dismiss() }
}
}
private var parsedInvitees: [String] {
invitees.components(separatedBy: CharacterSet(charactersIn: ",;\n \t"))
.filter { !$0.isEmpty }
}
}
+46
View File
@@ -0,0 +1,46 @@
import QuickLook
import SwiftUI
struct RootView: View {
@ObservedObject var model: AppModel
var body: some View {
ZStack {
Group {
if model.account == nil {
LoginView(model: model)
} else {
MainChatView(model: model)
}
}
if let item = model.mediaViewerItem {
MediaViewer(item: item, onClose: model.closeMediaViewer)
.transition(.opacity.combined(with: .scale(scale: 0.98)))
.zIndex(100)
}
if let call = model.activeCall {
CallView(model: model, call: call)
.transition(.opacity.combined(with: .scale(scale: 1.015)))
.zIndex(200)
}
}
.animation(.easeInOut(duration: 0.22), value: model.account?.id)
.animation(.easeInOut(duration: 0.2), value: model.mediaViewerItem?.id)
.animation(.easeInOut(duration: 0.2), value: model.activeCall?.id)
.alert("Luma", isPresented: errorBinding) {
Button("OK") { model.clearError() }
} message: {
Text(model.errorMessage ?? "Неизвестная ошибка")
}
.quickLookPreview($model.previewURL)
}
private var errorBinding: Binding<Bool> {
Binding(
get: { model.errorMessage != nil },
set: { visible in if !visible { model.clearError() } }
)
}
}
+194
View File
@@ -0,0 +1,194 @@
import PhotosUI
import SwiftUI
struct SettingsView: View {
@ObservedObject var model: AppModel
@Environment(\.dismiss) private var dismiss
@State private var showingSignOutConfirmation = false
@State private var forgetHistory = false
@State private var avatarItem: PhotosPickerItem?
var body: some View {
NavigationStack {
Form {
if let account = model.account {
Section("Профиль") {
HStack(spacing: 14) {
AvatarView(
conversation: Conversation(
jid: account.normalizedJID,
displayName: account.displayName.isEmpty ? nil : account.displayName
),
imageData: model.avatarData(for: account.normalizedJID),
size: 64
)
VStack(alignment: .leading, spacing: 6) {
Text(account.displayName.isEmpty ? account.normalizedJID : account.displayName)
.font(.headline)
PhotosPicker(selection: $avatarItem, matching: .images) {
Label("Обновить аватар", systemImage: "photo.badge.plus")
}
.disabled(model.isUpdatingAvatar)
}
Spacer()
if model.isUpdatingAvatar {
ProgressView()
}
}
}
Section("Аккаунт") {
LabeledContent("JID", value: account.normalizedJID)
LabeledContent("Ресурс", value: account.effectiveResource)
if let host = account.manualHost {
LabeledContent("Сервер", value: "\(host):\(account.manualPort ?? (account.usesDirectTLS ? 5223 : 5222))")
} else {
LabeledContent("Обнаружение", value: "DNS SRV")
}
}
}
Section("Соединение") {
HStack {
Label(connectionTitle, systemImage: connectionIcon)
.foregroundStyle(connectionColor)
Spacer()
if model.connectionStatus != .connected {
Button("Подключить") { Task { await model.reconnect() } }
}
}
LabeledContent("История MAM", value: model.isArchiveSyncing ? "Синхронизация" : "Готово")
}
Section("Шифрование") {
Toggle("OMEMO по умолчанию", isOn: Binding(
get: { model.globalEncryptionEnabled },
set: { model.setGlobalEncryptionEnabled($0) }
))
Text("Для отдельного чата режим можно изменить через значок замка в его панели.")
.font(.caption)
.foregroundStyle(.secondary)
if !model.globalEncryptionEnabled {
Label("Новые сообщения без отдельного правила отправляются открытым текстом.", systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
}
LabeledContent("Состояние", value: model.isOMEMOReady ? "Активно" : "Подготовка")
if let fingerprint = model.ownFingerprint {
VStack(alignment: .leading, spacing: 7) {
Text("Отпечаток этого устройства")
.font(.subheadline)
Text(fingerprint.chunked(every: 8).joined(separator: " "))
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
}
Section("Уведомления") {
Label("Локальные уведомления включаются после входа", systemImage: "bell.badge")
Text("Для доставки при выгруженном iOS-приложении требуется APNs и XEP-0357 push-шлюз приложения. Обычный XMPP-сокет не может постоянно работать в фоне.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Приватность чата") {
Toggle("Показывать статус набора", isOn: Binding(
get: { model.typingIndicatorsEnabled },
set: { model.setTypingIndicatorsEnabled($0) }
))
Text("Когда включено, Luma показывает «печатает…» и отправляет собеседникам стандартные XMPP-состояния набора.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section {
Toggle("Удалить локальную историю при выходе", isOn: $forgetHistory)
Button(role: .destructive) {
showingSignOutConfirmation = true
} label: {
Label("Выйти из аккаунта", systemImage: "rectangle.portrait.and.arrow.right")
}
}
}
.navigationTitle("Настройки")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Готово") { dismiss() }
}
}
.confirmationDialog(
"Выйти из Luma?",
isPresented: $showingSignOutConfirmation,
titleVisibility: .visible
) {
Button("Выйти", role: .destructive) {
Task {
await model.signOut(forgetHistory: forgetHistory)
dismiss()
}
}
Button("Отмена", role: .cancel) {}
} message: {
Text(forgetHistory
? "Пароль и локальная история будут удалены. Серверная история MAM не изменится."
: "Пароль будет удалён из Keychain. Серверная история MAM не изменится.")
}
.onChange(of: avatarItem) { _, item in
guard let item else { return }
Task {
do {
guard let data = try await item.loadTransferable(type: Data.self) else { return }
await model.updateOwnAvatar(from: data)
avatarItem = nil
} catch {
model.errorMessage = error.localizedDescription
}
}
}
}
#if os(macOS)
.frame(minWidth: 500, minHeight: 560)
#endif
}
private var connectionTitle: String {
switch model.connectionStatus {
case .connected: return "Подключено"
case .connecting: return "Подключение"
case .disconnected: return "Отключено"
}
}
private var connectionIcon: String {
switch model.connectionStatus {
case .connected: return "checkmark.circle.fill"
case .connecting: return "arrow.triangle.2.circlepath"
case .disconnected: return "xmark.circle.fill"
}
}
private var connectionColor: Color {
switch model.connectionStatus {
case .connected: return .green
case .connecting: return .orange
case .disconnected: return .red
}
}
}
private extension String {
func chunked(every size: Int) -> [String] {
guard size > 0 else { return [self] }
var result: [String] = []
var cursor = startIndex
while cursor < endIndex {
let end = index(cursor, offsetBy: size, limitedBy: endIndex) ?? endIndex
result.append(String(self[cursor..<end]))
cursor = end
}
return result
}
}
@@ -0,0 +1,159 @@
import Foundation
struct CapturedCameraMedia: Sendable {
let url: URL
let kind: ChatMessage.Kind
}
#if os(iOS)
import SwiftUI
import UniformTypeIdentifiers
import UIKit
@MainActor
struct SystemPhotoCameraView: UIViewControllerRepresentable {
let onMedia: (Result<CapturedCameraMedia, Error>) -> Void
let onCancel: () -> Void
static var isAvailable: Bool {
UIImagePickerController.isSourceTypeAvailable(.camera)
}
func makeCoordinator() -> Coordinator {
Coordinator(onMedia: onMedia, onCancel: onCancel)
}
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.sourceType = .camera
picker.mediaTypes = [UTType.image.identifier, UTType.movie.identifier]
picker.cameraCaptureMode = .photo
picker.videoQuality = .typeHigh
picker.videoMaximumDuration = 180
picker.allowsEditing = false
picker.modalPresentationStyle = .fullScreen
return picker
}
func updateUIViewController(
_ uiViewController: UIImagePickerController,
context: Context
) {}
final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
private let onMedia: (Result<CapturedCameraMedia, Error>) -> Void
private let onCancel: () -> Void
private var completed = false
init(
onMedia: @escaping (Result<CapturedCameraMedia, Error>) -> Void,
onCancel: @escaping () -> Void
) {
self.onMedia = onMedia
self.onCancel = onCancel
}
func imagePickerController(
_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
) {
guard !completed else { return }
completed = true
let mediaType = (info[.mediaType] as? String).flatMap { UTType($0) }
if mediaType?.conforms(to: .movie) == true,
let sourceURL = info[.mediaURL] as? URL {
prepareMovie(from: sourceURL)
return
}
guard let image = info[.originalImage] as? UIImage else {
onMedia(.failure(SystemCameraError.missingMedia))
return
}
preparePhoto(image)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
guard !completed else { return }
completed = true
onCancel()
}
private func preparePhoto(_ image: UIImage) {
let completion = onMedia
DispatchQueue.global(qos: .userInitiated).async {
let result: Result<CapturedCameraMedia, Error>
do {
let url = try Self.destinationURL(extension: "jpg")
guard let data = image.jpegData(compressionQuality: 0.9),
!data.isEmpty else {
throw SystemCameraError.encodingFailed
}
try data.write(to: url, options: [.atomic])
result = .success(CapturedCameraMedia(url: url, kind: .photo))
} catch {
result = .failure(error)
}
DispatchQueue.main.async { completion(result) }
}
}
private func prepareMovie(from sourceURL: URL) {
let completion = onMedia
DispatchQueue.global(qos: .userInitiated).async {
let result: Result<CapturedCameraMedia, Error>
do {
let fileExtension = sourceURL.pathExtension.isEmpty
? "mov"
: sourceURL.pathExtension
let destination = try Self.destinationURL(extension: fileExtension)
try FileManager.default.copyItem(at: sourceURL, to: destination)
let values = try destination.resourceValues(
forKeys: [.fileSizeKey, .isRegularFileKey]
)
guard values.isRegularFile == true,
(values.fileSize ?? 0) > 0 else {
try? FileManager.default.removeItem(at: destination)
throw SystemCameraError.emptyMovie
}
result = .success(CapturedCameraMedia(url: destination, kind: .video))
} catch {
result = .failure(error)
}
DispatchQueue.main.async { completion(result) }
}
}
private static func destinationURL(extension fileExtension: String) throws -> URL {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("LumaCameraMedia", isDirectory: true)
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
return directory.appendingPathComponent(
"camera-\(UUID().uuidString).\(fileExtension)"
)
}
}
}
private enum SystemCameraError: LocalizedError {
case missingMedia
case encodingFailed
case emptyMovie
var errorDescription: String? {
switch self {
case .missingMedia:
return "Камера не вернула фото или видео."
case .encodingFailed:
return "Не удалось подготовить фотографию к отправке."
case .emptyMovie:
return "Камера вернула пустой видеофайл. Повторите запись."
}
}
}
#endif
@@ -0,0 +1,192 @@
import AVFoundation
import SwiftUI
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
@MainActor
struct VideoNoteCaptureView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var recorder = VideoNoteRecorder()
@State private var errorMessage: String?
@State private var isFinalizing = false
let onComplete: (VideoNoteRecorder.Recording) -> Void
var body: some View {
NavigationStack {
VStack(spacing: 22) {
ZStack {
Circle().fill(Color.black)
CameraPreview(session: recorder.session)
.clipShape(Circle())
if !recorder.isPrepared {
ProgressView("Подготовка камеры…")
.tint(.white)
.foregroundStyle(.white)
}
}
.frame(maxWidth: 420)
.aspectRatio(1, contentMode: .fit)
.overlay(Circle().stroke(.white.opacity(0.16), lineWidth: 1))
Text(recordingStatus)
.font(.system(.title3, design: .monospaced).weight(.semibold))
.foregroundStyle(recorder.isRecording ? .red : .secondary)
Button {
guard recorder.isRecording, !isFinalizing else { return }
isFinalizing = true
Task { @MainActor in
await recorder.stop(
afterMinimumDuration: VideoNoteStopPolicy.minimumCaptureDuration
)
}
} label: {
ZStack {
Circle()
.stroke(Color.red, lineWidth: 4)
.frame(width: 76, height: 76)
if isFinalizing {
ProgressView()
.controlSize(.regular)
.tint(.red)
} else {
RoundedRectangle(cornerRadius: 7)
.fill(Color.red)
.frame(width: 32, height: 32)
}
}
}
.buttonStyle(.plain)
.disabled(!recorder.isRecording || isFinalizing)
.accessibilityLabel(isFinalizing ? "Видео завершается" : "Завершить запись")
Text("Запись началась. Нажмите красную кнопку, чтобы завершить и отправить.")
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(24)
.navigationTitle("Видеосообщение")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Отмена") {
recorder.cancel()
dismiss()
}
}
}
}
.task {
recorder.onFinished = { result in
switch result {
case .success(let recording):
onComplete(recording)
dismiss()
case .failure(let error):
isFinalizing = false
errorMessage = error.localizedDescription
}
}
do {
try await recorder.prepare()
guard !Task.isCancelled else { return }
try recorder.start()
} catch {
errorMessage = error.localizedDescription
}
}
.onDisappear {
if recorder.isRecording {
recorder.cancel()
} else {
recorder.shutdown()
}
}
.alert("Luma", isPresented: Binding(
get: { errorMessage != nil },
set: { if !$0 { errorMessage = nil } }
)) {
Button("OK") { errorMessage = nil }
} message: {
Text(errorMessage ?? "Неизвестная ошибка")
}
#if os(macOS)
.frame(minWidth: 540, minHeight: 650)
#endif
}
private func formatted(_ duration: TimeInterval) -> String {
let seconds = max(0, Int(duration.rounded(.down)))
return String(format: "%d:%02d", seconds / 60, seconds % 60)
}
private var recordingStatus: String {
if isFinalizing { return "Завершение…" }
if recorder.isRecording { return formatted(recorder.elapsed) }
return "Подготовка камеры…"
}
}
#if os(iOS)
struct CameraPreview: UIViewRepresentable {
let session: AVCaptureSession
func makeUIView(context: Context) -> PreviewView {
let view = PreviewView()
view.previewLayer.session = session
view.previewLayer.videoGravity = .resizeAspectFill
return view
}
func updateUIView(_ uiView: PreviewView, context: Context) {
uiView.previewLayer.session = session
}
final class PreviewView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var previewLayer: AVCaptureVideoPreviewLayer {
layer as! AVCaptureVideoPreviewLayer
}
}
}
#elseif os(macOS)
struct CameraPreview: NSViewRepresentable {
let session: AVCaptureSession
func makeNSView(context: Context) -> PreviewView {
let view = PreviewView()
view.previewLayer.session = session
view.previewLayer.videoGravity = .resizeAspectFill
return view
}
func updateNSView(_ nsView: PreviewView, context: Context) {
nsView.previewLayer.session = session
}
final class PreviewView: NSView {
let previewLayer = AVCaptureVideoPreviewLayer()
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
wantsLayer = true
layer = previewLayer
}
required init?(coder: NSCoder) {
return nil
}
override func layout() {
super.layout()
previewLayer.frame = bounds
}
}
}
#endif
@@ -0,0 +1,60 @@
import CryptoKit
import Foundation
import MartinOMEMO
final class CryptoKitAESGCMEngine: AES_GCM_Engine {
func encrypt(
iv: Data,
key: Data,
message: Data,
output: UnsafeMutablePointer<Data>?,
tag: UnsafeMutablePointer<Data>?
) -> Bool {
do {
let nonce = try AES.GCM.Nonce(data: iv)
let sealed = try AES.GCM.seal(
message,
using: SymmetricKey(data: key),
nonce: nonce
)
output?.pointee = sealed.ciphertext
tag?.pointee = sealed.tag
return true
} catch {
return false
}
}
func decrypt(
iv: Data,
key: Data,
encoded: Data,
auth tag: Data?,
output: UnsafeMutablePointer<Data>?
) -> Bool {
do {
let nonce = try AES.GCM.Nonce(data: iv)
let authenticationTag: Data
let ciphertext: Data
if let tag {
authenticationTag = tag
ciphertext = encoded
} else {
guard encoded.count >= 16 else { return false }
authenticationTag = Data(encoded.suffix(16))
ciphertext = Data(encoded.dropLast(16))
}
let box = try AES.GCM.SealedBox(
nonce: nonce,
ciphertext: ciphertext,
tag: authenticationTag
)
output?.pointee = try AES.GCM.open(box, using: SymmetricKey(data: key))
return true
} catch {
return false
}
}
}
File diff suppressed because it is too large. Load diff
+586
View File
@@ -0,0 +1,586 @@
import Foundation
import MartinOMEMO
final class LumaOMEMOStore: SignalStorage {
private let repository: OMEMOStateRepository
private let sessionAdapter: LumaSessionStore
private let preKeyAdapter: LumaPreKeyStore
private let signedPreKeyAdapter: LumaSignedPreKeyStore
private let identityAdapter: LumaIdentityKeyStore
private let senderKeyAdapter: LumaSenderKeyStore
private var signalContext: SignalContext?
init(accountJID: String) {
let repository = OMEMOStateRepository(accountJID: accountJID)
let sessionAdapter = LumaSessionStore(repository: repository)
let preKeyAdapter = LumaPreKeyStore(repository: repository)
let signedPreKeyAdapter = LumaSignedPreKeyStore(repository: repository)
let identityAdapter = LumaIdentityKeyStore(repository: repository)
let senderKeyAdapter = LumaSenderKeyStore(repository: repository)
self.repository = repository
self.sessionAdapter = sessionAdapter
self.preKeyAdapter = preKeyAdapter
self.signedPreKeyAdapter = signedPreKeyAdapter
self.identityAdapter = identityAdapter
self.senderKeyAdapter = senderKeyAdapter
super.init(
sessionStore: sessionAdapter,
preKeyStore: preKeyAdapter,
signedPreKeyStore: signedPreKeyAdapter,
identityKeyStore: identityAdapter,
senderKeyStore: senderKeyAdapter
)
}
override func setup(withContext context: SignalContext) {
signalContext = context
_ = regenerateKeys(wipe: false)
super.setup(withContext: context)
}
override func regenerateKeys(wipe: Bool = false) -> Bool {
guard let signalContext else { return false }
if wipe {
repository.reset()
}
let needsIdentity = identityAdapter.localRegistrationId() == 0 || identityAdapter.keyPair() == nil
guard needsIdentity else { return true }
let registrationID = signalContext.generateRegistrationId()
guard let pair = SignalIdentityKeyPair.generateKeyPair(context: signalContext),
let publicKey = pair.publicKey else {
return false
}
repository.mutate { state in
state.registrationID = registrationID
state.identityKeyPair = pair.serialized()
let address = SignalAddress(
name: repository.accountJID,
deviceId: Int32(bitPattern: registrationID)
)
state.identities[address.storageKey] = StoredOMEMOIdentity(
name: address.name,
deviceID: address.deviceId,
fingerprint: Self.fingerprint(publicKey),
key: publicKey,
status: IdentityStatus.verifiedActive.rawValue,
own: true
)
}
return true
}
var ownFingerprint: String? {
let registrationID = identityAdapter.localRegistrationId()
guard registrationID != 0 else { return nil }
return identityAdapter.identityFingerprint(
forAddress: SignalAddress(
name: repository.accountJID,
deviceId: Int32(bitPattern: registrationID)
)
)
}
func devices(for jid: String) -> [OMEMODevice] {
identityAdapter.identities(forName: jid.lowercased()).map { identity in
OMEMODevice(
jid: identity.address.name,
deviceID: identity.address.deviceId,
fingerprint: identity.fingerprint,
trust: Self.mapTrust(identity.status),
isActive: identity.status.isActive,
isOwn: identity.own
)
}
.sorted { lhs, rhs in lhs.deviceID < rhs.deviceID }
}
func hasSession(for address: SignalAddress) -> Bool {
sessionAdapter.containsSessionRecord(forAddress: address)
}
func flushPendingPersistence() {
repository.flush()
}
@discardableResult
func setVerified(_ verified: Bool, jid: String, deviceID: Int32) -> Bool {
identityAdapter.setStatus(
verified ? .verifiedActive : .trustedActive,
forIdentity: SignalAddress(name: jid.lowercased(), deviceId: deviceID)
)
}
private static func mapTrust(_ status: IdentityStatus) -> OMEMODevice.Trust {
switch status.trust {
case .undecided: return .undecided
case .trusted: return .trusted
case .verified: return .verified
case .compromised: return .compromised
}
}
fileprivate static func fingerprint(_ data: Data) -> String {
data.map { String(format: "%02x", $0) }.joined()
}
}
private final class LumaSessionStore: SignalSessionStoreProtocol {
private let repository: OMEMOStateRepository
init(repository: OMEMOStateRepository) {
self.repository = repository
}
func sessionRecord(forAddress address: SignalAddress) -> Data? {
repository.read { $0.sessions[address.storageKey] }
}
func allDevices(for name: String, activeAndTrusted: Bool) -> [Int32] {
repository.read { state in
state.sessions.keys.compactMap { key in
guard let address = StoredAddress(key), address.name == name.lowercased() else { return nil }
guard activeAndTrusted else { return address.deviceID }
guard let identity = state.identities[key] else { return address.deviceID }
let status = IdentityStatus(rawValue: identity.status) ?? .undecidedActive
let trusted = status.trust == .trusted || status.trust == .verified
return status.isActive && trusted ? address.deviceID : nil
}
}
}
func storeSessionRecord(_ data: Data, forAddress address: SignalAddress) -> Bool {
repository.mutate { $0.sessions[address.storageKey] = data }
return true
}
func containsSessionRecord(forAddress address: SignalAddress) -> Bool {
sessionRecord(forAddress: address) != nil
}
func deleteSessionRecord(forAddress address: SignalAddress) -> Bool {
repository.mutate { $0.sessions.removeValue(forKey: address.storageKey) }
return true
}
func deleteAllSessions(for name: String) -> Bool {
repository.mutate { state in
state.sessions = state.sessions.filter { key, _ in
StoredAddress(key)?.name != name.lowercased()
}
}
return true
}
}
private final class LumaPreKeyStore: SignalPreKeyStoreProtocol {
private let repository: OMEMOStateRepository
private let deletionLock = NSLock()
private var pendingDeletion: Set<UInt32> = []
init(repository: OMEMOStateRepository) {
self.repository = repository
}
func currentPreKeyId() -> UInt32 {
repository.read { state in
state.preKeys.keys.compactMap(UInt32.init).max() ?? 0
}
}
func loadPreKey(withId id: UInt32) -> Data? {
repository.read { $0.preKeys[String(id)] }
}
func storePreKey(_ data: Data, withId id: UInt32) -> Bool {
repository.mutate { $0.preKeys[String(id)] = data }
return true
}
func containsPreKey(withId id: UInt32) -> Bool {
loadPreKey(withId: id) != nil
}
func deletePreKey(withId id: UInt32) -> Bool {
deletionLock.lock()
pendingDeletion.insert(id)
deletionLock.unlock()
return true
}
func flushDeletedPreKeys() -> Bool {
deletionLock.lock()
let ids = pendingDeletion
pendingDeletion.removeAll()
deletionLock.unlock()
guard !ids.isEmpty else { return false }
repository.mutate { state in
ids.forEach { state.preKeys.removeValue(forKey: String($0)) }
}
return true
}
}
private final class LumaSignedPreKeyStore: SignalSignedPreKeyStoreProtocol {
private let repository: OMEMOStateRepository
init(repository: OMEMOStateRepository) {
self.repository = repository
}
func countSignedPreKeys() -> Int {
repository.read { $0.signedPreKeys.count }
}
func loadSignedPreKey(withId id: UInt32) -> Data? {
repository.read { $0.signedPreKeys[String(id)] }
}
func storeSignedPreKey(_ data: Data, withId id: UInt32) -> Bool {
repository.mutate { $0.signedPreKeys[String(id)] = data }
return true
}
func containsSignedPreKey(withId id: UInt32) -> Bool {
loadSignedPreKey(withId: id) != nil
}
func deleteSignedPreKey(withId id: UInt32) -> Bool {
repository.mutate { $0.signedPreKeys.removeValue(forKey: String(id)) }
return true
}
}
private final class LumaIdentityKeyStore: SignalIdentityKeyStoreProtocol {
private let repository: OMEMOStateRepository
init(repository: OMEMOStateRepository) {
self.repository = repository
}
func keyPair() -> SignalIdentityKeyPairProtocol? {
guard let data = repository.read({ $0.identityKeyPair }) else { return nil }
return SignalIdentityKeyPair(fromKeyPairData: data)
}
func localRegistrationId() -> UInt32 {
repository.read { $0.registrationID }
}
func save(identity: SignalAddress, key: SignalIdentityKeyProtocol?) -> Bool {
save(identity: identity, publicKeyData: key?.publicKey)
}
func isTrusted(identity: SignalAddress, key: SignalIdentityKeyProtocol?) -> Bool {
isTrusted(identity: identity, publicKeyData: key?.publicKey)
}
func save(identity: SignalAddress, publicKeyData: Data?) -> Bool {
guard let publicKeyData else { return false }
let fingerprint = LumaOMEMOStore.fingerprint(publicKeyData)
repository.mutate { state in
let existing = state.identities[identity.storageKey]
let changed = existing.map { $0.key != publicKeyData } ?? false
state.identities[identity.storageKey] = StoredOMEMOIdentity(
name: identity.name.lowercased(),
deviceID: identity.deviceId,
fingerprint: fingerprint,
key: publicKeyData,
status: changed ? IdentityStatus.undecidedActive.rawValue : (existing?.status ?? IdentityStatus.trustedActive.rawValue),
own: existing?.own ?? false
)
}
return true
}
func isTrusted(identity: SignalAddress, publicKeyData: Data?) -> Bool {
guard let publicKeyData else { return false }
return repository.read { state in
guard let existing = state.identities[identity.storageKey] else {
return true
}
let status = IdentityStatus(rawValue: existing.status) ?? .undecidedActive
return existing.key == publicKeyData
&& (status.trust == .trusted || status.trust == .verified)
}
}
func setStatus(_ status: IdentityStatus, forIdentity identity: SignalAddress) -> Bool {
var updated = false
repository.mutate { state in
guard var value = state.identities[identity.storageKey] else { return }
value.status = status.rawValue
state.identities[identity.storageKey] = value
updated = true
}
return updated
}
func setStatus(active: Bool, forIdentity identity: SignalAddress) -> Bool {
var updated = false
repository.mutate { state in
guard var value = state.identities[identity.storageKey],
let status = IdentityStatus(rawValue: value.status) else { return }
value.status = (active ? status.toActive() : status.toInactive()).rawValue
state.identities[identity.storageKey] = value
updated = true
}
return updated
}
func identities(forName name: String) -> [Identity] {
repository.read { state in
state.identities.values.compactMap { value in
guard value.name == name.lowercased(),
let status = IdentityStatus(rawValue: value.status) else { return nil }
return Identity(
address: SignalAddress(name: value.name, deviceId: value.deviceID),
status: status,
fingerprint: value.fingerprint,
key: value.key,
own: value.own
)
}
}
}
func identityFingerprint(forAddress address: SignalAddress) -> String? {
repository.read { $0.identities[address.storageKey]?.fingerprint }
}
}
private final class LumaSenderKeyStore: SignalSenderKeyStoreProtocol {
private let repository: OMEMOStateRepository
init(repository: OMEMOStateRepository) {
self.repository = repository
}
func storeSenderKey(_ key: Data, address: SignalAddress?, groupId: String?) -> Bool {
repository.mutate { state in
state.senderKeys[Self.key(address: address, groupID: groupId)] = key
}
return true
}
func loadSenderKey(forAddress address: SignalAddress?, groupId: String?) -> Data? {
repository.read { $0.senderKeys[Self.key(address: address, groupID: groupId)] }
}
private static func key(address: SignalAddress?, groupID: String?) -> String {
"\(address?.storageKey ?? "-")|\(groupID ?? "-")"
}
}
private final class OMEMOStateRepository: @unchecked Sendable {
fileprivate let accountJID: String
private let lock = NSLock()
private let persistenceQueue = DispatchQueue(
label: "app.luma.omemo.persistence",
qos: .utility
)
private let fileURL: URL
private var state: StoredOMEMOState
private var persistenceRevision: UInt64 = 0
private var persistenceScheduled = false
private var persistenceNotBeforeNanoseconds: UInt64 = 0
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private static let persistenceDebounceNanoseconds: UInt64 = 350_000_000
init(accountJID: String) {
self.accountJID = accountJID.lowercased()
let fileManager = FileManager.default
let root = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? fileManager.temporaryDirectory
let directory = root.appendingPathComponent("Luma/OMEMO", isDirectory: true)
try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
fileURL = directory.appendingPathComponent("\(Self.stableHash(accountJID)).json")
encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys]
decoder = JSONDecoder()
state = .empty
if let data = try? Data(contentsOf: fileURL),
let decoded = try? decoder.decode(StoredOMEMOState.self, from: data) {
state = decoded
}
}
func read<T>(_ operation: (StoredOMEMOState) -> T) -> T {
lock.lock()
defer { lock.unlock() }
return operation(state)
}
func mutate(_ operation: (inout StoredOMEMOState) -> Void) {
let shouldSchedule: Bool
let scheduledDeadlineNanoseconds: UInt64
lock.lock()
operation(&state)
persistenceRevision &+= 1
persistenceNotBeforeNanoseconds = DispatchTime.now().uptimeNanoseconds
&+ Self.persistenceDebounceNanoseconds
scheduledDeadlineNanoseconds = persistenceNotBeforeNanoseconds
shouldSchedule = !persistenceScheduled
if shouldSchedule {
persistenceScheduled = true
}
lock.unlock()
if shouldSchedule {
persistenceQueue.asyncAfter(
deadline: DispatchTime(
uptimeNanoseconds: scheduledDeadlineNanoseconds
)
) { [weak self] in
self?.persistWhenQuiet()
}
}
}
func reset() {
mutate { $0 = .empty }
}
func flush() {
persistenceQueue.sync { [self] in
persistUntilCurrent()
}
}
private func persistWhenQuiet() {
let snapshot: StoredOMEMOState
let revision: UInt64
let notBefore: UInt64
lock.lock()
guard persistenceScheduled else {
lock.unlock()
return
}
notBefore = persistenceNotBeforeNanoseconds
let now = DispatchTime.now().uptimeNanoseconds
guard now >= notBefore else {
lock.unlock()
persistenceQueue.asyncAfter(
deadline: DispatchTime(uptimeNanoseconds: notBefore)
) { [weak self] in
self?.persistWhenQuiet()
}
return
}
snapshot = state
revision = persistenceRevision
lock.unlock()
persist(snapshot)
lock.lock()
if revision == persistenceRevision {
persistenceScheduled = false
lock.unlock()
return
}
let retryAt = persistenceNotBeforeNanoseconds
lock.unlock()
persistenceQueue.asyncAfter(
deadline: DispatchTime(uptimeNanoseconds: retryAt)
) { [weak self] in
self?.persistWhenQuiet()
}
}
private func persistUntilCurrent() {
while true {
let snapshot: StoredOMEMOState
let revision: UInt64
lock.lock()
snapshot = state
revision = persistenceRevision
lock.unlock()
persist(snapshot)
lock.lock()
if revision == persistenceRevision {
persistenceScheduled = false
lock.unlock()
return
}
lock.unlock()
}
}
private func persist(_ snapshot: StoredOMEMOState) {
guard let data = try? encoder.encode(snapshot) else { return }
try? data.write(to: fileURL, options: [.atomic])
#if os(iOS)
try? FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
ofItemAtPath: fileURL.path
)
#endif
}
private static func stableHash(_ value: String) -> String {
let hash = value.lowercased().utf8.reduce(UInt64(14_695_981_039_346_656_037)) { partial, byte in
(partial ^ UInt64(byte)) &* 1_099_511_628_211
}
return String(hash, radix: 16)
}
}
private struct StoredOMEMOState: Codable {
var registrationID: UInt32
var identityKeyPair: Data?
var identities: [String: StoredOMEMOIdentity]
var preKeys: [String: Data]
var signedPreKeys: [String: Data]
var sessions: [String: Data]
var senderKeys: [String: Data]
static let empty = StoredOMEMOState(
registrationID: 0,
identityKeyPair: nil,
identities: [:],
preKeys: [:],
signedPreKeys: [:],
sessions: [:],
senderKeys: [:]
)
}
private struct StoredOMEMOIdentity: Codable {
var name: String
var deviceID: Int32
var fingerprint: String
var key: Data
var status: Int
var own: Bool
}
private struct StoredAddress {
let name: String
let deviceID: Int32
init?(_ storageKey: String) {
guard let separator = storageKey.lastIndex(of: "|") else { return nil }
let rawName = String(storageKey[..<separator])
let rawDevice = String(storageKey[storageKey.index(after: separator)...])
guard !rawName.isEmpty, let deviceID = Int32(rawDevice) else { return nil }
name = rawName
self.deviceID = deviceID
}
}
private extension SignalAddress {
var storageKey: String {
"\(name.lowercased())|\(deviceId)"
}
}
+57
View File
@@ -0,0 +1,57 @@
import Foundation
import Martin
/// Martin 3.2.4 does not expose `DefaultRoomStore.init()` outside its module.
/// This mirrors its in-memory behavior so the app can register XEP-0045 MUC.
final class LumaRoomStore: RoomStore {
typealias Room = RoomBase
private let dispatcher = QueueDispatcher(label: "LumaRoomStore")
private var roomsByJID: [BareJID: RoomBase] = [:]
func rooms(for context: Context) -> [RoomBase] {
dispatcher.sync {
Array(roomsByJID.values)
}
}
func room(for context: Context, with jid: BareJID) -> RoomBase? {
dispatcher.sync {
roomsByJID[jid]
}
}
func createRoom(
for context: Context,
with jid: BareJID,
nickname: String,
password: String?
) -> ConversationCreateResult<RoomBase> {
dispatcher.sync {
if let room = roomsByJID[jid] {
return .found(room)
}
let room = RoomBase(
context: context,
jid: jid,
nickname: nickname,
password: password,
dispatcher: dispatcher
)
roomsByJID[jid] = room
return .created(room)
}
}
func close(room: RoomBase) -> Bool {
dispatcher.sync {
guard roomsByJID[room.jid] === room else { return false }
roomsByJID.removeValue(forKey: room.jid)
return true
}
}
func initialize(context: Context) {}
func deinitialize(context: Context) {}
}
+61
View File
@@ -0,0 +1,61 @@
import Foundation
import Martin
final class LumaRosterStore: RosterStore {
typealias RosterItem = RosterItemBase
private let queue = DispatchQueue(label: "app.luma.roster-store")
private var itemsByJID: [JID: RosterItemBase] = [:]
private var rosterVersion: String?
func clear(for context: Context) {
queue.sync {
itemsByJID.removeAll()
rosterVersion = nil
}
}
func items(for context: Context) -> [RosterItemBase] {
queue.sync { Array(itemsByJID.values) }
}
func item(for context: Context, jid: JID) -> RosterItemBase? {
queue.sync { itemsByJID[jid] }
}
func updateItem(
for context: Context,
jid: JID,
name: String?,
subscription: RosterItemSubscription,
groups: [String],
ask: Bool,
annotations: [RosterItemAnnotation]
) {
let item = RosterItemBase(
jid: jid,
name: name,
subscription: subscription,
groups: groups,
ask: ask,
annotations: annotations
)
queue.sync { itemsByJID[jid] = item }
}
func deleteItem(for context: Context, jid: JID) {
_ = queue.sync { itemsByJID.removeValue(forKey: jid) }
}
func version(for context: Context) -> String? {
queue.sync { rosterVersion }
}
func set(version: String?, for context: Context) {
queue.sync { rosterVersion = version }
}
func initialize(context: Context) {}
func deinitialize(context: Context) {}
}
@@ -0,0 +1,34 @@
import Foundation
import Martin
final class StaticDNSSrvResolver: DNSSrvResolver {
private let host: String
private let port: Int
private let directTLS: Bool
init(host: String, port: Int, directTLS: Bool) {
self.host = host
self.port = port
self.directTLS = directTLS
}
func resolve(
domain: String,
for jid: BareJID,
completionHandler: @escaping (Result<XMPPSrvResult, DNSError>) -> Void
) {
let record = XMPPSrvRecord(
port: port,
weight: 0,
priority: 0,
target: host,
directTls: directTLS
)
completionHandler(.success(XMPPSrvResult(domain: domain, records: [record])))
}
func markAsInvalid(for domain: String, record: XMPPSrvRecord, for period: TimeInterval) {}
func markAsInvalid(for domain: String, host: String, port: Int, for period: TimeInterval) {}
}
File diff suppressed because it is too large. Load diff