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

No files matched your search

@@ -0,0 +1,92 @@
import Foundation
struct AccountConfiguration: Codable, Equatable, Hashable, Identifiable, Sendable {
var jid: String
var displayName: String
var resource: String
var manualHost: String?
var manualPort: Int?
var usesDirectTLS: Bool
var id: String { normalizedJID }
var normalizedJID: String {
jid.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
var domain: String? {
let pieces = normalizedJID.split(separator: "@", omittingEmptySubsequences: false)
guard pieces.count == 2, !pieces[1].isEmpty else { return nil }
return String(pieces[1])
}
var effectiveResource: String {
let trimmed = resource.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? "Luma" : trimmed
}
init(
jid: String,
displayName: String = "",
resource: String = "Luma",
manualHost: String? = nil,
manualPort: Int? = nil,
usesDirectTLS: Bool = false
) {
self.jid = jid
self.displayName = displayName
self.resource = resource
self.manualHost = manualHost?.nilIfBlank
self.manualPort = manualPort
self.usesDirectTLS = usesDirectTLS
}
func validated() throws -> AccountConfiguration {
let normalized = normalizedJID
let pieces = normalized.split(separator: "@", omittingEmptySubsequences: false)
guard pieces.count == 2,
!pieces[0].isEmpty,
!pieces[1].isEmpty,
!normalized.contains(where: { $0.isWhitespace }) else {
throw AccountValidationError.invalidJID
}
if let manualHost, manualHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
throw AccountValidationError.invalidHost
}
if let manualPort, !(1...65_535).contains(manualPort) {
throw AccountValidationError.invalidPort
}
var copy = self
copy.jid = normalized
copy.manualHost = manualHost?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfBlank
copy.resource = effectiveResource
return copy
}
}
enum AccountValidationError: LocalizedError {
case invalidJID
case invalidHost
case invalidPort
var errorDescription: String? {
switch self {
case .invalidJID:
return "Введите полный JID в формате user@example.org."
case .invalidHost:
return "Адрес сервера не может быть пустым."
case .invalidPort:
return "Порт должен быть числом от 1 до 65535."
}
}
}
private extension String {
var nilIfBlank: String? {
isEmpty ? nil : self
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,15 @@
import Foundation
/// Keeps MAM work below a visible frame-sized burst. A whole decoded pass is
/// still published atomically; these values only bound network and decryption
/// work performed before that publication.
enum ArchiveMessageBatchPolicy {
static let pageSize = 24
static let bootstrapMessageLimit = 40
static let decodeSliceSize = 1
static let maximumBufferedStanzas = 48
/// Leave roughly half of a 60 Hz frame between archived decryptions. This
/// keeps UIScrollView touch handling responsive even when OMEMO work is
/// unusually expensive on older devices.
static let interSliceDelayNanoseconds: UInt64 = 8_000_000
}
@@ -0,0 +1,81 @@
import Foundation
/// Durable MAM position. The archive UID is the primary synchronization
/// anchor; the timestamp is retained as a compatibility fallback for legacy
/// snapshots and servers that have expired an old UID.
struct ArchiveSyncCheckpoint: Codable, Equatable, Sendable {
let timestamp: Date
let cursor: String?
init(timestamp: Date, cursor: String? = nil) {
self.timestamp = timestamp
self.cursor = Self.normalizedCursor(cursor)
}
func advances(over previous: ArchiveSyncCheckpoint?) -> Bool {
guard let previous else { return true }
return timestamp > previous.timestamp || cursor != previous.cursor
}
static func normalizedCursor(_ value: String?) -> String? {
guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty else { return nil }
return value
}
}
struct ArchiveSyncRequestPosition: Equatable, Sendable {
let start: Date?
let after: String?
}
/// Keeps cursor-based catch-up and the timestamp fallback deterministic and
/// independently testable from Martin's callback lifecycle.
enum ArchiveSyncCursorPolicy {
static func requestPosition(
checkpoint: ArchiveSyncCheckpoint?,
resumeAfter: String?,
overlap: TimeInterval
) -> ArchiveSyncRequestPosition {
let after = ArchiveSyncCheckpoint.normalizedCursor(resumeAfter)
?? checkpoint?.cursor
if let after {
return ArchiveSyncRequestPosition(start: nil, after: after)
}
return ArchiveSyncRequestPosition(
start: checkpoint?.timestamp.addingTimeInterval(-overlap),
after: nil
)
}
static func resolvedCheckpoint(
previous: ArchiveSyncCheckpoint?,
cursor: String?,
highWatermark: Date?,
queryStartedAt: Date,
caughtUp: Bool
) -> ArchiveSyncCheckpoint {
let previousTimestamp = previous?.timestamp ?? .distantPast
let boundedHighWatermark = min(highWatermark ?? previousTimestamp, queryStartedAt)
let timestamp = caughtUp
? max(previousTimestamp, queryStartedAt)
: max(previousTimestamp, boundedHighWatermark)
return ArchiveSyncCheckpoint(
timestamp: timestamp,
cursor: ArchiveSyncCheckpoint.normalizedCursor(cursor) ?? previous?.cursor
)
}
static func shouldFallbackFromStoredCursor(
requestedAfter: String?,
checkpoint: ArchiveSyncCheckpoint?,
hasCompletedPage: Bool,
fallbackAlreadyUsed: Bool
) -> Bool {
guard !fallbackAlreadyUsed,
!hasCompletedPage,
let requestedAfter = ArchiveSyncCheckpoint.normalizedCursor(requestedAfter),
requestedAfter == checkpoint?.cursor else { return false }
return true
}
}
@@ -0,0 +1,33 @@
import Foundation
enum ArchiveSyncPageDecision: Equatable, Sendable {
case finished
case next(String)
case invalidCursor
}
/// Validates XEP-0313/RSM cursors so a misbehaving server cannot leave
/// history synchronization spinning forever on the same page.
struct ArchiveSyncPagination: Sendable {
private(set) var seenCursors: Set<String> = []
mutating func decision(
complete: Bool,
lastCursor: String?,
requestedAfter: String?
) -> ArchiveSyncPageDecision {
if complete { return .finished }
guard let cursor = lastCursor?.trimmingCharacters(in: .whitespacesAndNewlines),
!cursor.isEmpty else {
// An incomplete response without an RSM cursor cannot prove that
// the archive is exhausted. Advancing the checkpoint here would
// silently skip the remaining history.
return .invalidCursor
}
guard cursor != requestedAfter,
seenCursors.insert(cursor).inserted else {
return .invalidCursor
}
return .next(cursor)
}
}
@@ -0,0 +1,12 @@
import Foundation
/// Bounds whole-query MAM recovery so a server that keeps returning the same
/// broken page cannot leave the client in an endless restart loop.
enum ArchiveSyncRecoveryPolicy {
static let incrementalOverlap: TimeInterval = 60
static let interPageDelayNanoseconds: UInt64 = 100_000_000
static let queryTimeoutNanoseconds: UInt64 = 12_000_000_000
static let pageApplyTimeoutNanoseconds: UInt64 = 8_000_000_000
static let pageRetryLimit = 1
static let resumeAfterCaptureDelayNanoseconds: UInt64 = 3_000_000_000
}
@@ -0,0 +1,21 @@
import Foundation
/// Limits one foreground MAM pass. A large archive is advanced over several
/// app activations instead of monopolizing the main actor indefinitely.
struct ArchiveSyncWorkBudget: Equatable, Sendable {
static let maximumCompletedPages = 2
static let maximumRuntime: TimeInterval = 2
private(set) var completedPages = 0
let startedAt: Date
init(startedAt: Date = Date()) {
self.startedAt = startedAt
}
mutating func recordCompletedPage(at date: Date = Date()) -> Bool {
completedPages += 1
return completedPages >= Self.maximumCompletedPages
|| date.timeIntervalSince(startedAt) >= Self.maximumRuntime
}
}
@@ -0,0 +1,45 @@
import Foundation
struct AttachmentDraft: Identifiable, Hashable, Sendable {
let id: UUID
let url: URL
let filename: String
let mimeType: String
let kind: ChatMessage.Kind
let duration: TimeInterval?
let byteCount: Int
let thumbnailData: Data?
let isTemporary: Bool
init(
id: UUID = UUID(),
url: URL,
filename: String,
mimeType: String,
kind: ChatMessage.Kind,
duration: TimeInterval? = nil,
byteCount: Int,
thumbnailData: Data? = nil,
isTemporary: Bool = true
) {
self.id = id
self.url = url
self.filename = filename
self.mimeType = mimeType
self.kind = kind
self.duration = duration
self.byteCount = byteCount
self.thumbnailData = thumbnailData
self.isTemporary = isTemporary
}
}
struct AttachmentBatchResult: Sendable {
let sentCount: Int
let failedDrafts: [AttachmentDraft]
let captionSent: Bool
var isComplete: Bool {
failedDrafts.isEmpty
}
}
+114
View File
@@ -0,0 +1,114 @@
import Foundation
enum CallMedia: String, CaseIterable, Hashable, Sendable {
case audio
case video
}
enum CallDirection: String, Equatable, Sendable {
case incoming
case outgoing
}
enum CallPhase: Equatable, Sendable {
case ringing
case connecting
case connected
}
/// Stable value stored with a local call-history message.
enum CallHistoryOutcome: String, Codable, Hashable, Sendable {
case completed
case declined
case missed
case cancelled
case unanswered
case failed
case answeredElsewhere
}
struct CallHistoryMetadata: Codable, Hashable, Sendable {
let isVideo: Bool
let outcome: CallHistoryOutcome
}
/// Internal reason used to turn every call-ending path into one history result.
enum CallTerminationCause: Equatable, Sendable {
case localEnded
case localRejected
case remoteEnded
case remoteRejected
case remoteCancelled
case timedOut
case failed
case connectionDetached
case answeredElsewhere
}
struct CallHistoryEntry: Equatable, Sendable {
let id: String
let peerJID: String
let direction: CallDirection
let isVideo: Bool
let startedAt: Date
let endedAt: Date
let duration: TimeInterval?
let outcome: CallHistoryOutcome
}
/// Pure classification kept outside WebRTC so every termination scenario can
/// be tested without constructing a live peer connection.
enum CallHistoryPolicy {
static func outcome(
direction: CallDirection,
phase: CallPhase,
connectedAt: Date?,
cause: CallTerminationCause
) -> CallHistoryOutcome {
// Once media connected, a later hang-up or transport loss still belongs
// to the successful-call history and keeps its measured duration.
if connectedAt != nil || phase == .connected {
return .completed
}
switch cause {
case .localRejected, .remoteRejected:
return .declined
case .localEnded:
return .cancelled
case .remoteCancelled, .remoteEnded:
guard phase == .ringing else { return .failed }
return direction == .incoming ? .missed : .declined
case .timedOut:
guard phase == .ringing else { return .failed }
return direction == .incoming ? .missed : .unanswered
case .answeredElsewhere:
return .answeredElsewhere
case .failed, .connectionDetached:
return .failed
}
}
}
/// Value-type projection of the live WebRTC/Jingle session used by SwiftUI.
struct CallSnapshot: Identifiable, Equatable, Sendable {
let id: UUID
let peerJID: String
let direction: CallDirection
let media: Set<CallMedia>
let phase: CallPhase
let connectedAt: Date?
let isMuted: Bool
let isCameraEnabled: Bool
let isSpeakerEnabled: Bool
let hasLocalVideo: Bool
let hasRemoteVideo: Bool
var isVideoCall: Bool {
media.contains(.video)
}
var isIncomingRinging: Bool {
direction == .incoming && phase == .ringing
}
}
+319
View File
@@ -0,0 +1,319 @@
import Foundation
struct ChatMessage: Codable, Identifiable, Hashable, Sendable {
enum Direction: String, Codable, Sendable {
case incoming
case outgoing
}
enum Delivery: String, Codable, Sendable {
case sending
case sent
case delivered
case failed
}
enum Security: String, Codable, Sendable {
case omemo
case plaintext
case decryptionFailed
}
enum Kind: String, Codable, Sendable {
case text
case attachment
case photo
case video
case audio
case voice
case videoNote
case location
case system
var isMedia: Bool {
switch self {
case .attachment, .photo, .video, .audio, .voice, .videoNote:
return true
case .text, .location, .system:
return false
}
}
}
let id: String
var conversationID: String
var senderJID: String
var body: String
var timestamp: Date
var direction: Direction
var delivery: Delivery
var security: Security
var kind: Kind
var remoteAttachmentURL: String?
var localFilename: String?
var mimeType: String?
var duration: TimeInterval?
var byteCount: Int?
var encryptionFingerprint: String?
var editedAt: Date?
var replyToID: String?
var replyToJID: String?
var replyPreview: String?
var forwardedFrom: String?
var retractedAt: Date?
var stanzaID: String?
var senderDisplayName: String?
var isGroupMessage: Bool
var callHistory: CallHistoryMetadata?
var reactions: [MessageReaction]
init(
id: String = UUID().uuidString,
conversationID: String,
senderJID: String,
body: String,
timestamp: Date = Date(),
direction: Direction,
delivery: Delivery,
security: Security,
kind: Kind = .text,
remoteAttachmentURL: String? = nil,
localFilename: String? = nil,
mimeType: String? = nil,
duration: TimeInterval? = nil,
byteCount: Int? = nil,
encryptionFingerprint: String? = nil,
editedAt: Date? = nil,
replyToID: String? = nil,
replyToJID: String? = nil,
replyPreview: String? = nil,
forwardedFrom: String? = nil,
retractedAt: Date? = nil,
stanzaID: String? = nil,
senderDisplayName: String? = nil,
isGroupMessage: Bool = false,
callHistory: CallHistoryMetadata? = nil,
reactions: [MessageReaction] = []
) {
self.id = id
self.conversationID = conversationID.lowercased()
self.senderJID = Self.normalizedSenderJID(senderJID)
self.body = body
self.timestamp = timestamp
self.direction = direction
self.delivery = delivery
self.security = security
self.kind = kind
self.remoteAttachmentURL = remoteAttachmentURL
self.localFilename = localFilename
self.mimeType = mimeType
self.duration = duration
self.byteCount = byteCount
self.encryptionFingerprint = encryptionFingerprint
self.editedAt = editedAt
self.replyToID = replyToID
self.replyToJID = replyToJID.map(Self.normalizedSenderJID)
self.replyPreview = replyPreview
self.forwardedFrom = forwardedFrom
self.retractedAt = retractedAt
self.stanzaID = stanzaID
self.senderDisplayName = senderDisplayName
self.isGroupMessage = isGroupMessage
self.callHistory = callHistory
self.reactions = reactions
}
private enum CodingKeys: String, CodingKey {
case id
case conversationID
case senderJID
case body
case timestamp
case direction
case delivery
case security
case kind
case remoteAttachmentURL
case localFilename
case mimeType
case duration
case byteCount
case encryptionFingerprint
case editedAt
case replyToID
case replyToJID
case replyPreview
case forwardedFrom
case retractedAt
case stanzaID
case senderDisplayName
case isGroupMessage
case callHistory
case reactions
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
conversationID = try values.decode(String.self, forKey: .conversationID).lowercased()
senderJID = Self.normalizedSenderJID(try values.decode(String.self, forKey: .senderJID))
body = try values.decode(String.self, forKey: .body)
timestamp = try values.decode(Date.self, forKey: .timestamp)
direction = try values.decode(Direction.self, forKey: .direction)
delivery = try values.decode(Delivery.self, forKey: .delivery)
security = try values.decode(Security.self, forKey: .security)
kind = try values.decode(Kind.self, forKey: .kind)
remoteAttachmentURL = try values.decodeIfPresent(String.self, forKey: .remoteAttachmentURL)
localFilename = try values.decodeIfPresent(String.self, forKey: .localFilename)
mimeType = try values.decodeIfPresent(String.self, forKey: .mimeType)
duration = try values.decodeIfPresent(TimeInterval.self, forKey: .duration)
byteCount = try values.decodeIfPresent(Int.self, forKey: .byteCount)
encryptionFingerprint = try values.decodeIfPresent(String.self, forKey: .encryptionFingerprint)
editedAt = try values.decodeIfPresent(Date.self, forKey: .editedAt)
replyToID = try values.decodeIfPresent(String.self, forKey: .replyToID)
replyToJID = try values.decodeIfPresent(String.self, forKey: .replyToJID).map(Self.normalizedSenderJID)
replyPreview = try values.decodeIfPresent(String.self, forKey: .replyPreview)
forwardedFrom = try values.decodeIfPresent(String.self, forKey: .forwardedFrom)
retractedAt = try values.decodeIfPresent(Date.self, forKey: .retractedAt)
stanzaID = try values.decodeIfPresent(String.self, forKey: .stanzaID)
senderDisplayName = try values.decodeIfPresent(String.self, forKey: .senderDisplayName)
isGroupMessage = try values.decodeIfPresent(Bool.self, forKey: .isGroupMessage) ?? false
callHistory = try values.decodeIfPresent(CallHistoryMetadata.self, forKey: .callHistory)
reactions = try values.decodeIfPresent([MessageReaction].self, forKey: .reactions) ?? []
}
var canBeEdited: Bool {
!isRetracted
&& !isGroupMessage
&& direction == .outgoing
&& kind == .text
&& (delivery == .sent || delivery == .delivered)
}
var isRetracted: Bool {
retractedAt != nil
}
var canBeRepliedTo: Bool {
!isRetracted
&& kind != .system
&& (!isGroupMessage || stanzaID != nil)
}
var canBeForwarded: Bool {
!isRetracted && kind != .system
}
var canBeRetracted: Bool {
direction == .outgoing
&& !isGroupMessage
&& !isRetracted
&& kind != .system
&& (delivery == .sent || delivery == .delivered)
}
var replyIdentifier: String? {
isGroupMessage ? stanzaID : id
}
var quotePreview: String {
if isRetracted { return "Сообщение удалено" }
let value = kind == .text ? body : previewText
let normalized = value
.replacingOccurrences(of: "\n", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard normalized.count > 140 else { return normalized }
return String(normalized.prefix(137)) + "…"
}
var previewText: String {
if isRetracted { return "🚫 Сообщение удалено" }
if let callHistory {
let icon = callHistory.isVideo ? "🎥" : "📞"
return "\(icon) \(callTitle)"
}
switch kind {
case .attachment:
return "📎 \(localFilename ?? "Вложение")"
case .photo:
return "📷 Фото"
case .video:
return "🎬 Видео"
case .audio:
return "🎵 \(localFilename ?? "Аудио")"
case .voice:
return "🎙 Голосовое сообщение"
case .videoNote:
return "⭕️ Видеосообщение"
case .location:
return "📍 Геопозиция"
case .text, .system:
return body
}
}
var callTitle: String {
guard let callHistory else { return body }
let media = callHistory.isVideo ? "видеозвонок" : "аудиозвонок"
switch callHistory.outcome {
case .completed:
return direction == .incoming ? "Входящий \(media)" : "Исходящий \(media)"
case .declined:
return "Отклонённый \(media)"
case .missed:
return "Пропущенный \(media)"
case .cancelled:
return "Отменённый \(media)"
case .unanswered:
return "Нет ответа"
case .failed:
return "Неудачный \(media)"
case .answeredElsewhere:
return "Звонок принят на другом устройстве"
}
}
var callSubtitle: String? {
guard let callHistory else { return nil }
switch callHistory.outcome {
case .completed:
guard let duration else { return "Звонок завершён" }
return "Длительность \(Self.formattedCallDuration(duration))"
case .declined:
return direction == .incoming
? "Вы отклонили звонок"
: "Собеседник отклонил звонок"
case .missed:
return "Вы не ответили"
case .cancelled:
return direction == .outgoing
? "Вы отменили вызов"
: "Собеседник отменил вызов"
case .unanswered:
return "Собеседник не ответил"
case .failed:
return "Соединение не установлено"
case .answeredElsewhere:
return "Ответ с другого устройства"
}
}
private static func formattedCallDuration(_ value: TimeInterval) -> String {
let seconds = max(0, Int(value.rounded()))
if seconds >= 3_600 {
return String(
format: "%d:%02d:%02d",
seconds / 3_600,
(seconds % 3_600) / 60,
seconds % 60
)
}
return String(format: "%d:%02d", seconds / 60, seconds % 60)
}
private static func normalizedSenderJID(_ value: String) -> String {
guard let slash = value.firstIndex(of: "/") else { return value.lowercased() }
let bare = value[..<slash].lowercased()
return bare + String(value[slash...])
}
}
@@ -0,0 +1,20 @@
import CoreGraphics
enum ChatScrollPositionPolicy {
static let nearBottomThreshold: CGFloat = 96
static func isNearBottom(
bottomY: CGFloat,
viewportHeight: CGFloat
) -> Bool {
guard bottomY.isFinite, viewportHeight.isFinite, viewportHeight > 0 else {
return true
}
return bottomY <= viewportHeight + nearBottomThreshold
}
static func isNearBottom(distanceFromBottom: CGFloat) -> Bool {
guard distanceFromBottom.isFinite else { return true }
return distanceFromBottom <= nearBottomThreshold
}
}
@@ -0,0 +1,28 @@
import Foundation
/// A cached presentation row for the selected chat. Building day boundaries
/// once per message mutation avoids walking the full history during every
/// scroll-position or composer-state update.
struct ChatTimelineEntry: Identifiable, Hashable, Sendable {
let message: ChatMessage
let startsNewDay: Bool
var id: String { message.id }
static func make(
from messages: [ChatMessage],
calendar: Calendar = .autoupdatingCurrent
) -> [ChatTimelineEntry] {
var previousTimestamp: Date?
return messages.map { message in
let startsNewDay = previousTimestamp.map {
!calendar.isDate($0, inSameDayAs: message.timestamp)
} ?? true
previousTimestamp = message.timestamp
return ChatTimelineEntry(
message: message,
startsNewDay: startsNewDay
)
}
}
}
@@ -0,0 +1,29 @@
import Foundation
enum ChatTypingState: String, Sendable {
case active
case composing
case paused
case inactive
case gone
}
enum ChatTypingPolicy {
static let pauseDelayNanoseconds: UInt64 = 5_000_000_000
static let remoteExpiryNanoseconds: UInt64 = 8_000_000_000
static func displayText(names: [String], isGroup: Bool) -> String? {
let uniqueNames = Array(Set(names.filter { !$0.isEmpty })).sorted()
guard !uniqueNames.isEmpty else { return nil }
guard isGroup else { return "печатает…" }
switch uniqueNames.count {
case 1:
return "\(uniqueNames[0]) печатает…"
case 2:
return "\(uniqueNames[0]) и \(uniqueNames[1]) печатают…"
default:
return "Несколько участников печатают…"
}
}
}
+122
View File
@@ -0,0 +1,122 @@
import Foundation
struct Conversation: Codable, Identifiable, Hashable, Sendable {
enum Kind: String, Codable, Sendable {
case direct
case group
}
let id: String
var jid: String
var displayName: String
var lastMessage: String
var lastActivity: Date
var unreadCount: Int
var isOnline: Bool
var isPinned: Bool
var encryptionPreference: EncryptionPreference
var kind: Kind
var groupNickname: String?
var isGroupJoined: Bool
var shouldAutojoin: Bool
var occupantCount: Int
var invitedBy: String?
init(
jid: String,
displayName: String? = nil,
lastMessage: String = "",
lastActivity: Date = .distantPast,
unreadCount: Int = 0,
isOnline: Bool = false,
isPinned: Bool = false,
encryptionPreference: EncryptionPreference = .inheritGlobal,
kind: Kind = .direct,
groupNickname: String? = nil,
isGroupJoined: Bool = false,
shouldAutojoin: Bool = false,
occupantCount: Int = 0,
invitedBy: String? = nil
) {
let normalized = jid.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
self.id = normalized
self.jid = normalized
self.displayName = displayName?.nilIfBlank ?? normalized
self.lastMessage = lastMessage
self.lastActivity = lastActivity
self.unreadCount = unreadCount
self.isOnline = isOnline
self.isPinned = isPinned
self.encryptionPreference = encryptionPreference
self.kind = kind
self.groupNickname = groupNickname?.nilIfBlank
self.isGroupJoined = isGroupJoined
self.shouldAutojoin = shouldAutojoin
self.occupantCount = max(0, occupantCount)
self.invitedBy = invitedBy?.nilIfBlank
}
private enum CodingKeys: String, CodingKey {
case id
case jid
case displayName
case lastMessage
case lastActivity
case unreadCount
case isOnline
case isPinned
case encryptionPreference
case kind
case groupNickname
case isGroupJoined
case shouldAutojoin
case occupantCount
case invitedBy
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
jid = try values.decode(String.self, forKey: .jid)
id = try values.decodeIfPresent(String.self, forKey: .id) ?? jid.lowercased()
displayName = try values.decode(String.self, forKey: .displayName)
lastMessage = try values.decode(String.self, forKey: .lastMessage)
lastActivity = try values.decode(Date.self, forKey: .lastActivity)
unreadCount = try values.decode(Int.self, forKey: .unreadCount)
isOnline = try values.decode(Bool.self, forKey: .isOnline)
isPinned = try values.decode(Bool.self, forKey: .isPinned)
encryptionPreference = try values.decodeIfPresent(
EncryptionPreference.self,
forKey: .encryptionPreference
) ?? .inheritGlobal
kind = try values.decodeIfPresent(Kind.self, forKey: .kind) ?? .direct
groupNickname = try values.decodeIfPresent(String.self, forKey: .groupNickname)
isGroupJoined = try values.decodeIfPresent(Bool.self, forKey: .isGroupJoined) ?? false
shouldAutojoin = try values.decodeIfPresent(Bool.self, forKey: .shouldAutojoin) ?? false
occupantCount = max(0, try values.decodeIfPresent(Int.self, forKey: .occupantCount) ?? 0)
invitedBy = try values.decodeIfPresent(String.self, forKey: .invitedBy)
}
var isGroup: Bool { kind == .group }
var initials: String {
let source = displayName == jid ? (jid.split(separator: "@").first.map(String.init) ?? jid) : displayName
let words = source.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" })
let letters = words.prefix(2).compactMap(\.first)
if letters.isEmpty {
return String(source.prefix(1)).uppercased()
}
return String(letters).uppercased()
}
var colorSeed: UInt64 {
jid.utf8.reduce(14_695_981_039_346_656_037) { partial, byte in
(partial ^ UInt64(byte)) &* 1_099_511_628_211
}
}
}
private extension String {
var nilIfBlank: String? {
trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self
}
}
@@ -0,0 +1,29 @@
import Foundation
enum EncryptionPreference: String, Codable, CaseIterable, Equatable, Hashable, Sendable {
case inheritGlobal
case enabled
case disabled
func resolved(globalEnabled: Bool) -> Bool {
switch self {
case .inheritGlobal:
return globalEnabled
case .enabled:
return true
case .disabled:
return false
}
}
var title: String {
switch self {
case .inheritGlobal:
return "Как в настройках"
case .enabled:
return "Всегда включено"
case .disabled:
return "Выключено для чата"
}
}
}
+61
View File
@@ -0,0 +1,61 @@
import Foundation
struct GeoLocation: Equatable, Sendable {
let latitude: Double
let longitude: Double
let uncertainty: Double?
init?(latitude: Double, longitude: Double, uncertainty: Double? = nil) {
guard latitude.isFinite,
longitude.isFinite,
(-90...90).contains(latitude),
(-180...180).contains(longitude) else { return nil }
if let uncertainty, (!uncertainty.isFinite || uncertainty < 0) {
return nil
}
self.latitude = latitude
self.longitude = longitude
self.uncertainty = uncertainty
}
init?(uri: String) {
let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.lowercased().hasPrefix("geo:") else { return nil }
let payload = trimmed.dropFirst(4)
let components = payload.split(separator: ";", omittingEmptySubsequences: false)
guard let coordinatePart = components.first else { return nil }
let coordinates = coordinatePart.split(separator: ",", omittingEmptySubsequences: false)
guard coordinates.count == 2 || coordinates.count == 3,
let latitude = Double(coordinates[0]),
let longitude = Double(coordinates[1]) else { return nil }
if coordinates.count == 3, Double(coordinates[2]) == nil {
return nil
}
var uncertainty: Double?
for component in components.dropFirst() {
let pair = component.split(separator: "=", maxSplits: 1)
guard pair.count == 2 else { continue }
if pair[0].lowercased() == "u" {
guard let value = Double(pair[1]) else { return nil }
uncertainty = value
}
}
self.init(latitude: latitude, longitude: longitude, uncertainty: uncertainty)
}
var uriString: String {
let coordinates = String(
format: "geo:%.6f,%.6f",
locale: Locale(identifier: "en_US_POSIX"),
latitude,
longitude
)
guard let uncertainty else { return coordinates }
return coordinates + String(
format: ";u=%.0f",
locale: Locale(identifier: "en_US_POSIX"),
uncertainty
)
}
}
+76
View File
@@ -0,0 +1,76 @@
import Foundation
import UniformTypeIdentifiers
enum MediaMetadata {
struct Inferred: Equatable, Sendable {
let kind: ChatMessage.Kind
let mimeType: String?
let duration: TimeInterval?
}
static func transportFilename(
originalFilename: String,
kind: ChatMessage.Kind,
duration: TimeInterval?
) -> String {
let milliseconds = duration.flatMap { value -> Int? in
guard value.isFinite,
value > 0,
value <= Double(Int.max) / 1_000 else { return nil }
return Int((value * 1_000).rounded())
}
guard let milliseconds else { return originalFilename }
switch kind {
case .voice:
return "luma-voice-\(milliseconds)-\(originalFilename)"
case .videoNote:
return "luma-videonote-\(milliseconds)-\(originalFilename)"
default:
return originalFilename
}
}
static func infer(from transportFilename: String) -> Inferred {
let contentType = UTType(
filenameExtension: URL(fileURLWithPath: transportFilename).pathExtension
)
let kind: ChatMessage.Kind
if transportFilename.hasPrefix("luma-voice-") {
kind = .voice
} else if transportFilename.hasPrefix("luma-videonote-") {
kind = .videoNote
} else if contentType?.conforms(to: .image) == true {
kind = .photo
} else if contentType?.conforms(to: .movie) == true {
kind = .video
} else if contentType?.conforms(to: .audio) == true {
kind = .audio
} else {
kind = .attachment
}
return Inferred(
kind: kind,
mimeType: contentType?.preferredMIMEType,
duration: encodedDuration(from: transportFilename)
)
}
private static func encodedDuration(from filename: String) -> TimeInterval? {
let prefix: String
if filename.hasPrefix("luma-voice-") {
prefix = "luma-voice-"
} else if filename.hasPrefix("luma-videonote-") {
prefix = "luma-videonote-"
} else {
return nil
}
let remainder = filename.dropFirst(prefix.count)
guard let separator = remainder.firstIndex(of: "-"),
let milliseconds = Int(remainder[..<separator]),
milliseconds > 0 else { return nil }
return TimeInterval(milliseconds) / 1_000
}
}
@@ -0,0 +1,18 @@
enum MediaPickerRepresentation: Equatable, Sendable {
case photo
case video
}
enum MediaPickerSelectionPolicy {
static func preferredOrder(
supportsImage: Bool,
supportsVideo: Bool
) -> [MediaPickerRepresentation] {
// Live Photos can expose both types. Their still image is the most
// predictable cross-client representation; a video-only asset keeps
// movie first. The second entry is an explicit provider fallback.
supportsVideo && !supportsImage
? [.video, .photo]
: [.photo, .video]
}
}
@@ -0,0 +1,25 @@
import Foundation
/// Tracks overlapping media operations without letting an older operation
/// restore a stale busy state after a newer one has already finished.
struct MediaSendActivityTracker: Sendable {
private(set) var activeTokens: Set<UUID> = []
var isActive: Bool {
!activeTokens.isEmpty
}
mutating func begin() -> UUID {
let token = UUID()
activeTokens.insert(token)
return token
}
mutating func end(_ token: UUID) {
activeTokens.remove(token)
}
mutating func reset() {
activeTokens.removeAll()
}
}
@@ -0,0 +1,8 @@
import Foundation
struct MediaViewerItem: Identifiable, Equatable, Sendable {
let id: String
let url: URL
let kind: ChatMessage.Kind
let title: String
}
+103
View File
@@ -0,0 +1,103 @@
import Foundation
struct MessageReaction: Codable, Identifiable, Hashable, Sendable {
let senderJID: String
var emojis: [String]
var updatedAt: Date
var id: String { senderJID }
init(senderJID: String, emojis: [String], updatedAt: Date = Date()) {
self.senderJID = Self.normalizedJID(senderJID)
self.emojis = MessageReactionPolicy.sanitized(emojis)
self.updatedAt = updatedAt
}
private static func normalizedJID(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
}
struct MessageReactionSummary: Identifiable, Hashable, Sendable {
let emoji: String
let count: Int
let includesOwnReaction: Bool
var id: String { emoji }
}
enum MessageReactionPolicy {
static let quickChoices = ["👍", "❤️", "😂", "😮", "😢", "🔥"]
static func sanitized(_ values: [String]) -> [String] {
var seen: Set<String> = []
return values.compactMap { rawValue in
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard value.count == 1,
value.utf8.count <= 64,
value.unicodeScalars.contains(where: { $0.properties.isEmoji }),
seen.insert(value).inserted else { return nil }
return value
}
}
static func summaries(
reactions: [MessageReaction],
ownJID: String?
) -> [MessageReactionSummary] {
let normalizedOwnJID = ownJID.map(bareJID)
var counts: [String: Int] = [:]
var ownValues: Set<String> = []
for reaction in reactions {
let values = sanitized(reaction.emojis)
for emoji in values {
counts[emoji, default: 0] += 1
}
if normalizedOwnJID == bareJID(reaction.senderJID) {
ownValues.formUnion(values)
}
}
return counts.keys.sorted().map { emoji in
MessageReactionSummary(
emoji: emoji,
count: counts[emoji, default: 0],
includesOwnReaction: ownValues.contains(emoji)
)
}
}
static func bareJID(_ value: String) -> String {
value
.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: true)
.first
.map(String.init)?
.lowercased()
?? value.lowercased()
}
}
extension ChatMessage {
var reactionIdentifier: String? {
isGroupMessage ? stanzaID : id
}
var canBeReactedTo: Bool {
guard !isRetracted,
kind != .system,
direction == .incoming || delivery != .sending else { return false }
return !isGroupMessage || stanzaID != nil
}
func reactionSummaries(ownJID: String?) -> [MessageReactionSummary] {
MessageReactionPolicy.summaries(reactions: reactions, ownJID: ownJID)
}
func reactionEmojis(from senderJID: String) -> [String] {
let sender = MessageReactionPolicy.bareJID(senderJID)
return reactions.first {
MessageReactionPolicy.bareJID($0.senderJID) == sender
}?.emojis ?? []
}
}
@@ -0,0 +1,107 @@
import Foundation
/// Builds and removes the plain-text quote used when a peer does not render
/// XEP-0461 replies. XEP-0428 offsets are Unicode scalar offsets, not UTF-16.
enum MessageReplyFallback {
struct Parsed: Equatable, Sendable {
let body: String
let preview: String?
}
static func make(author: String, preview: String) -> (prefix: String, scalarCount: Int) {
let safeAuthor = author
.replacingOccurrences(of: "\n", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
let quoteLines = preview
.trimmingCharacters(in: .whitespacesAndNewlines)
.split(separator: "\n", omittingEmptySubsequences: false)
.prefix(8)
.map { "> \($0)" }
let prefix = (["> \(safeAuthor) написал(а):"] + quoteLines).joined(separator: "\n") + "\n\n"
return (prefix, prefix.unicodeScalars.count)
}
static func parse(
body: String,
fallbackStart: Int?,
fallbackEnd: Int?
) -> Parsed {
if let fallbackStart,
let fallbackEnd,
let fallback = scalarSubstring(body, start: fallbackStart, end: fallbackEnd),
let stripped = removingScalarRange(body, start: fallbackStart, end: fallbackEnd) {
return Parsed(
body: stripped.trimmingCharacters(in: .whitespacesAndNewlines),
preview: quotePreview(from: fallback)
)
}
return parseLegacyQuote(body) ?? Parsed(body: body, preview: nil)
}
static func parseLegacyQuote(_ body: String) -> Parsed? {
let lines = body.components(separatedBy: .newlines)
guard let first = lines.first,
first.trimmingCharacters(in: .whitespaces).hasPrefix(">") else { return nil }
var quoteLines: [String] = []
var bodyStart = 0
for (index, line) in lines.enumerated() {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix(">") {
quoteLines.append(String(trimmed.dropFirst()).trimmingCharacters(in: .whitespaces))
bodyStart = index + 1
continue
}
if trimmed.isEmpty, !quoteLines.isEmpty {
bodyStart = index + 1
continue
}
break
}
guard !quoteLines.isEmpty, bodyStart < lines.count else { return nil }
let remaining = lines[bodyStart...]
.joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !remaining.isEmpty else { return nil }
return Parsed(body: remaining, preview: normalizedPreview(quoteLines))
}
private static func quotePreview(from fallback: String) -> String? {
let lines = fallback.components(separatedBy: .newlines).compactMap { line -> String? in
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard trimmed.hasPrefix(">") else { return nil }
return String(trimmed.dropFirst()).trimmingCharacters(in: .whitespaces)
}
return normalizedPreview(lines)
}
private static func normalizedPreview(_ lines: [String]) -> String? {
var values = lines.filter { !$0.isEmpty }
if values.count > 1,
let first = values.first?.lowercased(),
first.hasSuffix(":") || first.contains(" wrote") || first.contains("написал") {
values.removeFirst()
}
let value = values.joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return nil }
return value.count > 280 ? String(value.prefix(277)) + "…" : value
}
private static func scalarSubstring(_ value: String, start: Int, end: Int) -> String? {
let scalars = value.unicodeScalars
guard start >= 0, end >= start, end <= scalars.count else { return nil }
let lower = scalars.index(scalars.startIndex, offsetBy: start)
let upper = scalars.index(scalars.startIndex, offsetBy: end)
return String(value[lower..<upper])
}
private static func removingScalarRange(_ value: String, start: Int, end: Int) -> String? {
let scalars = value.unicodeScalars
guard start >= 0, end >= start, end <= scalars.count else { return nil }
let lower = scalars.index(scalars.startIndex, offsetBy: start)
let upper = scalars.index(scalars.startIndex, offsetBy: end)
return String(value[..<lower]) + String(value[upper...])
}
}
@@ -0,0 +1,31 @@
import CoreGraphics
struct MessageReplySwipePolicy {
static let triggerDistance: CGFloat = 64
static let maximumOffset: CGFloat = 76
static let horizontalDominance: CGFloat = 1.4
static func offset(for translation: CGSize) -> CGFloat {
guard isHorizontal(translation) else { return 0 }
let distance = min(abs(translation.width) * 0.72, maximumOffset)
return translation.width < 0 ? -distance : distance
}
static func shouldReply(
translation: CGSize,
predictedEndTranslation: CGSize
) -> Bool {
let candidate = abs(predictedEndTranslation.width) > abs(translation.width)
? predictedEndTranslation
: translation
return isHorizontal(candidate) && abs(candidate.width) >= triggerDistance
}
static func progress(for offset: CGFloat) -> CGFloat {
min(1, abs(offset) / triggerDistance)
}
private static func isHorizontal(_ translation: CGSize) -> Bool {
abs(translation.width) > abs(translation.height) * horizontalDominance
}
}
@@ -0,0 +1,15 @@
import Foundation
enum NotificationPolicy {
static func shouldPresentMessage(
inserted: Bool,
isOutgoing: Bool,
appIsActive: Bool,
selectedConversationID: String?,
conversationID: String
) -> Bool {
guard inserted, !isOutgoing else { return false }
guard appIsActive else { return true }
return selectedConversationID?.lowercased() != conversationID.lowercased()
}
}
+38
View File
@@ -0,0 +1,38 @@
import Foundation
struct OMEMODevice: Identifiable, Hashable, Sendable {
enum Trust: String, Sendable {
case undecided
case trusted
case verified
case compromised
}
let jid: String
let deviceID: Int32
let fingerprint: String
let trust: Trust
let isActive: Bool
let isOwn: Bool
var id: String { "\(jid)|\(deviceID)" }
var formattedFingerprint: String {
fingerprint.chunked(every: 8).joined(separator: " ")
}
}
private extension String {
func chunked(every size: Int) -> [String] {
guard size > 0 else { return [self] }
var chunks: [String] = []
var start = startIndex
while start < endIndex {
let end = index(start, offsetBy: size, limitedBy: endIndex) ?? endIndex
chunks.append(String(self[start..<end]))
start = end
}
return chunks
}
}
@@ -0,0 +1,44 @@
import AVFoundation
import Foundation
/// AVCaptureMovieFileOutput may finish with a non-nil error while explicitly
/// reporting that the movie file itself was completed successfully.
enum VideoNoteRecordingCompletionPolicy {
static func shouldKeepOutput(for error: Error?) -> Bool {
guard let error else { return true }
let value = error as NSError
if value.domain == AVFoundationErrorDomain,
value.code == AVError.Code.maximumDurationReached.rawValue {
return true
}
if let finished = value.userInfo[AVErrorRecordingSuccessfullyFinishedKey] as? Bool {
return finished
}
return (value.userInfo[AVErrorRecordingSuccessfullyFinishedKey] as? NSNumber)?.boolValue
== true
}
/// A camera interruption may still leave a playable partial movie. That
/// file is useful for recovery, but it must not be sent as though the user
/// released the record button. The 60-second capture limit is the only
/// completion that is expected without an explicit stop request.
static func wasRequestedOrReachedLimit(
stopRequested: Bool,
error: Error?,
recordedDuration: TimeInterval,
maximumDuration: TimeInterval
) -> Bool {
if stopRequested { return true }
if let error,
(error as NSError).domain == AVFoundationErrorDomain,
(error as NSError).code == AVError.Code.maximumDurationReached.rawValue {
return true
}
guard recordedDuration.isFinite,
maximumDuration.isFinite,
maximumDuration > 0 else { return false }
return recordedDuration >= maximumDuration - 0.25
}
}
@@ -0,0 +1,38 @@
import Foundation
enum VideoNoteRecordingLifecycle: Equatable, Sendable {
case idle
case prepared
case starting
case recording
case stopping
var isBusy: Bool {
switch self {
case .starting, .recording, .stopping:
return true
case .idle, .prepared:
return false
}
}
static func acceptsCompletion(activeURL: URL?, outputURL: URL) -> Bool {
guard let activeURL else { return false }
return normalizedRecordingPath(activeURL) == normalizedRecordingPath(outputURL)
}
/// AVFoundation can return a temporary file through its canonical macOS
/// path (`/private/var/...`) even when the caller supplied the equivalent
/// user-facing path (`/var/...`). Treat those aliases as one recording so
/// a valid delegate callback is not mistaken for a stale attempt.
private static func normalizedRecordingPath(_ url: URL) -> String {
var path = url.standardizedFileURL.resolvingSymlinksInPath().path
for privateAlias in ["/private/var", "/private/tmp"] {
if path == privateAlias || path.hasPrefix(privateAlias + "/") {
path.removeFirst("/private".count)
break
}
}
return path
}
}