0.10.6
This commit is contained in:
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 "Несколько участников печатают…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 "Выключено для чата"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = "Не удалось отправить. Файлы сохранены здесь — проверьте соединение и повторите попытку."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import WebRTC
|
||||
|
||||
@MainActor
|
||||
struct CallView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
let call: CallSnapshot
|
||||
|
||||
private var conversation: Conversation {
|
||||
model.conversations.first(where: { $0.id == call.peerJID.lowercased() })
|
||||
?? Conversation(jid: call.peerJID, displayName: model.displayName(for: call.peerJID))
|
||||
}
|
||||
|
||||
private var remoteTrack: RTCVideoTrack? {
|
||||
model.remoteCallVideoTrack(for: call.id)
|
||||
}
|
||||
|
||||
private var localTrack: RTCVideoTrack? {
|
||||
model.localCallVideoTrack(for: call.id)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
background
|
||||
|
||||
VStack(spacing: 24) {
|
||||
callHeader
|
||||
.padding(.top, 22)
|
||||
|
||||
Spacer(minLength: 12)
|
||||
|
||||
if remoteTrack == nil {
|
||||
contactIdentity
|
||||
}
|
||||
|
||||
Spacer(minLength: 20)
|
||||
|
||||
controls
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
if call.isVideoCall, let localTrack, call.isCameraEnabled {
|
||||
RTCVideoRendererView(track: localTrack, mirrored: true)
|
||||
.frame(width: 118, height: 168)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
||||
.stroke(.white.opacity(0.24), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.38), radius: 16, y: 8)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
|
||||
.padding(.top, 92)
|
||||
.padding(.trailing, 18)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var background: some View {
|
||||
if call.isVideoCall, let remoteTrack {
|
||||
RTCVideoRendererView(track: remoteTrack)
|
||||
.ignoresSafeArea()
|
||||
.overlay {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
.black.opacity(0.48),
|
||||
.clear,
|
||||
.black.opacity(0.62)
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LinearGradient(
|
||||
colors: [Color(red: 0.11, green: 0.17, blue: 0.24), .black],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.overlay {
|
||||
Circle()
|
||||
.fill(Color.accentColor.opacity(0.24))
|
||||
.frame(width: 440, height: 440)
|
||||
.blur(radius: 90)
|
||||
.offset(x: 170, y: -260)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
private var callHeader: some View {
|
||||
VStack(spacing: 5) {
|
||||
Text(conversation.displayName)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
TimelineView(.periodic(from: .now, by: 1)) { context in
|
||||
Text(statusText(at: context.date))
|
||||
.font(.subheadline.monospacedDigit())
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private var contactIdentity: some View {
|
||||
VStack(spacing: 18) {
|
||||
AvatarView(
|
||||
conversation: conversation,
|
||||
imageData: model.avatarData(for: call.peerJID),
|
||||
size: 132
|
||||
)
|
||||
.overlay {
|
||||
Circle().stroke(.white.opacity(0.14), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.32), radius: 28, y: 14)
|
||||
|
||||
Text(conversation.displayName)
|
||||
.font(.system(size: 30, weight: .semibold, design: .rounded))
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
|
||||
Text(call.isVideoCall ? "Видеозвонок Luma" : "Аудиозвонок Luma")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white.opacity(0.66))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var controls: some View {
|
||||
if call.isIncomingRinging {
|
||||
HStack(spacing: 52) {
|
||||
CallControlButton(
|
||||
title: "Отклонить",
|
||||
systemImage: "phone.down.fill",
|
||||
color: .red,
|
||||
action: model.rejectCall
|
||||
)
|
||||
CallControlButton(
|
||||
title: "Ответить",
|
||||
systemImage: call.isVideoCall ? "video.fill" : "phone.fill",
|
||||
color: .green
|
||||
) {
|
||||
Task { await model.answerCall() }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
VStack(spacing: 22) {
|
||||
HStack(spacing: call.isVideoCall ? 10 : 24) {
|
||||
CallControlButton(
|
||||
title: call.isMuted ? "Включить" : "Микрофон",
|
||||
systemImage: call.isMuted ? "mic.slash.fill" : "mic.fill",
|
||||
color: call.isMuted ? .white.opacity(0.34) : .white.opacity(0.18)
|
||||
) {
|
||||
model.setCallMuted(!call.isMuted)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
CallControlButton(
|
||||
title: "Динамик",
|
||||
systemImage: call.isSpeakerEnabled ? "speaker.wave.3.fill" : "speaker.fill",
|
||||
color: call.isSpeakerEnabled ? .white.opacity(0.34) : .white.opacity(0.18)
|
||||
) {
|
||||
model.setCallSpeakerEnabled(!call.isSpeakerEnabled)
|
||||
}
|
||||
#endif
|
||||
|
||||
if call.isVideoCall {
|
||||
CallControlButton(
|
||||
title: call.isCameraEnabled ? "Камера" : "Включить",
|
||||
systemImage: call.isCameraEnabled ? "video.fill" : "video.slash.fill",
|
||||
color: call.isCameraEnabled ? .white.opacity(0.18) : .white.opacity(0.34)
|
||||
) {
|
||||
model.setCallCameraEnabled(!call.isCameraEnabled)
|
||||
}
|
||||
CallControlButton(
|
||||
title: "Повернуть",
|
||||
systemImage: "camera.rotate.fill",
|
||||
color: .white.opacity(0.18),
|
||||
action: model.switchCallCamera
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
CallControlButton(
|
||||
title: "Завершить",
|
||||
systemImage: "phone.down.fill",
|
||||
color: .red,
|
||||
action: model.endCall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func statusText(at date: Date) -> String {
|
||||
switch call.phase {
|
||||
case .ringing:
|
||||
return call.direction == .incoming ? "Входящий звонок" : "Вызов…"
|
||||
case .connecting:
|
||||
return "Соединение…"
|
||||
case .connected:
|
||||
guard let connectedAt = call.connectedAt else { return "Соединено" }
|
||||
let total = max(0, Int(date.timeIntervalSince(connectedAt)))
|
||||
let hours = total / 3_600
|
||||
let minutes = (total % 3_600) / 60
|
||||
let seconds = total % 60
|
||||
return hours > 0
|
||||
? String(format: "%d:%02d:%02d", hours, minutes, seconds)
|
||||
: String(format: "%02d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CallControlButton: View {
|
||||
let title: String
|
||||
let systemImage: String
|
||||
let color: Color
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 58, height: 58)
|
||||
.overlay {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
}
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.frame(minWidth: 58)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.white)
|
||||
.accessibilityLabel(title)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,307 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct AudioMessagePlayer: View {
|
||||
@ObservedObject var model: AppModel
|
||||
@ObservedObject private var playback: MediaPlaybackCoordinator
|
||||
let message: ChatMessage
|
||||
let onFallbackTap: () -> Void
|
||||
|
||||
init(
|
||||
model: AppModel,
|
||||
message: ChatMessage,
|
||||
onFallbackTap: @escaping () -> Void
|
||||
) {
|
||||
self.model = model
|
||||
_playback = ObservedObject(wrappedValue: model.audioPlayback)
|
||||
self.message = message
|
||||
self.onFallbackTap = onFallbackTap
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if message.kind == .voice {
|
||||
voicePlayer
|
||||
} else {
|
||||
musicPlayer
|
||||
}
|
||||
}
|
||||
.frame(width: 252, alignment: .leading)
|
||||
.task(id: message.remoteAttachmentURL) {
|
||||
await model.prepareMediaPreview(message)
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
|
||||
private var voicePlayer: some View {
|
||||
HStack(spacing: 10) {
|
||||
playButton
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
WaveformScrubber(
|
||||
samples: model.audioWaveform(for: message),
|
||||
progress: progress,
|
||||
activeColor: accent,
|
||||
inactiveColor: secondaryAccent,
|
||||
onSeek: seek
|
||||
)
|
||||
.frame(height: 30)
|
||||
|
||||
HStack(spacing: 7) {
|
||||
Text(voiceTimeText)
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(secondaryText)
|
||||
Spacer()
|
||||
Button(action: playback.cycleVoiceRate) {
|
||||
Text(rateText)
|
||||
.font(.caption2.monospacedDigit().weight(.bold))
|
||||
.foregroundStyle(accent)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(accent.opacity(0.14), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Скорость воспроизведения \(rateText)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var musicPlayer: some View {
|
||||
HStack(spacing: 11) {
|
||||
playButton
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(message.localFilename ?? "Аудиофайл")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(primaryText)
|
||||
.lineLimit(1)
|
||||
|
||||
MediaProgressScrubber(
|
||||
progress: progress,
|
||||
activeColor: accent,
|
||||
inactiveColor: secondaryAccent,
|
||||
onSeek: seek
|
||||
)
|
||||
.frame(height: 12)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Text(audioTimeText)
|
||||
if let byteCount = message.byteCount {
|
||||
Text("·")
|
||||
Text(ByteCountFormatter.string(
|
||||
fromByteCount: Int64(byteCount),
|
||||
countStyle: .file
|
||||
))
|
||||
}
|
||||
}
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(secondaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var playButton: some View {
|
||||
Button(action: togglePlayback) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(accent.opacity(message.direction == .outgoing ? 0.2 : 0.14))
|
||||
if model.isMediaPreviewLoading(message) && localURL == nil {
|
||||
ProgressView()
|
||||
.tint(accent)
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundStyle(accent)
|
||||
.offset(x: isPlaying ? 0 : 1)
|
||||
}
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(isPlaying ? "Пауза" : "Воспроизвести")
|
||||
}
|
||||
|
||||
private var localURL: URL? {
|
||||
model.mediaPreviewURL(for: message)
|
||||
}
|
||||
|
||||
private var isCurrent: Bool {
|
||||
playback.currentMessageID == message.id
|
||||
}
|
||||
|
||||
private var isPlaying: Bool {
|
||||
isCurrent && playback.isPlaying
|
||||
}
|
||||
|
||||
private var totalDuration: TimeInterval {
|
||||
let value = isCurrent && playback.duration > 0
|
||||
? playback.duration
|
||||
: (message.duration ?? 0)
|
||||
return max(0, value)
|
||||
}
|
||||
|
||||
private var displayedTime: TimeInterval {
|
||||
isCurrent ? playback.currentTime : 0
|
||||
}
|
||||
|
||||
private var progress: Double {
|
||||
guard isCurrent, totalDuration > 0 else { return 0 }
|
||||
return max(0, min(1, playback.currentTime / totalDuration))
|
||||
}
|
||||
|
||||
private var voiceTimeText: String {
|
||||
if isCurrent, playback.currentTime > 0 {
|
||||
return "\(MediaTimeFormatter.string(displayedTime)) / \(MediaTimeFormatter.string(totalDuration))"
|
||||
}
|
||||
return MediaTimeFormatter.string(totalDuration)
|
||||
}
|
||||
|
||||
private var audioTimeText: String {
|
||||
"\(MediaTimeFormatter.string(displayedTime)) / \(MediaTimeFormatter.string(totalDuration))"
|
||||
}
|
||||
|
||||
private var rateText: String {
|
||||
if playback.voiceRate < 1.25 { return "1×" }
|
||||
if playback.voiceRate < 1.75 { return "1.5×" }
|
||||
return "2×"
|
||||
}
|
||||
|
||||
private var accent: Color {
|
||||
message.direction == .outgoing ? .white : .accentColor
|
||||
}
|
||||
|
||||
private var secondaryAccent: Color {
|
||||
message.direction == .outgoing ? .white.opacity(0.30) : Color.secondary.opacity(0.24)
|
||||
}
|
||||
|
||||
private var primaryText: Color {
|
||||
message.direction == .outgoing ? .white : .primary
|
||||
}
|
||||
|
||||
private var secondaryText: Color {
|
||||
message.direction == .outgoing ? .white.opacity(0.78) : .secondary
|
||||
}
|
||||
|
||||
private func togglePlayback() {
|
||||
guard let localURL else {
|
||||
Task {
|
||||
await model.prepareMediaPreview(message)
|
||||
guard let downloadedURL = model.mediaPreviewURL(for: message) else {
|
||||
onFallbackTap()
|
||||
return
|
||||
}
|
||||
playback.toggle(
|
||||
messageID: message.id,
|
||||
url: downloadedURL,
|
||||
durationHint: message.duration,
|
||||
isVoice: message.kind == .voice
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
playback.toggle(
|
||||
messageID: message.id,
|
||||
url: localURL,
|
||||
durationHint: message.duration,
|
||||
isVoice: message.kind == .voice
|
||||
)
|
||||
}
|
||||
|
||||
private func seek(_ fraction: Double) {
|
||||
guard let localURL else { return }
|
||||
playback.seek(
|
||||
messageID: message.id,
|
||||
url: localURL,
|
||||
durationHint: message.duration,
|
||||
isVoice: message.kind == .voice,
|
||||
fraction: fraction
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WaveformScrubber: View {
|
||||
let samples: [Float]
|
||||
let progress: Double
|
||||
let activeColor: Color
|
||||
let inactiveColor: Color
|
||||
let onSeek: (Double) -> Void
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
HStack(alignment: .center, spacing: 2) {
|
||||
ForEach(Array(samples.enumerated()), id: \.offset) { index, sample in
|
||||
Capsule()
|
||||
.fill(sampleProgress(index) <= progress ? activeColor : inactiveColor)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: max(3, geometry.size.height * CGFloat(sample)))
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
onSeek(fraction(at: value.location.x, width: geometry.size.width))
|
||||
}
|
||||
)
|
||||
}
|
||||
.accessibilityLabel("Позиция голосового сообщения")
|
||||
.accessibilityValue("\(Int(progress * 100)) процентов")
|
||||
}
|
||||
|
||||
private func sampleProgress(_ index: Int) -> Double {
|
||||
guard samples.count > 1 else { return 0 }
|
||||
return Double(index) / Double(samples.count - 1)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MediaProgressScrubber: View {
|
||||
let progress: Double
|
||||
let activeColor: Color
|
||||
let inactiveColor: Color
|
||||
let onSeek: (Double) -> Void
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(inactiveColor).frame(height: 3)
|
||||
Capsule()
|
||||
.fill(activeColor)
|
||||
.frame(width: geometry.size.width * CGFloat(progress), height: 3)
|
||||
Circle()
|
||||
.fill(activeColor)
|
||||
.frame(width: 9, height: 9)
|
||||
.offset(x: max(0, geometry.size.width * CGFloat(progress) - 4.5))
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
onSeek(fraction(at: value.location.x, width: geometry.size.width))
|
||||
}
|
||||
)
|
||||
}
|
||||
.accessibilityLabel("Позиция аудиофайла")
|
||||
.accessibilityValue("\(Int(progress * 100)) процентов")
|
||||
}
|
||||
}
|
||||
|
||||
private func fraction(at x: CGFloat, width: CGFloat) -> Double {
|
||||
guard width > 0 else { return 0 }
|
||||
return max(0, min(1, Double(x / width)))
|
||||
}
|
||||
|
||||
enum MediaTimeFormatter {
|
||||
static func string(_ duration: TimeInterval) -> String {
|
||||
guard duration.isFinite else { return "0:00" }
|
||||
let seconds = max(0, Int(duration.rounded(.down)))
|
||||
if seconds >= 3_600 {
|
||||
return String(format: "%d:%02d:%02d", seconds / 3_600, (seconds / 60) % 60, seconds % 60)
|
||||
}
|
||||
return String(format: "%d:%02d", seconds / 60, seconds % 60)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
struct AvatarView: View {
|
||||
let conversation: Conversation
|
||||
var imageData: Data?
|
||||
var size: CGFloat = 50
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
avatarContent
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(Circle())
|
||||
|
||||
if isActive {
|
||||
Circle()
|
||||
.fill(.green)
|
||||
.frame(width: size * 0.25, height: size * 0.25)
|
||||
.overlay(Circle().stroke(.background, lineWidth: 2.5))
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(accessibilityText)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var avatarContent: some View {
|
||||
#if os(iOS)
|
||||
if let imageData, let image = UIImage(data: imageData) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if let imageData, let image = NSImage(data: imageData) {
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var fallback: some View {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [baseColor, baseColor.opacity(0.68)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.overlay {
|
||||
if conversation.isGroup {
|
||||
Image(systemName: "person.3.fill")
|
||||
.font(.system(size: size * 0.31, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
} else {
|
||||
Text(conversation.initials)
|
||||
.font(.system(size: size * 0.35, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isActive: Bool {
|
||||
conversation.isGroup ? conversation.isGroupJoined : conversation.isOnline
|
||||
}
|
||||
|
||||
private var accessibilityText: String {
|
||||
if conversation.isGroup {
|
||||
return "\(conversation.displayName), \(conversation.isGroupJoined ? "подключено" : "не подключено")"
|
||||
}
|
||||
return "\(conversation.displayName), \(conversation.isOnline ? "в сети" : "не в сети")"
|
||||
}
|
||||
|
||||
private var baseColor: Color {
|
||||
Color(
|
||||
hue: Double(conversation.colorSeed % 360) / 360,
|
||||
saturation: 0.68,
|
||||
brightness: 0.88
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Short faded curve under the compact source bubble in a collapsed reply.
|
||||
struct ReplyThreadCurve: Shape {
|
||||
let sourceDirection: ChatMessage.Direction
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
if sourceDirection == .incoming {
|
||||
path.move(to: CGPoint(x: rect.minX, y: rect.minY))
|
||||
path.addCurve(
|
||||
to: CGPoint(x: rect.maxX, y: rect.maxY),
|
||||
control1: CGPoint(x: rect.minX + 5, y: rect.maxY * 0.78),
|
||||
control2: CGPoint(x: rect.midX, y: rect.maxY)
|
||||
)
|
||||
} else {
|
||||
path.move(to: CGPoint(x: rect.maxX, y: rect.minY))
|
||||
path.addCurve(
|
||||
to: CGPoint(x: rect.minX, y: rect.maxY),
|
||||
control1: CGPoint(x: rect.maxX - 5, y: rect.maxY * 0.78),
|
||||
control2: CGPoint(x: rect.midX, y: rect.maxY)
|
||||
)
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
/// Bracket displayed beside a source message and several replies in focus mode.
|
||||
struct ReplyThreadBracket: Shape {
|
||||
let opensToTrailing: Bool
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let edgeX = opensToTrailing ? rect.maxX : rect.minX
|
||||
let spineX = opensToTrailing ? rect.minX : rect.maxX
|
||||
let radius = min(26, rect.width)
|
||||
var path = Path()
|
||||
|
||||
path.move(to: CGPoint(x: edgeX, y: rect.minY))
|
||||
path.addCurve(
|
||||
to: CGPoint(x: spineX, y: rect.minY + radius),
|
||||
control1: CGPoint(x: spineX, y: rect.minY),
|
||||
control2: CGPoint(x: spineX, y: rect.minY + radius * 0.45)
|
||||
)
|
||||
path.addLine(to: CGPoint(x: spineX, y: rect.maxY - radius))
|
||||
path.addCurve(
|
||||
to: CGPoint(x: edgeX, y: rect.maxY),
|
||||
control1: CGPoint(x: spineX, y: rect.maxY - radius * 0.45),
|
||||
control2: CGPoint(x: spineX, y: rect.maxY)
|
||||
)
|
||||
return path
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ConnectionBanner: View {
|
||||
@ObservedObject var model: AppModel
|
||||
|
||||
var body: some View {
|
||||
switch model.connectionStatus {
|
||||
case .connected:
|
||||
if model.isArchiveSyncing || !model.isOMEMOReady {
|
||||
banner(
|
||||
icon: model.isArchiveSyncing ? "arrow.triangle.2.circlepath" : "lock.rotation",
|
||||
text: model.isArchiveSyncing ? "Синхронизация истории…" : "Публикация ключей OMEMO…",
|
||||
color: .blue,
|
||||
progress: true
|
||||
)
|
||||
}
|
||||
case .connecting:
|
||||
banner(icon: "antenna.radiowaves.left.and.right", text: "Подключение…", color: .orange, progress: true)
|
||||
case .disconnected(let reason):
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "wifi.exclamationmark")
|
||||
Text(reason ?? "Нет соединения")
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Button("Повторить") { Task { await model.reconnect() } }
|
||||
.buttonStyle(.borderless)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.red.opacity(0.88))
|
||||
}
|
||||
}
|
||||
|
||||
private func banner(icon: String, text: String, color: Color, progress: Bool) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
if progress {
|
||||
ProgressView().controlSize(.mini).tint(.white)
|
||||
} else {
|
||||
Image(systemName: icon)
|
||||
}
|
||||
Text(text)
|
||||
Spacer()
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(color.opacity(0.88))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct ConversationRow: View {
|
||||
let conversation: Conversation
|
||||
let imageData: Data?
|
||||
let isEncrypted: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
AvatarView(conversation: conversation, imageData: imageData)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack {
|
||||
Text(conversation.displayName)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 8)
|
||||
if conversation.lastActivity != .distantPast {
|
||||
Text(conversation.lastActivity, format: .dateTime.hour().minute())
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 7) {
|
||||
if conversation.isGroup {
|
||||
Image(systemName: "person.3.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if !conversation.lastMessage.isEmpty {
|
||||
Image(systemName: isEncrypted ? "lock.fill" : "lock.open")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(isEncrypted ? Color.secondary : Color.orange)
|
||||
}
|
||||
Text(conversation.lastMessage.isEmpty ? conversation.jid : conversation.lastMessage)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 8)
|
||||
if conversation.isGroup, conversation.occupantCount > 0 {
|
||||
Text("\(conversation.occupantCount)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if conversation.unreadCount > 0 {
|
||||
Text("\(min(conversation.unreadCount, 99))")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(.tint, in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
|
||||
struct InlineVideoPlayer: UIViewRepresentable {
|
||||
let player: AVPlayer
|
||||
|
||||
func makeUIView(context: Context) -> PlayerView {
|
||||
let view = PlayerView()
|
||||
view.playerLayer.player = player
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: PlayerView, context: Context) {
|
||||
uiView.playerLayer.player = player
|
||||
}
|
||||
|
||||
final class PlayerView: UIView {
|
||||
override class var layerClass: AnyClass { AVPlayerLayer.self }
|
||||
|
||||
var playerLayer: AVPlayerLayer {
|
||||
layer as! AVPlayerLayer
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
playerLayer.videoGravity = .resizeAspectFill
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
nil
|
||||
}
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
|
||||
struct InlineVideoPlayer: NSViewRepresentable {
|
||||
let player: AVPlayer
|
||||
|
||||
func makeNSView(context: Context) -> PlayerView {
|
||||
let view = PlayerView()
|
||||
view.playerLayer.player = player
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: PlayerView, context: Context) {
|
||||
nsView.playerLayer.player = player
|
||||
}
|
||||
|
||||
final class PlayerView: NSView {
|
||||
let playerLayer = AVPlayerLayer()
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
wantsLayer = true
|
||||
layer = playerLayer
|
||||
playerLayer.videoGravity = .resizeAspectFill
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
nil
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
playerLayer.frame = bounds
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,63 @@
|
||||
import MapKit
|
||||
import SwiftUI
|
||||
|
||||
struct LocationMessagePreview: View {
|
||||
let location: GeoLocation
|
||||
|
||||
var body: some View {
|
||||
Button(action: openInMaps) {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
Map(initialPosition: cameraPosition, interactionModes: []) {
|
||||
Marker("Геопозиция", coordinate: coordinate)
|
||||
.tint(.red)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: "location.fill")
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Геопозиция")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Открыть в Картах")
|
||||
.font(.caption)
|
||||
.opacity(0.82)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 11)
|
||||
.padding(.vertical, 8)
|
||||
.background(.black.opacity(0.58), in: RoundedRectangle(cornerRadius: 11))
|
||||
.padding(9)
|
||||
}
|
||||
.frame(width: 252, height: 170)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.stroke(.primary.opacity(0.1), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Геопозиция")
|
||||
.accessibilityHint("Открывает точку в Картах")
|
||||
}
|
||||
|
||||
private var coordinate: CLLocationCoordinate2D {
|
||||
CLLocationCoordinate2D(latitude: location.latitude, longitude: location.longitude)
|
||||
}
|
||||
|
||||
private var cameraPosition: MapCameraPosition {
|
||||
.region(
|
||||
MKCoordinateRegion(
|
||||
center: coordinate,
|
||||
latitudinalMeters: 1_500,
|
||||
longitudinalMeters: 1_500
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func openInMaps() {
|
||||
let item = MKMapItem(placemark: MKPlacemark(coordinate: coordinate))
|
||||
item.name = "Геопозиция из Luma"
|
||||
item.openInMaps()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import AVFoundation
|
||||
import AVKit
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
private typealias MediaViewerPlatformImage = UIImage
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
private typealias MediaViewerPlatformImage = NSImage
|
||||
#endif
|
||||
|
||||
enum MediaViewerDismissGesturePolicy {
|
||||
private static let verticalDominance: CGFloat = 1.15
|
||||
|
||||
static func isVertical(_ translation: CGSize) -> Bool {
|
||||
let verticalDistance = abs(translation.height)
|
||||
let horizontalDistance = abs(translation.width)
|
||||
return verticalDistance >= 8 && verticalDistance > horizontalDistance * verticalDominance
|
||||
}
|
||||
|
||||
static func shouldDismiss(
|
||||
translation: CGSize,
|
||||
predictedEndTranslation: CGSize,
|
||||
containerHeight: CGFloat
|
||||
) -> Bool {
|
||||
guard isVertical(translation) else { return false }
|
||||
|
||||
let distanceThreshold = min(140, max(88, containerHeight * 0.14))
|
||||
if abs(translation.height) >= distanceThreshold {
|
||||
return true
|
||||
}
|
||||
|
||||
return isVertical(predictedEndTranslation)
|
||||
&& abs(predictedEndTranslation.height) >= distanceThreshold * 1.35
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct MediaViewer: View {
|
||||
let item: MediaViewerItem
|
||||
let onClose: () -> Void
|
||||
|
||||
@State private var dragOffset: CGFloat = 0
|
||||
@State private var isTrackingDismissGesture = false
|
||||
@State private var isCompletingDismissal = false
|
||||
@State private var isPhotoZoomed = false
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
Color.black
|
||||
.opacity(backgroundOpacity(in: geometry.size.height))
|
||||
.ignoresSafeArea()
|
||||
|
||||
ZStack {
|
||||
viewerContent
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(item.title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 8)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [.black.opacity(0.72), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.offset(y: dragOffset)
|
||||
.scaleEffect(contentScale(in: geometry.size.height))
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.simultaneousGesture(
|
||||
verticalDismissGesture(in: geometry.size),
|
||||
including: .all
|
||||
)
|
||||
.onChange(of: isPhotoZoomed) { _, isZoomed in
|
||||
guard isZoomed else { return }
|
||||
resetDismissGesture(animated: true)
|
||||
}
|
||||
.accessibilityAction(.escape) {
|
||||
closeImmediately()
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.onExitCommand {
|
||||
closeImmediately()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var canTrackDismissGesture: Bool {
|
||||
!isPhotoZoomed && !isCompletingDismissal
|
||||
}
|
||||
|
||||
private func verticalDismissGesture(in size: CGSize) -> some Gesture {
|
||||
DragGesture(minimumDistance: 8, coordinateSpace: .global)
|
||||
.onChanged { value in
|
||||
guard canTrackDismissGesture else { return }
|
||||
|
||||
if !isTrackingDismissGesture {
|
||||
guard MediaViewerDismissGesturePolicy.isVertical(value.translation) else { return }
|
||||
isTrackingDismissGesture = true
|
||||
}
|
||||
|
||||
dragOffset = value.translation.height
|
||||
}
|
||||
.onEnded { value in
|
||||
guard isTrackingDismissGesture else { return }
|
||||
isTrackingDismissGesture = false
|
||||
|
||||
guard canTrackDismissGesture else {
|
||||
resetDismissGesture(animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
if MediaViewerDismissGesturePolicy.shouldDismiss(
|
||||
translation: value.translation,
|
||||
predictedEndTranslation: value.predictedEndTranslation,
|
||||
containerHeight: size.height
|
||||
) {
|
||||
completeDismissal(
|
||||
direction: dismissalDirection(for: value),
|
||||
containerHeight: size.height
|
||||
)
|
||||
} else {
|
||||
resetDismissGesture(animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dismissalDirection(for value: DragGesture.Value) -> CGFloat {
|
||||
let projected = value.predictedEndTranslation.height
|
||||
let distance = abs(projected) > abs(value.translation.height)
|
||||
? projected
|
||||
: value.translation.height
|
||||
return distance < 0 ? -1 : 1
|
||||
}
|
||||
|
||||
private func completeDismissal(direction: CGFloat, containerHeight: CGFloat) {
|
||||
guard !isCompletingDismissal else { return }
|
||||
isCompletingDismissal = true
|
||||
|
||||
withAnimation(.easeOut(duration: 0.18)) {
|
||||
dragOffset = direction * max(700, containerHeight * 1.15)
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 150_000_000)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
private func resetDismissGesture(animated: Bool) {
|
||||
isTrackingDismissGesture = false
|
||||
if animated {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.84)) {
|
||||
dragOffset = 0
|
||||
}
|
||||
} else {
|
||||
dragOffset = 0
|
||||
}
|
||||
}
|
||||
|
||||
private func closeImmediately() {
|
||||
guard !isCompletingDismissal else { return }
|
||||
isCompletingDismissal = true
|
||||
onClose()
|
||||
}
|
||||
|
||||
private func dismissalProgress(in containerHeight: CGFloat) -> CGFloat {
|
||||
min(1, abs(dragOffset) / max(1, containerHeight * 0.62))
|
||||
}
|
||||
|
||||
private func backgroundOpacity(in containerHeight: CGFloat) -> Double {
|
||||
Double(1 - dismissalProgress(in: containerHeight) * 0.72)
|
||||
}
|
||||
|
||||
private func contentScale(in containerHeight: CGFloat) -> CGFloat {
|
||||
1 - dismissalProgress(in: containerHeight) * 0.045
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var viewerContent: some View {
|
||||
switch item.kind {
|
||||
case .photo:
|
||||
ZoomablePhotoView(url: item.url, isZoomed: $isPhotoZoomed)
|
||||
case .video:
|
||||
FullWindowVideoPlayer(url: item.url)
|
||||
default:
|
||||
ContentUnavailableView(
|
||||
"Не удалось открыть медиа",
|
||||
systemImage: "exclamationmark.triangle"
|
||||
)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct FullWindowVideoPlayer: View {
|
||||
let url: URL
|
||||
@State private var player: AVPlayer?
|
||||
|
||||
init(url: URL) {
|
||||
self.url = url
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VideoPlayer(player: player)
|
||||
.task(id: url) {
|
||||
player?.pause()
|
||||
let nextPlayer = AVPlayer(url: url)
|
||||
player = nextPlayer
|
||||
nextPlayer.play()
|
||||
}
|
||||
.onDisappear {
|
||||
player?.pause()
|
||||
player = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct ZoomablePhotoView: View {
|
||||
let url: URL
|
||||
@Binding var isZoomed: Bool
|
||||
|
||||
@State private var imageValue: MediaViewerPlatformImage?
|
||||
@State private var hasFinishedLoading = false
|
||||
@State private var scale: CGFloat = 1
|
||||
@State private var offset: CGSize = .zero
|
||||
@GestureState private var gestureScale: CGFloat = 1
|
||||
@GestureState private var gestureOffset: CGSize = .zero
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
Color.black
|
||||
|
||||
if let imageValue {
|
||||
image(imageValue)
|
||||
.scaledToFit()
|
||||
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||
.scaleEffect(effectiveScale)
|
||||
.offset(effectiveOffset)
|
||||
.gesture(dragGesture)
|
||||
.simultaneousGesture(magnifyGesture)
|
||||
.onTapGesture(count: 2, perform: toggleZoom)
|
||||
} else if hasFinishedLoading {
|
||||
invalidImage
|
||||
} else {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
.clipped()
|
||||
}
|
||||
.onChange(of: effectiveScale) { _, value in
|
||||
isZoomed = value > 1.01
|
||||
}
|
||||
.onDisappear {
|
||||
isZoomed = false
|
||||
}
|
||||
.task(id: url) {
|
||||
imageValue = nil
|
||||
hasFinishedLoading = false
|
||||
let data = await Task.detached(priority: .userInitiated) {
|
||||
try? Data(contentsOf: url, options: [.mappedIfSafe])
|
||||
}.value
|
||||
guard !Task.isCancelled else { return }
|
||||
imageValue = data.flatMap { MediaViewerPlatformImage(data: $0) }
|
||||
hasFinishedLoading = true
|
||||
}
|
||||
}
|
||||
|
||||
private var effectiveScale: CGFloat {
|
||||
min(5, max(1, scale * gestureScale))
|
||||
}
|
||||
|
||||
private var effectiveOffset: CGSize {
|
||||
guard effectiveScale > 1 else { return .zero }
|
||||
return CGSize(
|
||||
width: offset.width + gestureOffset.width,
|
||||
height: offset.height + gestureOffset.height
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func image(_ value: MediaViewerPlatformImage) -> some View {
|
||||
#if os(iOS)
|
||||
Image(uiImage: value)
|
||||
.resizable()
|
||||
#elseif os(macOS)
|
||||
Image(nsImage: value)
|
||||
.resizable()
|
||||
#endif
|
||||
}
|
||||
|
||||
private var invalidImage: some View {
|
||||
ContentUnavailableView(
|
||||
"Фото повреждено",
|
||||
systemImage: "photo.badge.exclamationmark"
|
||||
)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
private var dragGesture: some Gesture {
|
||||
DragGesture()
|
||||
.updating($gestureOffset) { value, state, _ in
|
||||
guard effectiveScale > 1 else { return }
|
||||
state = value.translation
|
||||
}
|
||||
.onEnded { value in
|
||||
guard effectiveScale > 1 else {
|
||||
offset = .zero
|
||||
return
|
||||
}
|
||||
offset.width += value.translation.width
|
||||
offset.height += value.translation.height
|
||||
}
|
||||
}
|
||||
|
||||
private var magnifyGesture: some Gesture {
|
||||
MagnifyGesture()
|
||||
.updating($gestureScale) { value, state, _ in
|
||||
state = value.magnification
|
||||
}
|
||||
.onEnded { value in
|
||||
scale = min(5, max(1, scale * value.magnification))
|
||||
if scale == 1 {
|
||||
offset = .zero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleZoom() {
|
||||
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) {
|
||||
if scale > 1 {
|
||||
scale = 1
|
||||
offset = .zero
|
||||
} else {
|
||||
scale = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,737 @@
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
struct MessageBubble: View {
|
||||
let model: AppModel
|
||||
let message: ChatMessage
|
||||
let isSelectionMode: Bool
|
||||
let isSelected: Bool
|
||||
let onAttachmentTap: () -> Void
|
||||
let onEdit: () -> Void
|
||||
let onReply: () -> Void
|
||||
let onForward: () -> Void
|
||||
let onRetry: () -> Void
|
||||
let onToggleSelection: () -> Void
|
||||
let onBeginSelection: () -> Void
|
||||
let onRetract: () -> Void
|
||||
let onDelete: () -> Void
|
||||
let onReplyTap: (String) -> Void
|
||||
let onReact: (String) -> Void
|
||||
|
||||
@State private var replySwipeOffset: CGFloat = 0
|
||||
@State private var replySwipeArmed = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
replySwipeIndicator
|
||||
messageContainer
|
||||
.offset(x: replySwipeOffset)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
guard isSelectionMode else { return }
|
||||
onToggleSelection()
|
||||
}
|
||||
.padding(
|
||||
.vertical,
|
||||
message.kind == .photo || message.kind == .video ? 7 : 2
|
||||
)
|
||||
.simultaneousGesture(
|
||||
replySwipeGesture,
|
||||
including: !isSelectionMode && message.canBeRepliedTo ? .gesture : .none
|
||||
)
|
||||
.onChange(of: isSelectionMode) { _, selecting in
|
||||
guard selecting else { return }
|
||||
replySwipeOffset = 0
|
||||
replySwipeArmed = false
|
||||
}
|
||||
.contextMenu {
|
||||
if !isSelectionMode {
|
||||
if message.canBeReactedTo {
|
||||
Menu {
|
||||
ForEach(MessageReactionPolicy.quickChoices, id: \.self) { emoji in
|
||||
Button(emoji) { onReact(emoji) }
|
||||
}
|
||||
} label: {
|
||||
Label("Реакция", systemImage: "face.smiling")
|
||||
}
|
||||
}
|
||||
if message.canBeRepliedTo {
|
||||
Button(action: onReply) {
|
||||
Label("Ответить", systemImage: "arrowshape.turn.up.left")
|
||||
}
|
||||
}
|
||||
if message.canBeForwarded {
|
||||
Button(action: onForward) {
|
||||
Label("Переслать", systemImage: "arrowshape.turn.up.right")
|
||||
}
|
||||
}
|
||||
if model.canRetryMediaMessage(message) {
|
||||
Button(action: onRetry) {
|
||||
Label("Повторить отправку", systemImage: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
if message.canBeEdited {
|
||||
Button(action: onEdit) {
|
||||
Label("Редактировать", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
Button(action: onBeginSelection) {
|
||||
Label("Выбрать", systemImage: "checkmark.circle")
|
||||
}
|
||||
Divider()
|
||||
Button(role: .destructive, action: onDelete) {
|
||||
Label("Удалить у меня", systemImage: "trash")
|
||||
}
|
||||
if message.canBeRetracted {
|
||||
Button(role: .destructive, action: onRetract) {
|
||||
Label("Удалить у всех", systemImage: "trash.slash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(Text(selectionAccessibilityLabel))
|
||||
.accessibilityAction(named: Text(isSelected ? "Убрать из выбранных" : "Выбрать")) {
|
||||
if isSelectionMode {
|
||||
onToggleSelection()
|
||||
} else {
|
||||
onBeginSelection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var messageContainer: some View {
|
||||
HStack(spacing: 2) {
|
||||
if isSelectionMode {
|
||||
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: 23, weight: .semibold))
|
||||
.foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
|
||||
.frame(width: 34)
|
||||
.padding(.leading, 7)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
VStack(spacing: 0) {
|
||||
if hasReplyReference {
|
||||
compactReplyReference
|
||||
}
|
||||
messageRow
|
||||
}
|
||||
.allowsHitTesting(!isSelectionMode)
|
||||
}
|
||||
}
|
||||
|
||||
private var replySwipeIndicator: some View {
|
||||
HStack {
|
||||
if replySwipeOffset >= 0 {
|
||||
replySwipeIcon
|
||||
Spacer()
|
||||
} else {
|
||||
Spacer()
|
||||
replySwipeIcon
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.allowsHitTesting(false)
|
||||
.opacity(replySwipeOffset == 0 ? 0 : 1)
|
||||
}
|
||||
|
||||
private var replySwipeIcon: some View {
|
||||
Image(systemName: replySwipeArmed
|
||||
? "arrowshape.turn.up.left.fill"
|
||||
: "arrowshape.turn.up.left")
|
||||
.font(.system(size: 19, weight: .semibold))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.scaleEffect(
|
||||
CGFloat(0.8) + CGFloat(0.2) * MessageReplySwipePolicy.progress(for: replySwipeOffset)
|
||||
)
|
||||
.opacity(Double(
|
||||
CGFloat(0.35) + CGFloat(0.65) * MessageReplySwipePolicy.progress(for: replySwipeOffset)
|
||||
))
|
||||
}
|
||||
|
||||
private var replySwipeGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 20)
|
||||
.onChanged { value in
|
||||
let offset = MessageReplySwipePolicy.offset(for: value.translation)
|
||||
guard offset != 0 || replySwipeOffset != 0 else {
|
||||
// A vertical timeline pan must not publish per-cell state
|
||||
// changes or compete with UIScrollView's own recognizer.
|
||||
return
|
||||
}
|
||||
replySwipeOffset = offset
|
||||
let armed = MessageReplySwipePolicy.shouldReply(
|
||||
translation: value.translation,
|
||||
predictedEndTranslation: value.translation
|
||||
)
|
||||
if armed, !replySwipeArmed {
|
||||
replySwipeFeedback()
|
||||
}
|
||||
replySwipeArmed = armed
|
||||
}
|
||||
.onEnded { value in
|
||||
let shouldReply = MessageReplySwipePolicy.shouldReply(
|
||||
translation: value.translation,
|
||||
predictedEndTranslation: value.predictedEndTranslation
|
||||
)
|
||||
if replySwipeOffset != 0 || replySwipeArmed {
|
||||
withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) {
|
||||
replySwipeOffset = 0
|
||||
replySwipeArmed = false
|
||||
}
|
||||
}
|
||||
if shouldReply {
|
||||
onReply()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func replySwipeFeedback() {
|
||||
#if os(iOS)
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
#endif
|
||||
}
|
||||
|
||||
private var selectionAccessibilityLabel: String {
|
||||
guard isSelectionMode else { return message.previewText }
|
||||
return "\(message.previewText), \(isSelected ? "выбрано" : "не выбрано")"
|
||||
}
|
||||
|
||||
private var messageRow: some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
if message.direction == .outgoing { Spacer(minLength: sideSpacerMinimum) }
|
||||
|
||||
VStack(alignment: message.direction == .outgoing ? .trailing : .leading, spacing: 4) {
|
||||
if message.isGroupMessage, message.direction == .incoming {
|
||||
Text(message.senderDisplayName ?? model.displayName(for: message.senderJID))
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(senderColor)
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
messageContent
|
||||
|
||||
if !reactionSummaries.isEmpty {
|
||||
reactionStrip
|
||||
}
|
||||
|
||||
if hasReplyReference {
|
||||
replyWarningIndicators
|
||||
} else {
|
||||
standardMetadata
|
||||
}
|
||||
}
|
||||
|
||||
if message.direction == .incoming { Spacer(minLength: sideSpacerMinimum) }
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
guard let targetID = message.replyToID,
|
||||
message.kind == .text || message.kind == .system else { return }
|
||||
onReplyTap(targetID)
|
||||
}
|
||||
}
|
||||
|
||||
private var sideSpacerMinimum: CGFloat {
|
||||
message.kind.isMedia ? 16 : 54
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var replyWarningIndicators: some View {
|
||||
if message.security != .omemo || message.delivery == .failed {
|
||||
HStack(spacing: 4) {
|
||||
if message.security != .omemo {
|
||||
Image(systemName: message.security == .plaintext ? "lock.open" : "exclamationmark.shield")
|
||||
.foregroundStyle(message.security == .plaintext ? .orange : .red)
|
||||
}
|
||||
if message.delivery == .failed {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private var standardMetadata: some View {
|
||||
HStack(spacing: 4) {
|
||||
if message.callHistory == nil, message.security != .omemo {
|
||||
Image(systemName: message.security == .plaintext ? "lock.open" : "exclamationmark.shield")
|
||||
.foregroundStyle(message.security == .plaintext ? .orange : .red)
|
||||
}
|
||||
if message.editedAt != nil {
|
||||
Text("изм.")
|
||||
}
|
||||
Text(message.timestamp, format: .dateTime.hour().minute())
|
||||
if message.direction == .outgoing, message.callHistory == nil {
|
||||
Image(systemName: deliveryIcon)
|
||||
.foregroundStyle(message.delivery == .failed ? .red : .secondary)
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var messageContent: some View {
|
||||
if message.isRetracted {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: "trash.slash")
|
||||
Text("Сообщение удалено")
|
||||
.italic()
|
||||
}
|
||||
.font(.subheadline)
|
||||
.padding(.horizontal, 13)
|
||||
.padding(.vertical, 9)
|
||||
.foregroundStyle(message.direction == .outgoing ? Color.white.opacity(0.86) : Color.secondary)
|
||||
.background(bubbleBackground)
|
||||
.clipShape(messageBubbleShape)
|
||||
} else {
|
||||
regularMessageContent
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var regularMessageContent: some View {
|
||||
switch message.kind {
|
||||
case .videoNote:
|
||||
VideoNotePreview(
|
||||
model: model,
|
||||
message: message,
|
||||
onFallbackTap: onAttachmentTap
|
||||
)
|
||||
case .video:
|
||||
VideoAttachmentPreview(
|
||||
model: model,
|
||||
message: message,
|
||||
onFallbackTap: onAttachmentTap
|
||||
)
|
||||
case .audio, .voice:
|
||||
AudioMessagePlayer(
|
||||
model: model,
|
||||
message: message,
|
||||
onFallbackTap: onAttachmentTap
|
||||
)
|
||||
.padding(.horizontal, 13)
|
||||
.padding(.vertical, 10)
|
||||
.background(bubbleBackground)
|
||||
.clipShape(messageBubbleShape)
|
||||
case .photo:
|
||||
PhotoAttachmentPreview(
|
||||
model: model,
|
||||
message: message,
|
||||
onFallbackTap: onAttachmentTap
|
||||
)
|
||||
case .location:
|
||||
if let location = GeoLocation(uri: message.body) {
|
||||
LocationMessagePreview(location: location)
|
||||
} else {
|
||||
Text(message.body)
|
||||
.lumaTextSelection()
|
||||
.padding(.horizontal, 13)
|
||||
.padding(.vertical, 9)
|
||||
.foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary)
|
||||
.background(bubbleBackground)
|
||||
.clipShape(messageBubbleShape)
|
||||
}
|
||||
case .attachment:
|
||||
Button(action: onAttachmentTap) {
|
||||
mediaContent
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 13)
|
||||
.padding(.vertical, 9)
|
||||
.foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary)
|
||||
.background(bubbleBackground)
|
||||
.clipShape(messageBubbleShape)
|
||||
case .text, .system:
|
||||
if let callHistory = message.callHistory {
|
||||
callHistoryContent(callHistory)
|
||||
} else {
|
||||
Text(message.body)
|
||||
.lumaTextSelection()
|
||||
.padding(.horizontal, 13)
|
||||
.padding(.vertical, 9)
|
||||
.foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary)
|
||||
.background(bubbleBackground)
|
||||
.clipShape(messageBubbleShape)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func callHistoryContent(_ history: CallHistoryMetadata) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(callAccentColor(for: history).opacity(
|
||||
message.direction == .outgoing ? 0.28 : 0.14
|
||||
))
|
||||
Image(systemName: history.isVideo ? "video.fill" : "phone.fill")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
Image(systemName: message.direction == .outgoing ? "arrow.up.right" : "arrow.down.left")
|
||||
.font(.system(size: 8, weight: .heavy))
|
||||
.padding(3)
|
||||
.background(Circle().fill(callDirectionBadgeBackground))
|
||||
.offset(x: 14, y: 14)
|
||||
}
|
||||
.frame(width: 42, height: 42)
|
||||
.foregroundStyle(callIconForeground(for: history))
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(message.callTitle)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(2)
|
||||
if let subtitle = message.callSubtitle {
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.opacity(0.78)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.frame(maxWidth: 300, alignment: .leading)
|
||||
.foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary)
|
||||
.background(bubbleBackground)
|
||||
.clipShape(messageBubbleShape)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(Text(message.callTitle))
|
||||
}
|
||||
|
||||
private func callAccentColor(for history: CallHistoryMetadata) -> Color {
|
||||
switch history.outcome {
|
||||
case .completed:
|
||||
return .green
|
||||
case .declined, .missed, .unanswered, .failed:
|
||||
return .red
|
||||
case .cancelled, .answeredElsewhere:
|
||||
return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private func callIconForeground(for history: CallHistoryMetadata) -> Color {
|
||||
message.direction == .outgoing ? .white : callAccentColor(for: history)
|
||||
}
|
||||
|
||||
private var callDirectionBadgeBackground: Color {
|
||||
message.direction == .outgoing
|
||||
? Color(red: 0.13, green: 0.57, blue: 0.94)
|
||||
: Color.secondary.opacity(0.16)
|
||||
}
|
||||
|
||||
private var reactionSummaries: [MessageReactionSummary] {
|
||||
message.reactionSummaries(ownJID: model.account?.normalizedJID)
|
||||
}
|
||||
|
||||
private var reactionStrip: some View {
|
||||
HStack(spacing: 5) {
|
||||
ForEach(reactionSummaries) { summary in
|
||||
Button {
|
||||
onReact(summary.emoji)
|
||||
} label: {
|
||||
HStack(spacing: 3) {
|
||||
Text(summary.emoji)
|
||||
Text(String(summary.count))
|
||||
.font(.caption2.weight(.semibold))
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
summary.includesOwnReaction
|
||||
? Color.accentColor.opacity(0.2)
|
||||
: Color.secondary.opacity(0.12),
|
||||
in: Capsule()
|
||||
)
|
||||
.overlay {
|
||||
Capsule().stroke(
|
||||
summary.includesOwnReaction
|
||||
? Color.accentColor.opacity(0.55)
|
||||
: Color.secondary.opacity(0.16),
|
||||
lineWidth: 0.8
|
||||
)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("\(summary.emoji), реакций: \(summary.count)")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
private var hasReplyReference: Bool {
|
||||
message.replyToID != nil || message.replyPreview != nil
|
||||
}
|
||||
|
||||
private var compactReplyReference: some View {
|
||||
Group {
|
||||
if let targetID = message.replyToID {
|
||||
Button {
|
||||
onReplyTap(targetID)
|
||||
} label: {
|
||||
compactReplyLayout
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Открыть ветку ответа")
|
||||
} else {
|
||||
compactReplyLayout
|
||||
}
|
||||
}
|
||||
.opacity(0.56)
|
||||
.padding(.bottom, -2)
|
||||
.task(id: replyTarget?.remoteAttachmentURL) {
|
||||
if let replyTarget,
|
||||
replyTarget.kind == .photo
|
||||
|| replyTarget.kind == .video
|
||||
|| replyTarget.kind == .videoNote {
|
||||
await model.prepareMediaPreview(replyTarget)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var compactReplyLayout: some View {
|
||||
HStack {
|
||||
if compactTargetDirection == .outgoing {
|
||||
Spacer(minLength: 58)
|
||||
}
|
||||
|
||||
VStack(
|
||||
alignment: compactTargetDirection == .outgoing ? .trailing : .leading,
|
||||
spacing: -1
|
||||
) {
|
||||
compactSourceBubble
|
||||
ReplyThreadCurve(sourceDirection: compactTargetDirection)
|
||||
.stroke(
|
||||
compactReplyColor.opacity(0.72),
|
||||
style: StrokeStyle(lineWidth: 2.4, lineCap: .round)
|
||||
)
|
||||
.frame(width: 58, height: 34)
|
||||
.offset(x: compactTargetDirection == .outgoing ? -18 : 18)
|
||||
}
|
||||
|
||||
if compactTargetDirection == .incoming {
|
||||
Spacer(minLength: 58)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private var compactSourceBubble: some View {
|
||||
HStack(spacing: 7) {
|
||||
compactReplyThumbnail
|
||||
Text(replyText)
|
||||
.font(.callout.weight(.medium))
|
||||
.foregroundStyle(compactReplyColor)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
.padding(.leading, compactTargetDirection == .incoming ? 12 : 10)
|
||||
.padding(.trailing, compactTargetDirection == .outgoing ? 12 : 10)
|
||||
.padding(.vertical, 7)
|
||||
.frame(maxWidth: 230, minHeight: 34, alignment: .leading)
|
||||
.background(Color.primary.opacity(0.018))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 17, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 17, style: .continuous)
|
||||
.stroke(compactReplyColor.opacity(0.75), lineWidth: 1.25)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var compactReplyThumbnail: some View {
|
||||
if let target = replyTarget, target.kind != .text, target.kind != .system {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.fill(Color.primary.opacity(0.08))
|
||||
replyThumbnailImage(for: target)
|
||||
}
|
||||
.frame(width: 27, height: 27)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func replyThumbnailImage(for target: ChatMessage) -> some View {
|
||||
if let data = model.mediaThumbnail(for: target) {
|
||||
#if os(iOS)
|
||||
if let image = ChatMediaImageCache.image(for: target, data: data) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
Image(systemName: replyMediaIcon(for: target.kind))
|
||||
.foregroundStyle(compactReplyColor)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if let image = ChatMediaImageCache.image(for: target, data: data) {
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
Image(systemName: replyMediaIcon(for: target.kind))
|
||||
.foregroundStyle(compactReplyColor)
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
Image(systemName: replyMediaIcon(for: target.kind))
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(compactReplyColor)
|
||||
}
|
||||
}
|
||||
|
||||
private var replyTarget: ChatMessage? {
|
||||
message.replyToID.flatMap { model.message(withID: $0, in: message.conversationID) }
|
||||
}
|
||||
|
||||
private var compactTargetDirection: ChatMessage.Direction {
|
||||
if let direction = replyTarget?.direction { return direction }
|
||||
return message.direction == .outgoing ? .incoming : .outgoing
|
||||
}
|
||||
|
||||
private var compactReplyColor: Color {
|
||||
compactTargetDirection == .outgoing
|
||||
? Color(red: 0.18, green: 0.59, blue: 0.98)
|
||||
: Color.secondary
|
||||
}
|
||||
|
||||
private var replyText: String {
|
||||
replyTarget?.quotePreview ?? message.replyPreview ?? "Исходное сообщение недоступно"
|
||||
}
|
||||
|
||||
private var mediaContent: some View {
|
||||
HStack(spacing: 11) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(.white.opacity(message.direction == .outgoing ? 0.22 : 0.12))
|
||||
.frame(width: message.kind == .videoNote ? 58 : 38, height: message.kind == .videoNote ? 58 : 38)
|
||||
Image(systemName: mediaIcon)
|
||||
.font(.system(size: message.kind == .videoNote ? 24 : 17))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(mediaTitle)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(2)
|
||||
Text(mediaSubtitle)
|
||||
.font(.caption)
|
||||
.opacity(0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var mediaTitle: String {
|
||||
switch message.kind {
|
||||
case .photo: return "Фото"
|
||||
case .video: return "Видео"
|
||||
case .audio: return message.localFilename ?? "Аудио"
|
||||
case .voice: return "Голосовое сообщение"
|
||||
case .videoNote: return "Видеосообщение"
|
||||
case .attachment: return message.localFilename ?? message.body
|
||||
case .location: return "Геопозиция"
|
||||
case .text, .system: return message.body
|
||||
}
|
||||
}
|
||||
|
||||
private var mediaSubtitle: String {
|
||||
if message.remoteAttachmentURL == nil {
|
||||
return "Подготовка…"
|
||||
}
|
||||
var parts: [String] = []
|
||||
if let duration = message.duration {
|
||||
let seconds = max(0, Int(duration.rounded()))
|
||||
parts.append(String(format: "%d:%02d", seconds / 60, seconds % 60))
|
||||
}
|
||||
if let byteCount = message.byteCount {
|
||||
parts.append(ByteCountFormatter.string(fromByteCount: Int64(byteCount), countStyle: .file))
|
||||
}
|
||||
return parts.isEmpty ? "Нажмите для просмотра" : parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private var mediaIcon: String {
|
||||
switch message.kind {
|
||||
case .attachment: return "doc.fill"
|
||||
case .photo: return "photo.fill"
|
||||
case .video: return "play.rectangle.fill"
|
||||
case .audio: return "music.note"
|
||||
case .voice: return "waveform"
|
||||
case .videoNote: return "video.circle.fill"
|
||||
case .location: return "location.fill"
|
||||
case .text, .system: return "doc.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private func replyMediaIcon(for kind: ChatMessage.Kind) -> String {
|
||||
switch kind {
|
||||
case .photo: return "photo.fill"
|
||||
case .video: return "play.fill"
|
||||
case .audio: return "music.note"
|
||||
case .voice: return "waveform"
|
||||
case .videoNote: return "video.circle.fill"
|
||||
case .attachment: return "doc.fill"
|
||||
case .location: return "location.fill"
|
||||
case .text, .system: return "quote.bubble.fill"
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var bubbleBackground: some View {
|
||||
if message.direction == .outgoing {
|
||||
LinearGradient(
|
||||
colors: [Color(red: 0.13, green: 0.51, blue: 0.94), Color(red: 0.12, green: 0.63, blue: 0.94)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
} else {
|
||||
Color.secondary.opacity(0.13)
|
||||
}
|
||||
}
|
||||
|
||||
private var messageBubbleShape: RoundedRectangle {
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
}
|
||||
|
||||
private var deliveryIcon: String {
|
||||
switch message.delivery {
|
||||
case .sending: return "clock"
|
||||
case .sent: return "checkmark"
|
||||
case .delivered: return "checkmark.circle.fill"
|
||||
case .failed: return "exclamationmark.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private var senderColor: Color {
|
||||
color(for: message.senderJID)
|
||||
}
|
||||
|
||||
private func color(for jid: String) -> Color {
|
||||
let seed = jid.utf8.reduce(UInt64(2_166_136_261)) { value, byte in
|
||||
(value ^ UInt64(byte)) &* 16_777_619
|
||||
}
|
||||
return Color(
|
||||
hue: Double(seed % 360) / 360,
|
||||
saturation: 0.72,
|
||||
brightness: 0.78
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
@ViewBuilder
|
||||
func lumaTextSelection() -> some View {
|
||||
#if os(macOS)
|
||||
textSelection(.enabled)
|
||||
#else
|
||||
self
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
struct PhotoAttachmentPreview: View {
|
||||
@ObservedObject var model: AppModel
|
||||
let message: ChatMessage
|
||||
let onFallbackTap: () -> Void
|
||||
|
||||
private let islandWidth: CGFloat = 268
|
||||
|
||||
var body: some View {
|
||||
Button(action: openViewer) {
|
||||
ZStack {
|
||||
Color.secondary.opacity(0.12)
|
||||
thumbnail
|
||||
.padding(6)
|
||||
|
||||
if model.isMediaPreviewLoading(message) {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.controlSize(.large)
|
||||
.padding(15)
|
||||
.background(.black.opacity(0.34), in: Circle())
|
||||
}
|
||||
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(7)
|
||||
.background(.black.opacity(0.42), in: Circle())
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 6) {
|
||||
if let filename = message.localFilename {
|
||||
Text(filename)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
Text(sizeText)
|
||||
.monospacedDigit()
|
||||
}
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 5)
|
||||
.background(.black.opacity(0.48), in: Capsule())
|
||||
}
|
||||
.padding(9)
|
||||
}
|
||||
.frame(width: islandWidth, height: islandHeight)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.stroke(Color.secondary.opacity(0.18), lineWidth: 1)
|
||||
)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 5)
|
||||
.task(id: message.remoteAttachmentURL) {
|
||||
await model.prepareMediaPreview(message)
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("Фото, \(sizeText)")
|
||||
.accessibilityHint("Открывает фото на весь экран")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var thumbnail: some View {
|
||||
if let data = model.mediaThumbnail(for: message) {
|
||||
#if os(iOS)
|
||||
if let image = ChatMediaImageCache.image(for: message, data: data) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if let image = ChatMediaImageCache.image(for: message, data: data) {
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
Image(systemName: "photo.fill")
|
||||
.font(.system(size: 42, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var sizeText: String {
|
||||
guard let byteCount = message.byteCount else { return "Фото" }
|
||||
return ByteCountFormatter.string(fromByteCount: Int64(byteCount), countStyle: .file)
|
||||
}
|
||||
|
||||
private var islandHeight: CGFloat {
|
||||
let contentWidth = islandWidth - 12
|
||||
let naturalHeight = contentWidth / thumbnailAspectRatio + 12
|
||||
return min(max(naturalHeight, 164), 328)
|
||||
}
|
||||
|
||||
private var thumbnailAspectRatio: CGFloat {
|
||||
guard let data = model.mediaThumbnail(for: message) else { return 4 / 3 }
|
||||
#if os(iOS)
|
||||
guard let image = ChatMediaImageCache.image(for: message, data: data),
|
||||
image.size.height > 0 else { return 4 / 3 }
|
||||
return max(0.45, min(2.2, image.size.width / image.size.height))
|
||||
#elseif os(macOS)
|
||||
guard let image = ChatMediaImageCache.image(for: message, data: data),
|
||||
image.size.height > 0 else { return 4 / 3 }
|
||||
return max(0.45, min(2.2, image.size.width / image.size.height))
|
||||
#endif
|
||||
}
|
||||
|
||||
private func openViewer() {
|
||||
Task {
|
||||
await model.presentMediaViewer(message)
|
||||
if model.mediaViewerItem?.id != message.id,
|
||||
message.remoteAttachmentURL == nil {
|
||||
onFallbackTap()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import SwiftUI
|
||||
import WebRTC
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
|
||||
struct RTCVideoRendererView: UIViewRepresentable {
|
||||
let track: RTCVideoTrack?
|
||||
var mirrored = false
|
||||
var contentMode: UIView.ContentMode = .scaleAspectFill
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> RTCMTLVideoView {
|
||||
let view = RTCMTLVideoView(frame: .zero)
|
||||
view.videoContentMode = contentMode
|
||||
view.backgroundColor = .black
|
||||
context.coordinator.update(track: track, renderer: view)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ view: RTCMTLVideoView, context: Context) {
|
||||
view.videoContentMode = contentMode
|
||||
view.transform = mirrored
|
||||
? CGAffineTransform(scaleX: -1, y: 1)
|
||||
: .identity
|
||||
context.coordinator.update(track: track, renderer: view)
|
||||
}
|
||||
|
||||
static func dismantleUIView(_ view: RTCMTLVideoView, coordinator: Coordinator) {
|
||||
coordinator.detach(renderer: view)
|
||||
}
|
||||
|
||||
final class Coordinator {
|
||||
private weak var track: RTCVideoTrack?
|
||||
|
||||
func update(track newTrack: RTCVideoTrack?, renderer: RTCMTLVideoView) {
|
||||
guard track !== newTrack else { return }
|
||||
track?.remove(renderer)
|
||||
track = newTrack
|
||||
newTrack?.add(renderer)
|
||||
}
|
||||
|
||||
func detach(renderer: RTCMTLVideoView) {
|
||||
track?.remove(renderer)
|
||||
track = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
|
||||
struct RTCVideoRendererView: NSViewRepresentable {
|
||||
let track: RTCVideoTrack?
|
||||
var mirrored = false
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> RTCMTLNSVideoView {
|
||||
let view = RTCMTLNSVideoView(frame: .zero)
|
||||
view.wantsLayer = true
|
||||
view.layer?.backgroundColor = NSColor.black.cgColor
|
||||
context.coordinator.update(track: track, renderer: view)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ view: RTCMTLNSVideoView, context: Context) {
|
||||
view.layer?.setAffineTransform(
|
||||
mirrored ? CGAffineTransform(scaleX: -1, y: 1) : .identity
|
||||
)
|
||||
context.coordinator.update(track: track, renderer: view)
|
||||
}
|
||||
|
||||
static func dismantleNSView(_ view: RTCMTLNSVideoView, coordinator: Coordinator) {
|
||||
coordinator.detach(renderer: view)
|
||||
}
|
||||
|
||||
final class Coordinator {
|
||||
private weak var track: RTCVideoTrack?
|
||||
|
||||
func update(track newTrack: RTCVideoTrack?, renderer: RTCMTLNSVideoView) {
|
||||
guard track !== newTrack else { return }
|
||||
track?.remove(renderer)
|
||||
track = newTrack
|
||||
newTrack?.add(renderer)
|
||||
}
|
||||
|
||||
func detach(renderer: RTCMTLNSVideoView) {
|
||||
track?.remove(renderer)
|
||||
track = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,347 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// iMessage-style focus mode for one source message and its reply thread.
|
||||
@MainActor
|
||||
struct ReplyThreadOverlay: View {
|
||||
@ObservedObject var model: AppModel
|
||||
let rootMessage: ChatMessage
|
||||
let replies: [ChatMessage]
|
||||
let selectedReplyID: String
|
||||
let onDismiss: () -> Void
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var isVisible = false
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .topTrailing) {
|
||||
Rectangle()
|
||||
.fill(.ultraThinMaterial)
|
||||
.ignoresSafeArea()
|
||||
|
||||
Color.black
|
||||
.opacity(colorScheme == .dark ? 0.72 : 0.62)
|
||||
.ignoresSafeArea()
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: onDismiss)
|
||||
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(spacing: 14) {
|
||||
Text(
|
||||
rootMessage.timestamp,
|
||||
format: .dateTime
|
||||
.weekday(.abbreviated)
|
||||
.month(.abbreviated)
|
||||
.day()
|
||||
.hour()
|
||||
.minute()
|
||||
)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.white.opacity(0.56))
|
||||
.padding(.bottom, 2)
|
||||
|
||||
threadContent(
|
||||
maximumBubbleWidth: min(520, geometry.size.width * 0.76)
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: min(760, geometry.size.width))
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.top, max(58, geometry.safeAreaInsets.top + 30))
|
||||
.padding(.bottom, max(34, geometry.safeAreaInsets.bottom + 20))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.scrollIndicators(.hidden)
|
||||
.onAppear {
|
||||
isVisible = true
|
||||
DispatchQueue.main.async {
|
||||
withAnimation(.easeOut(duration: 0.28)) {
|
||||
proxy.scrollTo(selectedReplyID, anchor: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: onDismiss) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundStyle(.white.opacity(0.82))
|
||||
.frame(width: 32, height: 32)
|
||||
.background(.white.opacity(0.12), in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.accessibilityLabel("Закрыть ветку ответов")
|
||||
.padding(.top, max(12, geometry.safeAreaInsets.top + 6))
|
||||
.padding(.trailing, 14)
|
||||
}
|
||||
.opacity(isVisible ? 1 : 0)
|
||||
.scaleEffect(isVisible ? 1 : 0.985)
|
||||
.animation(.easeOut(duration: 0.22), value: isVisible)
|
||||
}
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.985)))
|
||||
}
|
||||
|
||||
private func threadContent(maximumBubbleWidth: CGFloat) -> some View {
|
||||
let showsBracket = replies.count > 1
|
||||
let bracketOnLeadingEdge = rootMessage.direction == .outgoing
|
||||
|
||||
return VStack(spacing: 9) {
|
||||
ReplyThreadMessageRow(
|
||||
model: model,
|
||||
message: rootMessage,
|
||||
maximumBubbleWidth: maximumBubbleWidth,
|
||||
isSelected: false,
|
||||
showsDeliveryStatus: false
|
||||
)
|
||||
.id(rootMessage.id)
|
||||
|
||||
if showsBracket {
|
||||
replyCountRow
|
||||
}
|
||||
|
||||
ForEach(replies) { reply in
|
||||
ReplyThreadMessageRow(
|
||||
model: model,
|
||||
message: reply,
|
||||
maximumBubbleWidth: maximumBubbleWidth,
|
||||
isSelected: reply.id == selectedReplyID,
|
||||
showsDeliveryStatus: reply.id == selectedReplyID
|
||||
)
|
||||
.id(reply.id)
|
||||
}
|
||||
}
|
||||
.padding(.leading, showsBracket && bracketOnLeadingEdge ? 43 : 0)
|
||||
.padding(.trailing, showsBracket && !bracketOnLeadingEdge ? 43 : 0)
|
||||
.overlay(
|
||||
alignment: bracketOnLeadingEdge ? .leading : .trailing
|
||||
) {
|
||||
if showsBracket {
|
||||
ReplyThreadBracket(opensToTrailing: bracketOnLeadingEdge)
|
||||
.stroke(
|
||||
Color.white.opacity(0.25),
|
||||
style: StrokeStyle(lineWidth: 3, lineCap: .round)
|
||||
)
|
||||
.frame(width: 38)
|
||||
.padding(.vertical, 28)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var replyCountRow: some View {
|
||||
HStack {
|
||||
if rootMessage.direction == .outgoing { Spacer(minLength: 40) }
|
||||
Text(replyCountText)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(Color(red: 0.16, green: 0.59, blue: 1))
|
||||
.padding(.horizontal, 13)
|
||||
if rootMessage.direction == .incoming { Spacer(minLength: 40) }
|
||||
}
|
||||
}
|
||||
|
||||
private var replyCountText: String {
|
||||
let value = replies.count
|
||||
let mod10 = value % 10
|
||||
let mod100 = value % 100
|
||||
let noun: String
|
||||
if mod10 == 1, mod100 != 11 {
|
||||
noun = "ответ"
|
||||
} else if (2...4).contains(mod10), !(12...14).contains(mod100) {
|
||||
noun = "ответа"
|
||||
} else {
|
||||
noun = "ответов"
|
||||
}
|
||||
return "\(value) \(noun)"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct ReplyThreadMessageRow: View {
|
||||
@ObservedObject var model: AppModel
|
||||
let message: ChatMessage
|
||||
let maximumBubbleWidth: CGFloat
|
||||
let isSelected: Bool
|
||||
let showsDeliveryStatus: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(
|
||||
alignment: message.direction == .outgoing ? .trailing : .leading,
|
||||
spacing: 4
|
||||
) {
|
||||
if message.isGroupMessage, message.direction == .incoming {
|
||||
Text(message.senderDisplayName ?? model.displayName(for: message.senderJID))
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.white.opacity(0.62))
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
|
||||
HStack(alignment: .bottom) {
|
||||
if message.direction == .outgoing { Spacer(minLength: 44) }
|
||||
focusedBubble
|
||||
.frame(maxWidth: maximumBubbleWidth, alignment: message.direction == .outgoing ? .trailing : .leading)
|
||||
.shadow(
|
||||
color: isSelected ? Color.blue.opacity(0.5) : Color.clear,
|
||||
radius: isSelected ? 22 : 0,
|
||||
y: isSelected ? 8 : 0
|
||||
)
|
||||
.scaleEffect(isSelected ? 1.018 : 1)
|
||||
if message.direction == .incoming { Spacer(minLength: 44) }
|
||||
}
|
||||
|
||||
if showsDeliveryStatus, message.direction == .outgoing {
|
||||
Text(deliveryText)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.white.opacity(0.54))
|
||||
.padding(.trailing, 13)
|
||||
}
|
||||
}
|
||||
.animation(.spring(response: 0.34, dampingFraction: 0.78), value: isSelected)
|
||||
.task(id: message.remoteAttachmentURL) {
|
||||
if isVisualMedia {
|
||||
await model.prepareMediaPreview(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var focusedBubble: some View {
|
||||
if isVisualMedia {
|
||||
visualMediaBubble
|
||||
} else {
|
||||
HStack(spacing: 10) {
|
||||
if let icon = nonVisualMediaIcon {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
}
|
||||
Text(displayText)
|
||||
.font(.body)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.leading, message.direction == .incoming ? 17 : 14)
|
||||
.padding(.trailing, message.direction == .outgoing ? 17 : 14)
|
||||
.padding(.vertical, 11)
|
||||
.foregroundStyle(.white)
|
||||
.background(focusedBubbleBackground)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
private var visualMediaBubble: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ZStack {
|
||||
Color.white.opacity(0.08)
|
||||
mediaThumbnail
|
||||
if message.kind != .photo {
|
||||
Image(systemName: "play.fill")
|
||||
.font(.system(size: 24, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 52, height: 52)
|
||||
.background(.black.opacity(0.42), in: Circle())
|
||||
}
|
||||
}
|
||||
.frame(width: min(maximumBubbleWidth, 430), height: 190)
|
||||
.clipped()
|
||||
|
||||
Text(message.previewText)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 15)
|
||||
.padding(.vertical, 11)
|
||||
}
|
||||
.background(focusedBubbleBackground)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var mediaThumbnail: some View {
|
||||
if let data = model.mediaThumbnail(for: message) {
|
||||
#if os(iOS)
|
||||
if let image = ChatMediaImageCache.image(for: message, data: data) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
mediaPlaceholder
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if let image = ChatMediaImageCache.image(for: message, data: data) {
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
mediaPlaceholder
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
mediaPlaceholder
|
||||
}
|
||||
}
|
||||
|
||||
private var mediaPlaceholder: some View {
|
||||
Image(systemName: message.kind == .photo ? "photo.fill" : "video.fill")
|
||||
.font(.system(size: 42, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.62))
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var focusedBubbleBackground: some View {
|
||||
if message.direction == .outgoing {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.12, green: 0.51, blue: 0.96),
|
||||
Color(red: 0.10, green: 0.63, blue: 1)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
} else {
|
||||
LinearGradient(
|
||||
colors: [Color.white.opacity(0.34), Color.white.opacity(0.23)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var isVisualMedia: Bool {
|
||||
!message.isRetracted
|
||||
&& (message.kind == .photo || message.kind == .video || message.kind == .videoNote)
|
||||
}
|
||||
|
||||
private var nonVisualMediaIcon: String? {
|
||||
switch message.kind {
|
||||
case .attachment: return "doc.fill"
|
||||
case .audio: return "music.note"
|
||||
case .voice: return "waveform"
|
||||
case .location: return "location.fill"
|
||||
case .text, .system, .photo, .video, .videoNote: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private var displayText: String {
|
||||
message.isRetracted ? "Сообщение удалено" : message.body
|
||||
}
|
||||
|
||||
private var deliveryText: String {
|
||||
let state: String
|
||||
switch message.delivery {
|
||||
case .sending: state = "Отправка"
|
||||
case .sent: state = "Отправлено"
|
||||
case .delivered: state = "Доставлено"
|
||||
case .failed: state = "Не отправлено"
|
||||
}
|
||||
let value = Calendar.current.isDateInToday(message.timestamp)
|
||||
? message.timestamp.formatted(date: .omitted, time: .shortened)
|
||||
: message.timestamp.formatted(date: .numeric, time: .omitted)
|
||||
return "\(state) \(value)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
struct VideoAttachmentPreview: View {
|
||||
@ObservedObject var model: AppModel
|
||||
let message: ChatMessage
|
||||
let onFallbackTap: () -> Void
|
||||
|
||||
private let islandWidth: CGFloat = 268
|
||||
|
||||
var body: some View {
|
||||
Button(action: openViewer) {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.06, green: 0.17, blue: 0.29),
|
||||
Color(red: 0.09, green: 0.43, blue: 0.64)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
thumbnail
|
||||
.padding(6)
|
||||
|
||||
if model.isMediaPreviewLoading(message) {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.controlSize(.large)
|
||||
} else {
|
||||
Image(systemName: "play.fill")
|
||||
.font(.system(size: 22, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(15)
|
||||
.background(.black.opacity(0.38), in: Circle())
|
||||
}
|
||||
|
||||
VStack {
|
||||
HStack {
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
||||
.font(.caption.weight(.bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(7)
|
||||
.background(.black.opacity(0.42), in: Circle())
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 6) {
|
||||
if let filename = message.localFilename {
|
||||
Text(filename)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer(minLength: 6)
|
||||
Text(metadataText)
|
||||
.monospacedDigit()
|
||||
}
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 5)
|
||||
.background(.black.opacity(0.48), in: Capsule())
|
||||
}
|
||||
.padding(9)
|
||||
}
|
||||
.frame(width: islandWidth, height: islandHeight)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.stroke(Color.secondary.opacity(0.18), lineWidth: 1)
|
||||
)
|
||||
.contentShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 5)
|
||||
.task(id: message.remoteAttachmentURL) {
|
||||
await model.prepareMediaPreview(message)
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("Видео, \(metadataText)")
|
||||
.accessibilityHint("Открывает видео на весь экран")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var thumbnail: some View {
|
||||
if let data = model.mediaThumbnail(for: message) {
|
||||
#if os(iOS)
|
||||
if let image = ChatMediaImageCache.image(for: message, data: data) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if let image = ChatMediaImageCache.image(for: message, data: data) {
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
Image(systemName: "film.fill")
|
||||
.font(.system(size: 38, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.65))
|
||||
}
|
||||
}
|
||||
|
||||
private var metadataText: String {
|
||||
var parts: [String] = []
|
||||
if let duration = message.duration {
|
||||
parts.append(MediaTimeFormatter.string(duration))
|
||||
}
|
||||
if let byteCount = message.byteCount {
|
||||
parts.append(ByteCountFormatter.string(fromByteCount: Int64(byteCount), countStyle: .file))
|
||||
}
|
||||
return parts.isEmpty ? "Видео" : parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private var islandHeight: CGFloat {
|
||||
let contentWidth = islandWidth - 12
|
||||
let naturalHeight = contentWidth / thumbnailAspectRatio + 12
|
||||
return min(max(naturalHeight, 164), 250)
|
||||
}
|
||||
|
||||
private var thumbnailAspectRatio: CGFloat {
|
||||
guard let data = model.mediaThumbnail(for: message) else { return 16 / 9 }
|
||||
#if os(iOS)
|
||||
guard let image = ChatMediaImageCache.image(for: message, data: data),
|
||||
image.size.height > 0 else { return 16 / 9 }
|
||||
return max(0.7, min(2.2, image.size.width / image.size.height))
|
||||
#elseif os(macOS)
|
||||
guard let image = ChatMediaImageCache.image(for: message, data: data),
|
||||
image.size.height > 0 else { return 16 / 9 }
|
||||
return max(0.7, min(2.2, image.size.width / image.size.height))
|
||||
#endif
|
||||
}
|
||||
|
||||
private func openViewer() {
|
||||
Task {
|
||||
await model.presentMediaViewer(message)
|
||||
if model.mediaViewerItem?.id != message.id,
|
||||
message.remoteAttachmentURL == nil {
|
||||
onFallbackTap()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
struct VideoNotePreview: View {
|
||||
@ObservedObject var model: AppModel
|
||||
let message: ChatMessage
|
||||
let onFallbackTap: () -> Void
|
||||
|
||||
@State private var player: AVPlayer?
|
||||
@State private var isPlaying = false
|
||||
|
||||
private var diameter: CGFloat { isPlaying ? 176 : 144 }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.08, green: 0.30, blue: 0.56),
|
||||
Color(red: 0.07, green: 0.64, blue: 0.83)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
thumbnail
|
||||
|
||||
if let player {
|
||||
InlineVideoPlayer(player: player)
|
||||
}
|
||||
|
||||
if previewURL == nil, model.isMediaPreviewLoading(message) {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.controlSize(.large)
|
||||
} else if !isPlaying {
|
||||
Image(systemName: "play.fill")
|
||||
.font(.system(size: 24, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(17)
|
||||
.background(.black.opacity(0.34), in: Circle())
|
||||
}
|
||||
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
Text(durationText)
|
||||
.font(.caption2.monospacedDigit().weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(.black.opacity(0.48), in: Capsule())
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
}
|
||||
.frame(width: diameter, height: diameter)
|
||||
.animation(.spring(response: 0.32, dampingFraction: 0.82), value: isPlaying)
|
||||
.clipShape(Circle())
|
||||
.overlay(Circle().stroke(.white.opacity(0.18), lineWidth: 1))
|
||||
.contentShape(Circle())
|
||||
.onTapGesture(perform: togglePlayback)
|
||||
.task(id: message.remoteAttachmentURL) {
|
||||
await model.prepareMediaPreview(message)
|
||||
configurePlayerIfNeeded()
|
||||
}
|
||||
.onChange(of: previewURL) { _, _ in
|
||||
configurePlayerIfNeeded()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime)) { notification in
|
||||
guard let item = notification.object as? AVPlayerItem,
|
||||
item === player?.currentItem else { return }
|
||||
player?.seek(to: .zero)
|
||||
isPlaying = false
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .lumaExclusiveMediaPlayback)) { notification in
|
||||
guard let activeID = notification.object as? String,
|
||||
activeID != message.id else { return }
|
||||
player?.pause()
|
||||
isPlaying = false
|
||||
}
|
||||
.onDisappear {
|
||||
player?.pause()
|
||||
isPlaying = false
|
||||
}
|
||||
.accessibilityLabel("Видеосообщение, \(durationText)")
|
||||
.accessibilityHint("Нажмите для воспроизведения или паузы")
|
||||
}
|
||||
|
||||
private var previewURL: URL? {
|
||||
model.mediaPreviewURL(for: message)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var thumbnail: some View {
|
||||
if let data = model.mediaThumbnail(for: message) {
|
||||
#if os(iOS)
|
||||
if let image = ChatMediaImageCache.image(for: message, data: data) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if let image = ChatMediaImageCache.image(for: message, data: data) {
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
Image(systemName: "video.fill")
|
||||
.font(.system(size: 34, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
}
|
||||
}
|
||||
|
||||
private var durationText: String {
|
||||
let seconds = max(0, Int((message.duration ?? 0).rounded()))
|
||||
return String(format: "%d:%02d", seconds / 60, seconds % 60)
|
||||
}
|
||||
|
||||
private func configurePlayerIfNeeded() {
|
||||
guard player == nil, let previewURL else { return }
|
||||
let player = AVPlayer(url: previewURL)
|
||||
player.actionAtItemEnd = .pause
|
||||
self.player = player
|
||||
}
|
||||
|
||||
private func togglePlayback() {
|
||||
guard previewURL != nil else {
|
||||
onFallbackTap()
|
||||
return
|
||||
}
|
||||
configurePlayerIfNeeded()
|
||||
guard let player else { return }
|
||||
if isPlaying {
|
||||
player.pause()
|
||||
} else {
|
||||
model.audioPlayback.stop()
|
||||
NotificationCenter.default.post(
|
||||
name: .lumaExclusiveMediaPlayback,
|
||||
object: message.id
|
||||
)
|
||||
player.play()
|
||||
}
|
||||
isPlaying.toggle()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import SwiftUI
|
||||
|
||||
struct EncryptionDevicesView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
let conversation: Conversation
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var devices: [OMEMODevice] = []
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
Label(
|
||||
statusTitle,
|
||||
systemImage: statusIcon
|
||||
)
|
||||
.foregroundStyle(statusColor)
|
||||
} footer: {
|
||||
Text("Luma использует trust-on-first-use. Сверьте отпечаток по другому каналу и отметьте устройство как проверенное.")
|
||||
}
|
||||
|
||||
if devices.isEmpty {
|
||||
Section("Устройства контакта") {
|
||||
ContentUnavailableView(
|
||||
"Ключи ещё не получены",
|
||||
systemImage: "key.horizontal",
|
||||
description: Text("Они появятся после первой попытки отправки или получения OMEMO-сообщения.")
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Section("Устройства контакта") {
|
||||
ForEach(devices) { device in
|
||||
DeviceRow(device: device) {
|
||||
model.setDeviceVerified(
|
||||
device.trust != .verified,
|
||||
jid: device.jid,
|
||||
deviceID: device.deviceID
|
||||
)
|
||||
reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let fingerprint = model.ownFingerprint {
|
||||
Section("Это устройство") {
|
||||
Text(fingerprint.chunked(every: 8).joined(separator: " "))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Шифрование")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Готово") { dismiss() }
|
||||
}
|
||||
}
|
||||
.onAppear(perform: reload)
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 480, minHeight: 500)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func reload() {
|
||||
devices = model.devices(for: conversation.jid).filter { !$0.isOwn }
|
||||
}
|
||||
|
||||
private var statusTitle: String {
|
||||
guard model.encryptionEnabled(for: conversation.jid) else { return "OMEMO выключено для этого чата" }
|
||||
return model.isOMEMOReady ? "OMEMO активно" : "OMEMO подготавливается"
|
||||
}
|
||||
|
||||
private var statusIcon: String {
|
||||
guard model.encryptionEnabled(for: conversation.jid) else { return "lock.open" }
|
||||
return model.isOMEMOReady ? "lock.shield.fill" : "lock.rotation"
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
model.encryptionEnabled(for: conversation.jid) && model.isOMEMOReady ? .green : .orange
|
||||
}
|
||||
}
|
||||
|
||||
private struct DeviceRow: View {
|
||||
let device: OMEMODevice
|
||||
let toggleVerification: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Label("Устройство \(device.deviceID)", systemImage: device.isActive ? "iphone" : "iphone.slash")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Button(action: toggleVerification) {
|
||||
Label(trustTitle, systemImage: trustIcon)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(trustColor)
|
||||
}
|
||||
Text(device.formattedFingerprint)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.padding(.vertical, 5)
|
||||
}
|
||||
|
||||
private var trustTitle: String {
|
||||
switch device.trust {
|
||||
case .verified: return "Проверено"
|
||||
case .trusted: return "Доверено"
|
||||
case .undecided: return "Проверить"
|
||||
case .compromised: return "Скомпрометировано"
|
||||
}
|
||||
}
|
||||
|
||||
private var trustIcon: String {
|
||||
switch device.trust {
|
||||
case .verified: return "checkmark.seal.fill"
|
||||
case .trusted: return "checkmark.shield"
|
||||
case .undecided: return "questionmark.diamond"
|
||||
case .compromised: return "exclamationmark.triangle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private var trustColor: Color {
|
||||
switch device.trust {
|
||||
case .verified: return .green
|
||||
case .trusted: return .blue
|
||||
case .undecided: return .orange
|
||||
case .compromised: return .red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
func chunked(every size: Int) -> [String] {
|
||||
guard size > 0 else { return [self] }
|
||||
var result: [String] = []
|
||||
var cursor = startIndex
|
||||
while cursor < endIndex {
|
||||
let end = index(cursor, offsetBy: size, limitedBy: endIndex) ?? endIndex
|
||||
result.append(String(self[cursor..<end]))
|
||||
cursor = end
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct ForwardMessageView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
let messages: [ChatMessage]
|
||||
let onComplete: () -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var destinationJID = ""
|
||||
@State private var isForwarding = false
|
||||
|
||||
init(
|
||||
model: AppModel,
|
||||
messages: [ChatMessage],
|
||||
onComplete: @escaping () -> Void = {}
|
||||
) {
|
||||
_model = ObservedObject(wrappedValue: model)
|
||||
self.messages = messages.filter(\.canBeForwarded)
|
||||
self.onComplete = onComplete
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section(messages.count == 1 ? "Пересылаемое сообщение" : "Пересылаемые сообщения") {
|
||||
Label(forwardingSummary, systemImage: "arrowshape.turn.up.right.fill")
|
||||
.fontWeight(.semibold)
|
||||
ForEach(Array(messages.prefix(3))) { message in
|
||||
Text(message.previewText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
if messages.count > 3 {
|
||||
Text("И ещё \(messages.count - 3)…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if !model.conversations.isEmpty {
|
||||
Section("Чаты") {
|
||||
ForEach(model.conversations) { conversation in
|
||||
Button {
|
||||
forward(to: conversation.jid)
|
||||
} label: {
|
||||
HStack(spacing: 11) {
|
||||
AvatarView(
|
||||
conversation: conversation,
|
||||
imageData: model.avatarData(for: conversation.jid),
|
||||
size: 34
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(conversation.displayName)
|
||||
.foregroundStyle(.primary)
|
||||
Text(conversation.jid)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "paperplane.fill")
|
||||
.foregroundStyle(.tint)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isForwarding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Другой адрес") {
|
||||
TextField("user@example.org", text: $destinationJID)
|
||||
.disableAutocorrection(true)
|
||||
Button {
|
||||
forward(to: destinationJID)
|
||||
} label: {
|
||||
Label("Переслать", systemImage: "paperplane.fill")
|
||||
}
|
||||
.disabled(
|
||||
isForwarding
|
||||
|| destinationJID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle(messages.count == 1 ? "Переслать" : "Переслать сообщения")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Отмена") { dismiss() }
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if isForwarding {
|
||||
ZStack {
|
||||
Color.black.opacity(0.08).ignoresSafeArea()
|
||||
ProgressView("Пересылка…")
|
||||
.padding(18)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 420, minHeight: 520)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func forward(to jid: String) {
|
||||
guard !isForwarding, !messages.isEmpty else { return }
|
||||
isForwarding = true
|
||||
Task {
|
||||
let succeeded = await model.forwardMessages(messages, to: jid)
|
||||
isForwarding = false
|
||||
if succeeded {
|
||||
onComplete()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var forwardingSummary: String {
|
||||
let count = messages.count
|
||||
let remainder100 = count % 100
|
||||
let remainder10 = count % 10
|
||||
let word: String
|
||||
if (11...14).contains(remainder100) {
|
||||
word = "сообщений"
|
||||
} else if remainder10 == 1 {
|
||||
word = "сообщение"
|
||||
} else if (2...4).contains(remainder10) {
|
||||
word = "сообщения"
|
||||
} else {
|
||||
word = "сообщений"
|
||||
}
|
||||
return "\(count) \(word)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct GroupInfoView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
let conversation: Conversation
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var nickname: String
|
||||
@State private var invitees = ""
|
||||
@State private var isJoining = false
|
||||
@State private var isInviting = false
|
||||
|
||||
init(model: AppModel, conversation: Conversation) {
|
||||
self.model = model
|
||||
self.conversation = conversation
|
||||
_nickname = State(initialValue: conversation.groupNickname ?? model.suggestedGroupNickname)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Групповой чат") {
|
||||
LabeledContent("Комната", value: conversation.jid)
|
||||
LabeledContent("Состояние", value: liveConversation.isGroupJoined ? "Подключено" : "Не подключено")
|
||||
LabeledContent("Участников", value: "\(liveConversation.occupantCount)")
|
||||
TextField("Ваш псевдоним", text: $nickname)
|
||||
.disabled(liveConversation.isGroupJoined)
|
||||
}
|
||||
|
||||
if !liveConversation.isGroupJoined {
|
||||
Section {
|
||||
Button {
|
||||
join()
|
||||
} label: {
|
||||
Label(liveConversation.invitedBy == nil ? "Войти в комнату" : "Принять приглашение", systemImage: "person.2.fill")
|
||||
}
|
||||
.disabled(nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isJoining)
|
||||
} footer: {
|
||||
if let inviter = liveConversation.invitedBy {
|
||||
Text("Приглашение от \(inviter)")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Section("Пригласить участников") {
|
||||
TextField("user@example.org, ещё@example.org", text: $invitees, axis: .vertical)
|
||||
.lineLimit(2...4)
|
||||
Button {
|
||||
invite()
|
||||
} label: {
|
||||
if isInviting {
|
||||
ProgressView()
|
||||
} else {
|
||||
Label("Отправить приглашения", systemImage: "person.badge.plus")
|
||||
}
|
||||
}
|
||||
.disabled(parsedInvitees.isEmpty || isInviting)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
model.leaveGroup(jid: conversation.jid)
|
||||
dismiss()
|
||||
} label: {
|
||||
Label("Покинуть комнату", systemImage: "rectangle.portrait.and.arrow.right")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Label(
|
||||
model.encryptionEnabled(for: conversation.jid)
|
||||
? "Для комнаты включено OMEMO. Нужны неанонимный MUC и OMEMO-устройства у всех участников."
|
||||
: "OMEMO для этой комнаты выключено: сообщения передаются открытым текстом внутри TLS.",
|
||||
systemImage: model.encryptionEnabled(for: conversation.jid) ? "lock.fill" : "lock.open"
|
||||
)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle(conversation.displayName)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Готово") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 480, minHeight: 500)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var liveConversation: Conversation {
|
||||
model.conversations.first(where: { $0.id == conversation.id }) ?? conversation
|
||||
}
|
||||
|
||||
private var parsedInvitees: [String] {
|
||||
invitees.components(separatedBy: CharacterSet(charactersIn: ",;\n \t"))
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
private func join() {
|
||||
isJoining = true
|
||||
Task {
|
||||
await model.joinGroup(jid: conversation.jid, nickname: nickname)
|
||||
isJoining = false
|
||||
}
|
||||
}
|
||||
|
||||
private func invite() {
|
||||
let outgoing = parsedInvitees
|
||||
guard !outgoing.isEmpty else { return }
|
||||
isInviting = true
|
||||
Task {
|
||||
await model.inviteMembers(outgoing, to: conversation.jid)
|
||||
invitees = ""
|
||||
isInviting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import CoreLocation
|
||||
import Combine
|
||||
import Foundation
|
||||
import MapKit
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct LocationPickerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject private var locationProvider = LocationProvider()
|
||||
@State private var cameraPosition: MapCameraPosition = .automatic
|
||||
@State private var selectedCoordinate: CLLocationCoordinate2D?
|
||||
|
||||
let onSend: (GeoLocation) -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
Map(position: $cameraPosition) {
|
||||
UserAnnotation()
|
||||
}
|
||||
.mapStyle(.standard(elevation: .realistic))
|
||||
.onMapCameraChange(frequency: .onEnd) { context in
|
||||
selectedCoordinate = context.region.center
|
||||
}
|
||||
|
||||
Image(systemName: "mappin.circle.fill")
|
||||
.font(.system(size: 42, weight: .semibold))
|
||||
.symbolRenderingMode(.palette)
|
||||
.foregroundStyle(.white, Color.accentColor)
|
||||
.shadow(color: .black.opacity(0.28), radius: 4, y: 2)
|
||||
.offset(y: -20)
|
||||
.allowsHitTesting(false)
|
||||
|
||||
VStack {
|
||||
if let message = locationProvider.errorMessage {
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.regularMaterial, in: Capsule())
|
||||
.padding()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: centerOnCurrentLocation) {
|
||||
Group {
|
||||
if locationProvider.isLocating {
|
||||
ProgressView()
|
||||
} else {
|
||||
Image(systemName: "location.fill")
|
||||
}
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
.background(.regularMaterial, in: Circle())
|
||||
.shadow(color: .black.opacity(0.16), radius: 5, y: 2)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Показать мою геопозицию")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
VStack(spacing: 10) {
|
||||
Text(coordinateText)
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Button(action: sendSelection) {
|
||||
Label("Отправить эту геопозицию", systemImage: "location.fill")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.accentColor, in: RoundedRectangle(cornerRadius: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(selectedCoordinate == nil)
|
||||
}
|
||||
.padding(16)
|
||||
.background(.regularMaterial)
|
||||
}
|
||||
.navigationTitle("Геопозиция")
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Отмена") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
locationProvider.requestCurrentLocation()
|
||||
}
|
||||
.onReceive(locationProvider.$location.compactMap { $0 }) { location in
|
||||
selectedCoordinate = location.coordinate
|
||||
cameraPosition = .region(
|
||||
MKCoordinateRegion(
|
||||
center: location.coordinate,
|
||||
latitudinalMeters: 1_200,
|
||||
longitudinalMeters: 1_200
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var coordinateText: String {
|
||||
guard let selectedCoordinate else { return "Переместите карту, чтобы выбрать точку" }
|
||||
return String(
|
||||
format: "%.6f, %.6f",
|
||||
locale: Locale(identifier: "en_US_POSIX"),
|
||||
selectedCoordinate.latitude,
|
||||
selectedCoordinate.longitude
|
||||
)
|
||||
}
|
||||
|
||||
private func centerOnCurrentLocation() {
|
||||
if let location = locationProvider.location {
|
||||
selectedCoordinate = location.coordinate
|
||||
cameraPosition = .region(
|
||||
MKCoordinateRegion(
|
||||
center: location.coordinate,
|
||||
latitudinalMeters: 1_200,
|
||||
longitudinalMeters: 1_200
|
||||
)
|
||||
)
|
||||
} else {
|
||||
locationProvider.requestCurrentLocation()
|
||||
}
|
||||
}
|
||||
|
||||
private func sendSelection() {
|
||||
guard let selectedCoordinate,
|
||||
let location = GeoLocation(
|
||||
latitude: selectedCoordinate.latitude,
|
||||
longitude: selectedCoordinate.longitude
|
||||
) else { return }
|
||||
onSend(location)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import SwiftUI
|
||||
|
||||
struct LoginView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
@State private var jid = ""
|
||||
@State private var password = ""
|
||||
@State private var displayName = ""
|
||||
@State private var resource = "Luma"
|
||||
@State private var showAdvanced = false
|
||||
@State private var manualHost = ""
|
||||
@State private var manualPort = ""
|
||||
@State private var directTLS = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.08, green: 0.38, blue: 0.78),
|
||||
Color(red: 0.12, green: 0.62, blue: 0.96),
|
||||
Color(red: 0.44, green: 0.84, blue: 0.96)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
|
||||
Circle()
|
||||
.fill(.white.opacity(0.12))
|
||||
.frame(width: 420, height: 420)
|
||||
.offset(x: -210, y: -260)
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 28) {
|
||||
brand
|
||||
loginCard
|
||||
}
|
||||
.frame(maxWidth: 460)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 48)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var brand: some View {
|
||||
VStack(spacing: 14) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||
.fill(.white)
|
||||
.frame(width: 88, height: 88)
|
||||
.shadow(color: .black.opacity(0.16), radius: 22, y: 10)
|
||||
Image(systemName: "bubble.left.and.bubble.right.fill")
|
||||
.font(.system(size: 39, weight: .semibold))
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [.blue, .cyan],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Text("Luma")
|
||||
.font(.system(size: 42, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
Text("Приватный XMPP без привязки к одному серверу")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white.opacity(0.86))
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
|
||||
private var loginCard: some View {
|
||||
VStack(spacing: 18) {
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
Text("XMPP-аккаунт")
|
||||
.font(.headline)
|
||||
TextField("you@example.org", text: $jid)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disableAutocorrection(true)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
Text("Пароль")
|
||||
.font(.headline)
|
||||
SecureField("Пароль", text: $password)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
DisclosureGroup("Дополнительные настройки", isExpanded: $showAdvanced) {
|
||||
VStack(spacing: 14) {
|
||||
TextField("Отображаемое имя", text: $displayName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
TextField("Ресурс устройства", text: $resource)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
TextField("Сервер вручную (необязательно)", text: $manualHost)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
TextField(directTLS ? "Порт, обычно 5223" : "Порт, обычно 5222", text: $manualPort)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Toggle("Direct TLS", isOn: $directTLS)
|
||||
Text("Без ручного адреса Luma использует DNS SRV и автоматически выбирает STARTTLS/direct TLS.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.top, 14)
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await signIn() }
|
||||
} label: {
|
||||
HStack {
|
||||
if model.isAuthenticating {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
Text(model.isAuthenticating ? "Подключение…" : "Войти")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 34)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.disabled(model.isAuthenticating)
|
||||
|
||||
Label("Сообщения отправляются с OMEMO. Пароль хранится только в Keychain.", systemImage: "lock.shield.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(26)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||||
.shadow(color: .black.opacity(0.14), radius: 24, y: 12)
|
||||
}
|
||||
|
||||
private func signIn() async {
|
||||
let port = Int(manualPort.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
let account = AccountConfiguration(
|
||||
jid: jid,
|
||||
displayName: displayName,
|
||||
resource: resource,
|
||||
manualHost: manualHost,
|
||||
manualPort: port,
|
||||
usesDirectTLS: directTLS
|
||||
)
|
||||
await model.signIn(account: account, password: password)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct MainChatView: View {
|
||||
private enum SidebarMode: String, CaseIterable {
|
||||
case chats
|
||||
case contacts
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .chats: return "Чаты"
|
||||
case .contacts: return "Контакты"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ObservedObject var model: AppModel
|
||||
@State private var searchText = ""
|
||||
@State private var sidebarMode: SidebarMode = .chats
|
||||
@State private var showingNewChat = false
|
||||
@State private var showingNewGroup = false
|
||||
@State private var showingSettings = false
|
||||
|
||||
private var filteredConversations: [Conversation] {
|
||||
guard !searchText.isEmpty else { return model.conversations }
|
||||
return model.conversations.filter(matchesSearch)
|
||||
}
|
||||
|
||||
private var filteredRosterContacts: [Conversation] {
|
||||
model.conversations
|
||||
.filter { !$0.isGroup && model.rosterContactJIDs.contains($0.id) }
|
||||
.filter { searchText.isEmpty || matchesSearch($0) }
|
||||
.sorted(by: contactSort)
|
||||
}
|
||||
|
||||
private var filteredGroupContacts: [Conversation] {
|
||||
model.conversations
|
||||
.filter { $0.isGroup }
|
||||
.filter { searchText.isEmpty || matchesSearch($0) }
|
||||
.sorted(by: contactSort)
|
||||
}
|
||||
|
||||
private var hasStoredContacts: Bool {
|
||||
!model.rosterContactJIDs.isEmpty || model.conversations.contains { $0.isGroup }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
VStack(spacing: 0) {
|
||||
ConnectionBanner(model: model)
|
||||
|
||||
Picker("Раздел", selection: $sidebarMode) {
|
||||
Label("Чаты", systemImage: "bubble.left.and.bubble.right.fill")
|
||||
.tag(SidebarMode.chats)
|
||||
Label("Контакты", systemImage: "person.2.fill")
|
||||
.tag(SidebarMode.contacts)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
switch sidebarMode {
|
||||
case .chats:
|
||||
chatList
|
||||
case .contacts:
|
||||
contactsList
|
||||
}
|
||||
}
|
||||
.navigationTitle(sidebarMode.title)
|
||||
.searchable(
|
||||
text: $searchText,
|
||||
prompt: sidebarMode == .chats ? "Поиск чатов" : "Поиск контактов"
|
||||
)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
Button { showingSettings = true } label: {
|
||||
Image(systemName: "gearshape")
|
||||
}
|
||||
Button { showingNewGroup = true } label: {
|
||||
Image(systemName: "person.3.fill")
|
||||
}
|
||||
.accessibilityLabel("Новый групповой чат")
|
||||
.help("Новый групповой чат")
|
||||
Button { showingNewChat = true } label: {
|
||||
Image(systemName: "square.and.pencil")
|
||||
}
|
||||
.accessibilityLabel("Новый личный чат")
|
||||
.help("Новый личный чат")
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
if let conversation = model.selectedConversation {
|
||||
ChatView(model: model, conversation: conversation)
|
||||
.id(conversation.id)
|
||||
} else {
|
||||
WelcomeDetailView()
|
||||
}
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.onChange(of: model.selectedConversationID) { _, id in
|
||||
guard let id else { return }
|
||||
model.selectConversation(id: id)
|
||||
}
|
||||
.sheet(isPresented: $showingNewChat) {
|
||||
NewChatView(model: model)
|
||||
}
|
||||
.sheet(isPresented: $showingNewGroup) {
|
||||
NewGroupView(model: model)
|
||||
}
|
||||
.sheet(isPresented: $showingSettings) {
|
||||
SettingsView(model: model)
|
||||
}
|
||||
}
|
||||
|
||||
private var chatList: some View {
|
||||
List(selection: $model.selectedConversationID) {
|
||||
ForEach(filteredConversations) { conversation in
|
||||
ConversationRow(
|
||||
conversation: conversation,
|
||||
imageData: model.avatarData(for: conversation.jid),
|
||||
isEncrypted: model.encryptionEnabled(for: conversation.jid)
|
||||
)
|
||||
.tag(conversation.id)
|
||||
.listRowInsets(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12))
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.overlay {
|
||||
if filteredConversations.isEmpty {
|
||||
ContentUnavailableView(
|
||||
searchText.isEmpty ? "Нет чатов" : "Ничего не найдено",
|
||||
systemImage: searchText.isEmpty ? "bubble.left" : "magnifyingglass",
|
||||
description: Text(
|
||||
searchText.isEmpty
|
||||
? "Создайте личный или групповой чат."
|
||||
: "Попробуйте другой JID или имя."
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var contactsList: some View {
|
||||
List(selection: $model.selectedConversationID) {
|
||||
if !filteredRosterContacts.isEmpty {
|
||||
Section {
|
||||
ForEach(filteredRosterContacts) { conversation in
|
||||
ContactRow(
|
||||
conversation: conversation,
|
||||
imageData: model.avatarData(for: conversation.jid)
|
||||
)
|
||||
.tag(conversation.id)
|
||||
.listRowInsets(EdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12))
|
||||
}
|
||||
} header: {
|
||||
Label("Люди из roster", systemImage: "person.2.fill")
|
||||
}
|
||||
}
|
||||
|
||||
if !filteredGroupContacts.isEmpty {
|
||||
Section {
|
||||
ForEach(filteredGroupContacts) { conversation in
|
||||
ContactRow(
|
||||
conversation: conversation,
|
||||
imageData: model.avatarData(for: conversation.jid)
|
||||
)
|
||||
.tag(conversation.id)
|
||||
.listRowInsets(EdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12))
|
||||
}
|
||||
} header: {
|
||||
Label("Групповые чаты", systemImage: "person.3.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.overlay {
|
||||
if filteredRosterContacts.isEmpty && filteredGroupContacts.isEmpty {
|
||||
ContentUnavailableView(
|
||||
searchText.isEmpty ? "Нет контактов" : "Ничего не найдено",
|
||||
systemImage: searchText.isEmpty ? "person.2" : "magnifyingglass",
|
||||
description: Text(contactListDescription)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var contactListDescription: String {
|
||||
if !searchText.isEmpty {
|
||||
return "Попробуйте другой JID или имя."
|
||||
}
|
||||
if !hasStoredContacts, model.connectionStatus != .connected {
|
||||
return "Подключитесь к серверу, чтобы загрузить roster Prosody и групповые комнаты."
|
||||
}
|
||||
return "Добавьте человека в roster или создайте групповой чат."
|
||||
}
|
||||
|
||||
private func matchesSearch(_ conversation: Conversation) -> Bool {
|
||||
conversation.displayName.localizedCaseInsensitiveContains(searchText)
|
||||
|| conversation.jid.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
|
||||
private func contactSort(_ lhs: Conversation, _ rhs: Conversation) -> Bool {
|
||||
lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
private struct ContactRow: View {
|
||||
let conversation: Conversation
|
||||
let imageData: Data?
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
AvatarView(conversation: conversation, imageData: imageData, size: 42)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(spacing: 6) {
|
||||
Text(conversation.displayName)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
if !conversation.isGroup, conversation.isOnline {
|
||||
Circle()
|
||||
.fill(Color.green)
|
||||
.frame(width: 7, height: 7)
|
||||
.accessibilityLabel("В сети")
|
||||
}
|
||||
}
|
||||
|
||||
if conversation.isGroup {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "person.3.fill")
|
||||
Text(groupSubtitle)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text(conversation.jid)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private var groupSubtitle: String {
|
||||
guard conversation.occupantCount > 0 else { return conversation.jid }
|
||||
return "\(conversation.occupantCount) участн. • \(conversation.jid)"
|
||||
}
|
||||
}
|
||||
|
||||
private struct WelcomeDetailView: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.secondary.opacity(0.04).ignoresSafeArea()
|
||||
ContentUnavailableView {
|
||||
Label("Luma", systemImage: "bubble.left.and.bubble.right.fill")
|
||||
} description: {
|
||||
Text("Выберите чат или начните новый защищённый диалог.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import SwiftUI
|
||||
|
||||
struct NewChatView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var jid = ""
|
||||
@State private var name = ""
|
||||
@State private var addToRoster = true
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Контакт") {
|
||||
TextField("user@example.org", text: $jid)
|
||||
.disableAutocorrection(true)
|
||||
TextField("Имя (необязательно)", text: $name)
|
||||
}
|
||||
Section {
|
||||
Toggle("Добавить в roster", isOn: $addToRoster)
|
||||
} footer: {
|
||||
Text("Начать чат можно с любым полным JID. Для шифрования у контакта должно быть опубликовано хотя бы одно OMEMO-устройство.")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Новый чат")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Отмена") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Начать") {
|
||||
model.openConversation(
|
||||
jid: jid,
|
||||
name: name.isEmpty ? nil : name,
|
||||
addToRoster: addToRoster
|
||||
)
|
||||
if model.errorMessage == nil { dismiss() }
|
||||
}
|
||||
.fontWeight(.semibold)
|
||||
.disabled(jid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 380, minHeight: 300)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct NewGroupView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var roomJID = ""
|
||||
@State private var name = ""
|
||||
@State private var nickname = ""
|
||||
@State private var invitees = ""
|
||||
@State private var isJoining = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
TextField("room@conference.example.org", text: $roomJID)
|
||||
.disableAutocorrection(true)
|
||||
TextField("Название (необязательно)", text: $name)
|
||||
TextField("Ваш псевдоним", text: $nickname)
|
||||
}
|
||||
} label: {
|
||||
Text("Комната")
|
||||
.font(.headline)
|
||||
}
|
||||
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
TextField("alice@example.org, bob@example.org", text: $invitees, axis: .vertical)
|
||||
.lineLimit(2...5)
|
||||
Text("Укажите JID через запятую или с новой строки. Если комнаты ещё нет, Prosody создаст её с настройками по умолчанию.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} label: {
|
||||
Text("Пригласить")
|
||||
.font(.headline)
|
||||
}
|
||||
|
||||
Label(
|
||||
"Новая комната настраивается как неанонимная и доступная участникам, чтобы поддерживать групповое OMEMO. Режим шифрования можно изменить в меню замка внутри чата.",
|
||||
systemImage: "lock.shield.fill"
|
||||
)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.navigationTitle("Новая группа")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Отмена") { dismiss() }
|
||||
.disabled(isJoining)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Создать / войти") { join() }
|
||||
.fontWeight(.semibold)
|
||||
.disabled(!canJoin || isJoining)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if nickname.isEmpty { nickname = model.suggestedGroupNickname }
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 480, minHeight: 470)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var canJoin: Bool {
|
||||
!roomJID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& !nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
private func join() {
|
||||
isJoining = true
|
||||
Task {
|
||||
await model.createOrJoinGroup(
|
||||
roomJID: roomJID,
|
||||
name: name.isEmpty ? nil : name,
|
||||
nickname: nickname,
|
||||
invitees: parsedInvitees
|
||||
)
|
||||
isJoining = false
|
||||
if model.errorMessage == nil { dismiss() }
|
||||
}
|
||||
}
|
||||
|
||||
private var parsedInvitees: [String] {
|
||||
invitees.components(separatedBy: CharacterSet(charactersIn: ",;\n \t"))
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import QuickLook
|
||||
import SwiftUI
|
||||
|
||||
struct RootView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Group {
|
||||
if model.account == nil {
|
||||
LoginView(model: model)
|
||||
} else {
|
||||
MainChatView(model: model)
|
||||
}
|
||||
}
|
||||
|
||||
if let item = model.mediaViewerItem {
|
||||
MediaViewer(item: item, onClose: model.closeMediaViewer)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.98)))
|
||||
.zIndex(100)
|
||||
}
|
||||
|
||||
if let call = model.activeCall {
|
||||
CallView(model: model, call: call)
|
||||
.transition(.opacity.combined(with: .scale(scale: 1.015)))
|
||||
.zIndex(200)
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.22), value: model.account?.id)
|
||||
.animation(.easeInOut(duration: 0.2), value: model.mediaViewerItem?.id)
|
||||
.animation(.easeInOut(duration: 0.2), value: model.activeCall?.id)
|
||||
.alert("Luma", isPresented: errorBinding) {
|
||||
Button("OK") { model.clearError() }
|
||||
} message: {
|
||||
Text(model.errorMessage ?? "Неизвестная ошибка")
|
||||
}
|
||||
.quickLookPreview($model.previewURL)
|
||||
}
|
||||
|
||||
private var errorBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { model.errorMessage != nil },
|
||||
set: { visible in if !visible { model.clearError() } }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var showingSignOutConfirmation = false
|
||||
@State private var forgetHistory = false
|
||||
@State private var avatarItem: PhotosPickerItem?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
if let account = model.account {
|
||||
Section("Профиль") {
|
||||
HStack(spacing: 14) {
|
||||
AvatarView(
|
||||
conversation: Conversation(
|
||||
jid: account.normalizedJID,
|
||||
displayName: account.displayName.isEmpty ? nil : account.displayName
|
||||
),
|
||||
imageData: model.avatarData(for: account.normalizedJID),
|
||||
size: 64
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(account.displayName.isEmpty ? account.normalizedJID : account.displayName)
|
||||
.font(.headline)
|
||||
PhotosPicker(selection: $avatarItem, matching: .images) {
|
||||
Label("Обновить аватар", systemImage: "photo.badge.plus")
|
||||
}
|
||||
.disabled(model.isUpdatingAvatar)
|
||||
}
|
||||
Spacer()
|
||||
if model.isUpdatingAvatar {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Аккаунт") {
|
||||
LabeledContent("JID", value: account.normalizedJID)
|
||||
LabeledContent("Ресурс", value: account.effectiveResource)
|
||||
if let host = account.manualHost {
|
||||
LabeledContent("Сервер", value: "\(host):\(account.manualPort ?? (account.usesDirectTLS ? 5223 : 5222))")
|
||||
} else {
|
||||
LabeledContent("Обнаружение", value: "DNS SRV")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Соединение") {
|
||||
HStack {
|
||||
Label(connectionTitle, systemImage: connectionIcon)
|
||||
.foregroundStyle(connectionColor)
|
||||
Spacer()
|
||||
if model.connectionStatus != .connected {
|
||||
Button("Подключить") { Task { await model.reconnect() } }
|
||||
}
|
||||
}
|
||||
LabeledContent("История MAM", value: model.isArchiveSyncing ? "Синхронизация" : "Готово")
|
||||
}
|
||||
|
||||
Section("Шифрование") {
|
||||
Toggle("OMEMO по умолчанию", isOn: Binding(
|
||||
get: { model.globalEncryptionEnabled },
|
||||
set: { model.setGlobalEncryptionEnabled($0) }
|
||||
))
|
||||
Text("Для отдельного чата режим можно изменить через значок замка в его панели.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if !model.globalEncryptionEnabled {
|
||||
Label("Новые сообщения без отдельного правила отправляются открытым текстом.", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
LabeledContent("Состояние", value: model.isOMEMOReady ? "Активно" : "Подготовка")
|
||||
if let fingerprint = model.ownFingerprint {
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
Text("Отпечаток этого устройства")
|
||||
.font(.subheadline)
|
||||
Text(fingerprint.chunked(every: 8).joined(separator: " "))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Уведомления") {
|
||||
Label("Локальные уведомления включаются после входа", systemImage: "bell.badge")
|
||||
Text("Для доставки при выгруженном iOS-приложении требуется APNs и XEP-0357 push-шлюз приложения. Обычный XMPP-сокет не может постоянно работать в фоне.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Приватность чата") {
|
||||
Toggle("Показывать статус набора", isOn: Binding(
|
||||
get: { model.typingIndicatorsEnabled },
|
||||
set: { model.setTypingIndicatorsEnabled($0) }
|
||||
))
|
||||
Text("Когда включено, Luma показывает «печатает…» и отправляет собеседникам стандартные XMPP-состояния набора.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Удалить локальную историю при выходе", isOn: $forgetHistory)
|
||||
|
||||
Button(role: .destructive) {
|
||||
showingSignOutConfirmation = true
|
||||
} label: {
|
||||
Label("Выйти из аккаунта", systemImage: "rectangle.portrait.and.arrow.right")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Настройки")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Готово") { dismiss() }
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Выйти из Luma?",
|
||||
isPresented: $showingSignOutConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Выйти", role: .destructive) {
|
||||
Task {
|
||||
await model.signOut(forgetHistory: forgetHistory)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
Button("Отмена", role: .cancel) {}
|
||||
} message: {
|
||||
Text(forgetHistory
|
||||
? "Пароль и локальная история будут удалены. Серверная история MAM не изменится."
|
||||
: "Пароль будет удалён из Keychain. Серверная история MAM не изменится.")
|
||||
}
|
||||
.onChange(of: avatarItem) { _, item in
|
||||
guard let item else { return }
|
||||
Task {
|
||||
do {
|
||||
guard let data = try await item.loadTransferable(type: Data.self) else { return }
|
||||
await model.updateOwnAvatar(from: data)
|
||||
avatarItem = nil
|
||||
} catch {
|
||||
model.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 500, minHeight: 560)
|
||||
#endif
|
||||
}
|
||||
|
||||
private var connectionTitle: String {
|
||||
switch model.connectionStatus {
|
||||
case .connected: return "Подключено"
|
||||
case .connecting: return "Подключение"
|
||||
case .disconnected: return "Отключено"
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionIcon: String {
|
||||
switch model.connectionStatus {
|
||||
case .connected: return "checkmark.circle.fill"
|
||||
case .connecting: return "arrow.triangle.2.circlepath"
|
||||
case .disconnected: return "xmark.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private var connectionColor: Color {
|
||||
switch model.connectionStatus {
|
||||
case .connected: return .green
|
||||
case .connecting: return .orange
|
||||
case .disconnected: return .red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
func chunked(every size: Int) -> [String] {
|
||||
guard size > 0 else { return [self] }
|
||||
var result: [String] = []
|
||||
var cursor = startIndex
|
||||
while cursor < endIndex {
|
||||
let end = index(cursor, offsetBy: size, limitedBy: endIndex) ?? endIndex
|
||||
result.append(String(self[cursor..<end]))
|
||||
cursor = end
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import Foundation
|
||||
|
||||
struct CapturedCameraMedia: Sendable {
|
||||
let url: URL
|
||||
let kind: ChatMessage.Kind
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
struct SystemPhotoCameraView: UIViewControllerRepresentable {
|
||||
let onMedia: (Result<CapturedCameraMedia, Error>) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
static var isAvailable: Bool {
|
||||
UIImagePickerController.isSourceTypeAvailable(.camera)
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(onMedia: onMedia, onCancel: onCancel)
|
||||
}
|
||||
|
||||
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||
let picker = UIImagePickerController()
|
||||
picker.delegate = context.coordinator
|
||||
picker.sourceType = .camera
|
||||
picker.mediaTypes = [UTType.image.identifier, UTType.movie.identifier]
|
||||
picker.cameraCaptureMode = .photo
|
||||
picker.videoQuality = .typeHigh
|
||||
picker.videoMaximumDuration = 180
|
||||
picker.allowsEditing = false
|
||||
picker.modalPresentationStyle = .fullScreen
|
||||
return picker
|
||||
}
|
||||
|
||||
func updateUIViewController(
|
||||
_ uiViewController: UIImagePickerController,
|
||||
context: Context
|
||||
) {}
|
||||
|
||||
final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
||||
private let onMedia: (Result<CapturedCameraMedia, Error>) -> Void
|
||||
private let onCancel: () -> Void
|
||||
private var completed = false
|
||||
|
||||
init(
|
||||
onMedia: @escaping (Result<CapturedCameraMedia, Error>) -> Void,
|
||||
onCancel: @escaping () -> Void
|
||||
) {
|
||||
self.onMedia = onMedia
|
||||
self.onCancel = onCancel
|
||||
}
|
||||
|
||||
func imagePickerController(
|
||||
_ picker: UIImagePickerController,
|
||||
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
|
||||
) {
|
||||
guard !completed else { return }
|
||||
completed = true
|
||||
|
||||
let mediaType = (info[.mediaType] as? String).flatMap { UTType($0) }
|
||||
if mediaType?.conforms(to: .movie) == true,
|
||||
let sourceURL = info[.mediaURL] as? URL {
|
||||
prepareMovie(from: sourceURL)
|
||||
return
|
||||
}
|
||||
|
||||
guard let image = info[.originalImage] as? UIImage else {
|
||||
onMedia(.failure(SystemCameraError.missingMedia))
|
||||
return
|
||||
}
|
||||
preparePhoto(image)
|
||||
}
|
||||
|
||||
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
||||
guard !completed else { return }
|
||||
completed = true
|
||||
onCancel()
|
||||
}
|
||||
|
||||
private func preparePhoto(_ image: UIImage) {
|
||||
let completion = onMedia
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let result: Result<CapturedCameraMedia, Error>
|
||||
do {
|
||||
let url = try Self.destinationURL(extension: "jpg")
|
||||
guard let data = image.jpegData(compressionQuality: 0.9),
|
||||
!data.isEmpty else {
|
||||
throw SystemCameraError.encodingFailed
|
||||
}
|
||||
try data.write(to: url, options: [.atomic])
|
||||
result = .success(CapturedCameraMedia(url: url, kind: .photo))
|
||||
} catch {
|
||||
result = .failure(error)
|
||||
}
|
||||
DispatchQueue.main.async { completion(result) }
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareMovie(from sourceURL: URL) {
|
||||
let completion = onMedia
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
let result: Result<CapturedCameraMedia, Error>
|
||||
do {
|
||||
let fileExtension = sourceURL.pathExtension.isEmpty
|
||||
? "mov"
|
||||
: sourceURL.pathExtension
|
||||
let destination = try Self.destinationURL(extension: fileExtension)
|
||||
try FileManager.default.copyItem(at: sourceURL, to: destination)
|
||||
let values = try destination.resourceValues(
|
||||
forKeys: [.fileSizeKey, .isRegularFileKey]
|
||||
)
|
||||
guard values.isRegularFile == true,
|
||||
(values.fileSize ?? 0) > 0 else {
|
||||
try? FileManager.default.removeItem(at: destination)
|
||||
throw SystemCameraError.emptyMovie
|
||||
}
|
||||
result = .success(CapturedCameraMedia(url: destination, kind: .video))
|
||||
} catch {
|
||||
result = .failure(error)
|
||||
}
|
||||
DispatchQueue.main.async { completion(result) }
|
||||
}
|
||||
}
|
||||
|
||||
private static func destinationURL(extension fileExtension: String) throws -> URL {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("LumaCameraMedia", isDirectory: true)
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
return directory.appendingPathComponent(
|
||||
"camera-\(UUID().uuidString).\(fileExtension)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum SystemCameraError: LocalizedError {
|
||||
case missingMedia
|
||||
case encodingFailed
|
||||
case emptyMovie
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .missingMedia:
|
||||
return "Камера не вернула фото или видео."
|
||||
case .encodingFailed:
|
||||
return "Не удалось подготовить фотографию к отправке."
|
||||
case .emptyMovie:
|
||||
return "Камера вернула пустой видеофайл. Повторите запись."
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,192 @@
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
struct VideoNoteCaptureView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject private var recorder = VideoNoteRecorder()
|
||||
@State private var errorMessage: String?
|
||||
@State private var isFinalizing = false
|
||||
|
||||
let onComplete: (VideoNoteRecorder.Recording) -> Void
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 22) {
|
||||
ZStack {
|
||||
Circle().fill(Color.black)
|
||||
CameraPreview(session: recorder.session)
|
||||
.clipShape(Circle())
|
||||
if !recorder.isPrepared {
|
||||
ProgressView("Подготовка камеры…")
|
||||
.tint(.white)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 420)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.overlay(Circle().stroke(.white.opacity(0.16), lineWidth: 1))
|
||||
|
||||
Text(recordingStatus)
|
||||
.font(.system(.title3, design: .monospaced).weight(.semibold))
|
||||
.foregroundStyle(recorder.isRecording ? .red : .secondary)
|
||||
|
||||
Button {
|
||||
guard recorder.isRecording, !isFinalizing else { return }
|
||||
isFinalizing = true
|
||||
Task { @MainActor in
|
||||
await recorder.stop(
|
||||
afterMinimumDuration: VideoNoteStopPolicy.minimumCaptureDuration
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color.red, lineWidth: 4)
|
||||
.frame(width: 76, height: 76)
|
||||
if isFinalizing {
|
||||
ProgressView()
|
||||
.controlSize(.regular)
|
||||
.tint(.red)
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 7)
|
||||
.fill(Color.red)
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!recorder.isRecording || isFinalizing)
|
||||
.accessibilityLabel(isFinalizing ? "Видео завершается" : "Завершить запись")
|
||||
|
||||
Text("Запись началась. Нажмите красную кнопку, чтобы завершить и отправить.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(24)
|
||||
.navigationTitle("Видеосообщение")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Отмена") {
|
||||
recorder.cancel()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
recorder.onFinished = { result in
|
||||
switch result {
|
||||
case .success(let recording):
|
||||
onComplete(recording)
|
||||
dismiss()
|
||||
case .failure(let error):
|
||||
isFinalizing = false
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
do {
|
||||
try await recorder.prepare()
|
||||
guard !Task.isCancelled else { return }
|
||||
try recorder.start()
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if recorder.isRecording {
|
||||
recorder.cancel()
|
||||
} else {
|
||||
recorder.shutdown()
|
||||
}
|
||||
}
|
||||
.alert("Luma", isPresented: Binding(
|
||||
get: { errorMessage != nil },
|
||||
set: { if !$0 { errorMessage = nil } }
|
||||
)) {
|
||||
Button("OK") { errorMessage = nil }
|
||||
} message: {
|
||||
Text(errorMessage ?? "Неизвестная ошибка")
|
||||
}
|
||||
#if os(macOS)
|
||||
.frame(minWidth: 540, minHeight: 650)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func formatted(_ duration: TimeInterval) -> String {
|
||||
let seconds = max(0, Int(duration.rounded(.down)))
|
||||
return String(format: "%d:%02d", seconds / 60, seconds % 60)
|
||||
}
|
||||
|
||||
private var recordingStatus: String {
|
||||
if isFinalizing { return "Завершение…" }
|
||||
if recorder.isRecording { return formatted(recorder.elapsed) }
|
||||
return "Подготовка камеры…"
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
struct CameraPreview: UIViewRepresentable {
|
||||
let session: AVCaptureSession
|
||||
|
||||
func makeUIView(context: Context) -> PreviewView {
|
||||
let view = PreviewView()
|
||||
view.previewLayer.session = session
|
||||
view.previewLayer.videoGravity = .resizeAspectFill
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: PreviewView, context: Context) {
|
||||
uiView.previewLayer.session = session
|
||||
}
|
||||
|
||||
final class PreviewView: UIView {
|
||||
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
|
||||
|
||||
var previewLayer: AVCaptureVideoPreviewLayer {
|
||||
layer as! AVCaptureVideoPreviewLayer
|
||||
}
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
struct CameraPreview: NSViewRepresentable {
|
||||
let session: AVCaptureSession
|
||||
|
||||
func makeNSView(context: Context) -> PreviewView {
|
||||
let view = PreviewView()
|
||||
view.previewLayer.session = session
|
||||
view.previewLayer.videoGravity = .resizeAspectFill
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: PreviewView, context: Context) {
|
||||
nsView.previewLayer.session = session
|
||||
}
|
||||
|
||||
final class PreviewView: NSView {
|
||||
let previewLayer = AVCaptureVideoPreviewLayer()
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
wantsLayer = true
|
||||
layer = previewLayer
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
return nil
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
previewLayer.frame = bounds
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -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
@@ -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)"
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user