0.10.6
This commit is contained in:
commit
92660a4ba0
228 files changed
+24496
No files matched your search
@@ -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...])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user