imsg/Sources/IMsgCore/MessageStore+ReactionEvents.swift
2026-05-05 03:38:10 +01:00

141 lines
4.9 KiB
Swift

import Foundation
import SQLite
/// A reaction event represents when someone adds or removes a reaction to a message.
/// Unlike `Reaction` which represents the current state, this captures the event itself.
public struct ReactionEvent: Sendable, Equatable {
/// The ROWID of the reaction message in the database
public let rowID: Int64
/// The chat ID where the reaction occurred
public let chatID: Int64
/// The type of reaction
public let reactionType: ReactionType
/// Whether this is adding (true) or removing (false) a reaction
public let isAdd: Bool
/// The sender of the reaction (phone number or email)
public let sender: String
/// Whether the reaction was sent by the current user
public let isFromMe: Bool
/// When the reaction event occurred
public let date: Date
/// The GUID of the message being reacted to
public let reactedToGUID: String
/// The ROWID of the message being reacted to (if available)
public let reactedToID: Int64?
/// The original text of the reaction message (e.g., "Liked \"hello\"")
public let text: String
public init(
rowID: Int64,
chatID: Int64,
reactionType: ReactionType,
isAdd: Bool,
sender: String,
isFromMe: Bool,
date: Date,
reactedToGUID: String,
reactedToID: Int64?,
text: String
) {
self.rowID = rowID
self.chatID = chatID
self.reactionType = reactionType
self.isAdd = isAdd
self.sender = sender
self.isFromMe = isFromMe
self.date = date
self.reactedToGUID = reactedToGUID
self.reactedToID = reactedToID
self.text = text
}
}
extension MessageStore {
/// Fetch reaction events (add/remove) after a given rowID.
/// These are the reaction messages themselves, useful for streaming reaction events in watch mode.
public func reactionEventsAfter(afterRowID: Int64, chatID: Int64?, limit: Int) throws
-> [ReactionEvent]
{
guard schema.hasReactionColumns else { return [] }
let bodyColumn = schema.hasAttributedBody ? "m.attributedBody" : "NULL"
let destinationCallerColumn =
schema.hasDestinationCallerID ? "m.destination_caller_id" : "NULL"
var sql = """
SELECT m.ROWID AS reaction_rowid, cmj.chat_id AS chat_id,
m.associated_message_type AS associated_message_type,
m.associated_message_guid AS associated_message_guid,
m.handle_id AS handle_id, h.id AS sender, m.is_from_me AS is_from_me,
m.date AS date, IFNULL(m.text, '') AS text,
\(destinationCallerColumn) AS destination_caller_id,
\(bodyColumn) AS body,
orig.ROWID AS orig_rowid
FROM message m
LEFT JOIN chat_message_join cmj ON m.ROWID = cmj.message_id
LEFT JOIN handle h ON m.handle_id = h.ROWID
LEFT JOIN message orig ON (orig.guid = m.associated_message_guid
OR m.associated_message_guid LIKE '%/' || orig.guid)
WHERE m.ROWID > ?
AND m.associated_message_type >= 2000
AND m.associated_message_type <= 3006
"""
var bindings: [Binding?] = [afterRowID]
if let chatID {
sql += " AND cmj.chat_id = ?"
bindings.append(chatID)
}
sql += " ORDER BY m.ROWID ASC LIMIT ?"
bindings.append(limit)
return try withConnection { db in
var events: [ReactionEvent] = []
let rows = try db.prepareRowIterator(sql, bindings: bindings)
while let row = try rows.failableNext() {
let rowID = try int64Value(row, "reaction_rowid") ?? 0
let resolvedChatID = try int64Value(row, "chat_id") ?? chatID ?? 0
let typeValue = try intValue(row, "associated_message_type") ?? 0
let associatedGUID = try stringValue(row, "associated_message_guid")
var sender = try stringValue(row, "sender")
let isFromMe = try boolValue(row, "is_from_me")
let date = try appleDate(from: int64Value(row, "date"))
let text = try stringValue(row, "text")
let destinationCallerID = try stringValue(row, "destination_caller_id")
let body = try dataValue(row, "body")
let origRowID = try int64Value(row, "orig_rowid")
if sender.isEmpty && !destinationCallerID.isEmpty {
sender = destinationCallerID
}
let resolvedText = text.isEmpty ? TypedStreamParser.parseAttributedBody(body) : text
let decoded = decodeReaction(
associatedType: typeValue,
associatedGUID: associatedGUID,
text: resolvedText
)
guard let reactionType = decoded.reactionType, let isAdd = decoded.isReactionAdd else {
continue
}
events.append(
ReactionEvent(
rowID: rowID,
chatID: resolvedChatID,
reactionType: reactionType,
isAdd: isAdd,
sender: sender,
isFromMe: isFromMe,
date: date,
reactedToGUID: decoded.reactedToGUID ?? "",
reactedToID: origRowID,
text: resolvedText
))
}
return events
}
}
}