325 lines
11 KiB
Swift
325 lines
11 KiB
Swift
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 originID: String?
|
|
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,
|
|
originID: String? = 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.originID = originID ?? (direction == .outgoing ? id : nil)
|
|
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 originID
|
|
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)
|
|
originID = try values.decodeIfPresent(String.self, forKey: .originID)
|
|
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...])
|
|
}
|
|
}
|