diff --git a/snikket/Chat.hx b/snikket/Chat.hx index a8a9da7..fc369b4 100644 --- a/snikket/Chat.hx +++ b/snikket/Chat.hx @@ -135,6 +135,8 @@ abstract class Chat { })); case ReactionUpdateStanza(update): persistence.storeReaction(client.accountId(), update, (m)->{}); + case ModerateMessageStanza(action): + client.moderateMessage(action); default: // ignore } @@ -1030,6 +1032,10 @@ class Channel extends Chat { promises.push(new thenshim.Promise((resolve, reject) -> { persistence.storeReaction(client.accountId(), update, (_) -> resolve(null)); })); + case ModerateMessageStanza(action): + promises.push(new thenshim.Promise((resolve, reject) -> { + client.moderateMessage(action).then((_) -> resolve(null)); + })); default: // ignore } diff --git a/snikket/ChatMessage.hx b/snikket/ChatMessage.hx index adebd57..a011837 100644 --- a/snikket/ChatMessage.hx +++ b/snikket/ChatMessage.hx @@ -191,6 +191,22 @@ class ChatMessage { return type == MessageChannel || type == MessageChannelPrivate ? serverId : localId; } + @:allow(snikket) + private function makeModerated(timestamp: String, moderatorId: Null, reason: Null) { + text = null; + attachments = []; + payloads = []; + versions = []; + final cleanedStub = clone(); + final payload = new Stanza("retracted", { xmlns: "urn:xmpp:message-retract:1", stamp: timestamp }); + if (reason != null) payload.textTag("reason", reason); + payload.tag("moderated", { by: moderatorId, xmlns: "urn:xmpp:message-moderate:1" }).up(); + payloads.push(payload); + final head = clone(); + head.timestamp = timestamp; + versions = [head, cleanedStub]; + } + private function set_localId(localId:Null) { if(this.localId != null) { throw new Exception("Message already has a localId set"); diff --git a/snikket/Client.hx b/snikket/Client.hx index 17a74e1..c6d205f 100644 --- a/snikket/Client.hx +++ b/snikket/Client.hx @@ -190,6 +190,8 @@ class Client extends EventEmitter { fetchMediaByHash([hash], [from]); } persistence.storeReaction(accountId(), update, (stored) -> if (stored != null) notifyMessageHandlers(stored, ReactionEvent)); + case ModerateMessageStanza(action): + moderateMessage(action).then((stored) -> if (stored != null) notifyMessageHandlers(stored, CorrectionEvent)); default: // ignore } @@ -900,6 +902,23 @@ class Client extends EventEmitter { return chats.find((chat) -> chat.chatId == chatId); } + @:allow(snikket) + private function moderateMessage(action: ModerationAction): Promise> { + return new thenshim.Promise((resolve, reject) -> + persistence.getMessage(accountId(), action.chatId, action.moderateServerId, null, (moderateMessage) -> { + if (moderateMessage == null) return resolve(null); + for(attachment in moderateMessage.attachments) { + for(hash in attachment.hashes) { + persistence.removeMedia(hash.algorithm, hash.hash); + } + } + moderateMessage.makeModerated(action.timestamp, action.moderatorId, action.reason); + persistence.updateMessage(accountId(), moderateMessage); + resolve(moderateMessage); + }) + ); + } + @:allow(snikket) private function getDirectChat(chatId:String, triggerIfNew:Bool = true):DirectChat { for (chat in chats) { @@ -1417,6 +1436,10 @@ class Client extends EventEmitter { promises.push(new thenshim.Promise((resolve, reject) -> { persistence.storeReaction(accountId(), update, (_) -> resolve(null)); })); + case ModerateMessageStanza(action): + promises.push(new thenshim.Promise((resolve, reject) -> { + moderateMessage(action).then((_) -> resolve(null)); + })); default: // ignore } diff --git a/snikket/Message.hx b/snikket/Message.hx index 4e24647..fe1a9ea 100644 --- a/snikket/Message.hx +++ b/snikket/Message.hx @@ -26,6 +26,7 @@ enum abstract MessageType(Int) { enum MessageStanza { ErrorMessageStanza(stanza: Stanza); ChatMessageStanza(message: ChatMessage); + ModerateMessageStanza(action: ModerationAction); ReactionUpdateStanza(update: ReactionUpdate); UnknownMessageStanza(stanza: Stanza); } @@ -198,7 +199,30 @@ class Message { Reflect.setField(msg, "localId", jmi.attr.get("id")); } - if (msg.text == null && msg.attachments.length < 1) return new Message(msg.chatId(), msg.senderId(), msg.threadId, UnknownMessageStanza(stanza)); + final retract = stanza.getChild("replace", "urn:xmpp:message-retract:1"); + final fasten = stanza.getChild("apply-to", "urn:xmpp:fasten:0"); + final moderated = retract?.getChild("moderated", "urn:xmpp:message-retract:1") ?? fasten?.getChild("moderated", "urn:xmpp:message-moderate:0"); + final moderateServerId = retract?.attr?.get("id") ?? fasten?.attr?.get("id"); + if (moderated != null && moderateServerId != null && isGroupchat && msg.from != null && msg.from.isBare() && msg.from.asString() == msg.chatId()) { + final reason = retract?.getChildText("reason") ?? moderated?.getChildText("reason"); + final by = moderated.attr.get("by"); + // TODO: occupant id as well / instead of by? + return new Message( + msg.chatId(), + msg.senderId(), + msg.threadId, + ModerateMessageStanza(new ModerationAction(msg.chatId(), moderateServerId, timestamp, by, reason)) + ); + } + + final replace = stanza.getChild("replace", "urn:xmpp:message-correct:0"); + final replaceId = replace?.attr?.get("id"); + if (replaceId != null) { + msg.versions = [msg.clone()]; + Reflect.setField(msg, "localId", replaceId); + } + + if (msg.text == null && msg.attachments.length < 1 && msg.versions.length < 1) return new Message(msg.chatId(), msg.senderId(), msg.threadId, UnknownMessageStanza(stanza)); for (fallback in stanza.allTags("fallback", "urn:xmpp:fallback:0")) { msg.payloads.push(fallback); @@ -271,13 +295,6 @@ class Message { } } - final replace = stanza.getChild("replace", "urn:xmpp:message-correct:0"); - final replaceId = replace?.attr?.get("id"); - if (replaceId != null) { - msg.versions = [msg.clone()]; - Reflect.setField(msg, "localId", replaceId); - } - return new Message(msg.chatId(), msg.senderId(), msg.threadId, ChatMessageStanza(msg)); } } diff --git a/snikket/ModerationAction.hx b/snikket/ModerationAction.hx new file mode 100644 index 0000000..5c5e89f --- /dev/null +++ b/snikket/ModerationAction.hx @@ -0,0 +1,17 @@ +package snikket; + +class ModerationAction { + public final chatId: String; + public final moderateServerId: String; + public final timestamp: String; + public final moderatorId: Null; + public final reason: Null; + + public function new(chatId: String, moderateServerId: String, timestamp: String, moderatorId: Null, reason: Null) { + this.chatId = chatId; + this.moderateServerId = moderateServerId; + this.timestamp = timestamp; + this.moderatorId = moderatorId; + this.reason = reason; + } +} diff --git a/snikket/Persistence.hx b/snikket/Persistence.hx index bd07317..f63ea00 100644 --- a/snikket/Persistence.hx +++ b/snikket/Persistence.hx @@ -16,12 +16,15 @@ interface Persistence { public function getChatsUnreadDetails(accountId: String, chats: Array, callback: (details:Array<{ chatId: String, message: ChatMessage, unreadCount: Int }>)->Void):Void; public function storeReaction(accountId: String, update: ReactionUpdate, callback: (Null)->Void):Void; public function storeMessage(accountId: String, message: ChatMessage, callback: (ChatMessage)->Void):Void; + public function updateMessage(accountId: String, message: ChatMessage):Void; public function updateMessageStatus(accountId: String, localId: String, status:MessageStatus, callback: (ChatMessage)->Void):Void; + public function getMessage(accountId: String, chatId: String, serverId: Null, localId: Null, callback: (Null)->Void):Void; public function getMessagesBefore(accountId: String, chatId: String, beforeId: Null, beforeTime: Null, callback: (messages:Array)->Void):Void; public function getMessagesAfter(accountId: String, chatId: String, afterId: Null, afterTime: Null, callback: (messages:Array)->Void):Void; public function getMessagesAround(accountId: String, chatId: String, aroundId: Null, aroundTime: Null, callback: (messages:Array)->Void):Void; public function hasMedia(hashAlgorithm:String, hash:BytesData, callback: (has:Bool)->Void):Void; public function storeMedia(mime:String, bytes:BytesData, callback: ()->Void):Void; + public function removeMedia(hashAlgorithm:String, hash:BytesData):Void; public function storeCaps(caps:Caps):Void; public function getCaps(ver:String, callback: (Null)->Void):Void; public function storeLogin(login:String, clientId:String, displayName:String, token:Null):Void; diff --git a/snikket/persistence/Dummy.hx b/snikket/persistence/Dummy.hx index bfb178b..17e8093 100644 --- a/snikket/persistence/Dummy.hx +++ b/snikket/persistence/Dummy.hx @@ -41,6 +41,15 @@ class Dummy implements Persistence { callback(message); } + @HaxeCBridge.noemit + public function updateMessage(accountId: String, message: ChatMessage) { + } + + @HaxeCBridge.noemit + public function getMessage(accountId: String, chatId: String, serverId: Null, localId: Null, callback: (Null)->Void) { + callback(null); + } + @HaxeCBridge.noemit public function getMessagesBefore(accountId: String, chatId: String, beforeId: Null, beforeTime: Null, callback: (Array)->Void) { callback([]); @@ -86,6 +95,10 @@ class Dummy implements Persistence { callback(); } + @HaxeCBridge.noemit + public function removeMedia(hashAlgorithm:String, hash:BytesData) { + } + @HaxeCBridge.noemit public function storeCaps(caps:Caps) { } diff --git a/snikket/persistence/Sqlite.hx b/snikket/persistence/Sqlite.hx index ad8d5c9..24a4e1a 100644 --- a/snikket/persistence/Sqlite.hx +++ b/snikket/persistence/Sqlite.hx @@ -190,6 +190,35 @@ class Sqlite implements Persistence { callback(message); } + @HaxeCBridge.noemit + public function updateMessage(accountId: String, message: ChatMessage) { + storeMessage(accountId, message, (_)->{}); + } + + + public function getMessage(accountId: String, chatId: String, serverId: Null, localId: Null, callback: (Null)->Void) { + final q = new StringBuf(); + q.add("SELECT stanza FROM messages WHERE account_id="); + db.addValue(q, accountId); + q.add(" AND chat_id="); + db.addValue(q, chatId); + if (serverId != null) { + q.add(" AND mam_id="); + db.addValue(q, serverId); + } else if (localId != null) { + q.add(" AND stanza_id="); + db.addValue(q, localId); + } + q.add("LIMIT 1"); + final result = db.request(q.toString()); + final messages = []; + for (row in result) { + callback(ChatMessage.fromStanza(Stanza.parse(row.stanza), JID.parse(accountId))); // TODO + return; + } + callback(null); + } + private function getMessages(accountId: String, chatId: String, time: String, op: String) { final q = new StringBuf(); q.add("SELECT stanza FROM messages WHERE account_id="); @@ -294,13 +323,13 @@ class Sqlite implements Persistence { } @HaxeCBridge.noemit - public function getMediaUri(hashAlgorithm:String, hash:BytesData, callback: (Null)->Void) { + public function getMediaPath(hashAlgorithm:String, hash:BytesData) { if (hashAlgorithm == "sha-256") { final path = blobpath + "/f" + Bytes.ofData(hash).toHex(); if (FileSystem.exists(path)) { - callback("file://" + FileSystem.absolutePath(path)); + return FileSystem.absolutePath(path); } else { - callback(null); + return null; } } else if (hashAlgorithm == "sha-1") { final q = new StringBuf(); @@ -309,10 +338,9 @@ class Sqlite implements Persistence { q.add(" LIMIT 1"); final result = db.request(q.toString()); for (row in result) { - getMediaUri("sha-256", row.sha256, callback); - return; + return getMediaPath("sha-256", row.sha256); } - callback(null); + return null; } else { throw "Unknown hash algorithm: " + hashAlgorithm; } @@ -320,7 +348,13 @@ class Sqlite implements Persistence { @HaxeCBridge.noemit public function hasMedia(hashAlgorithm:String, hash:BytesData, callback: (Bool)->Void) { - getMediaUri(hashAlgorithm, hash, (uri) -> callback(uri != null)); + callback(getMediaPath(hashAlgorithm, hash) != null); + } + + @HaxeCBridge.noemit + public function removeMedia(hashAlgorithm:String, hash:BytesData) { + final path = getMediaPath(hashAlgorithm, hash); + if (path != null) FileSystem.deleteFile(path); } @HaxeCBridge.noemit diff --git a/snikket/persistence/browser.js b/snikket/persistence/browser.js index ecef1b7..b8be273 100644 --- a/snikket/persistence/browser.js +++ b/snikket/persistence/browser.js @@ -419,6 +419,12 @@ const browser = (dbname, tokenize, stemmer) => { }); }, + updateMessage: function(account, message) { + const tx = db.transaction(["messages"], "readwrite"); + const store = tx.objectStore("messages"); + store.put(serializeMessage(account, message)); + }, + updateMessageStatus: function(account, localId, status, callback) { const tx = db.transaction(["messages"], "readwrite"); const store = tx.objectStore("messages"); @@ -569,11 +575,27 @@ const browser = (dbname, tokenize, stemmer) => { hasMedia: function(hashAlgorithm, hash, callback) { (async () => { - const response = await this.getMediaResponse(hashAlgorithm, hash); + const response = await this.getMediaResponse(mkNiUrl(hashAlgorithm, hash)); return !!response; })().then(callback); }, + removeMedia: function(hashAlgorithm, hash) { + (async () => { + var niUrl; + if (hashAlgorithm === "sha-256") { + niUrl = mkNiUrl(hashAlgorithm, hash); + } else { + const tx = db.transaction(["keyvaluepairs"], "readonly"); + const store = tx.objectStore("keyvaluepairs"); + niUrl = await promisifyRequest(store.get(mkNiUrl(hashAlgorithm, hash))); + if (!niUrl) return; + } + + return await cache.delete(niUrl); + })(); + }, + storeMedia: function(mime, buffer, callback) { (async function() { const sha256 = await crypto.subtle.digest("SHA-256", buffer);