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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user