git » sdk » commit b567138

Support for blocking/unblocking chats

author Stephen Paul Weber
2024-11-06 22:02:07 UTC
committer Stephen Paul Weber
2024-11-06 22:02:07 UTC
parent b98ab7c83cc0406df5a76c730d96802e24ce4c1f

Support for blocking/unblocking chats

Block status is persisted on the chat.
It is updated when server pushes, and a full blocklist pull is done on
new session.
Block closes any open chat and prevents any notifications for that chat
so it should not notify or open again even on MAM.
Explicit unblock opens the chat.

Also implemented spam reporting when making the block call if the app
wishes to do so. SPAM reports must contain an example SPAM message.

snikket/Chat.hx +50 -8
snikket/Client.hx +61 -2
snikket/persistence/Sqlite.hx +6 -3
snikket/persistence/browser.js +2 -0
snikket/queries/BlocklistGet.hx +45 -0

diff --git a/snikket/Chat.hx b/snikket/Chat.hx
index d02ddcf..9033345 100644
--- a/snikket/Chat.hx
+++ b/snikket/Chat.hx
@@ -57,6 +57,7 @@ abstract class Chat {
 	**/
 	@:allow(snikket)
 	public var uiState(default, null): UiState = Open;
+	public var isBlocked(default, null): Bool = false;
 	@:allow(snikket)
 	private var extensions: Stanza;
 	private var _unreadCount = 0;
@@ -71,12 +72,13 @@ abstract class Chat {
 	private var activeThread: Null<String> = null;
 
 	@:allow(snikket)
-	private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, extensions: Null<Stanza> = null, readUpToId: Null<String> = null, readUpToBy: Null<String> = null) {
+	private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, isBlocked = false, extensions: Null<Stanza> = null, readUpToId: Null<String> = null, readUpToBy: Null<String> = null) {
 		this.client = client;
 		this.stream = stream;
 		this.persistence = persistence;
 		this.chatId = chatId;
 		this.uiState = uiState;
+		this.isBlocked = isBlocked;
 		this.extensions = extensions ?? new Stanza("extensions", { xmlns: "urn:xmpp:bookmarks:1" });
 		this.readUpToId = readUpToId;
 		this.readUpToBy = readUpToBy;
@@ -272,6 +274,44 @@ abstract class Chat {
 	**/
 	abstract public function close():Void;
 
+	/**
+		Block this chat so it will not re-open
+	**/
+	public function block(reportSpam: Null<ChatMessage>, onServer: Bool): Void {
+		if (reportSpam != null && !onServer) throw "Can't report SPAM if not sending to server";
+		isBlocked = true;
+		if (uiState != Closed) close(); // close persists
+		if (onServer) {
+			final iq = new Stanza("iq", { type: "set", id: ID.short() })
+				.tag("block", { xmlns: "urn:xmpp:blocking" })
+				.tag("item", { jid: chatId });
+			if (reportSpam != null) {
+				iq
+					.tag("report", { xmlns: "urn:xmpp:reporting:1", reason: "urn:xmpp:reporting:spam" })
+					.tag("stanza-id", { xmlns: "urn:xmpp:sid:0", by: reportSpam.serverIdBy, id: reportSpam.serverId });
+			}
+			stream.sendIq(iq, (response) -> {});
+		}
+	}
+
+	/**
+		Unblock this chat so it will not open again
+	**/
+	public function unblock(onServer: Bool): Void {
+		isBlocked = false;
+		uiState = Open;
+		persistence.storeChat(client.accountId(), this);
+		client.trigger("chats/update", [this]);
+		if (onServer) {
+			stream.sendIq(
+				new Stanza("iq", { type: "set", id: ID.short() })
+					.tag("unblock", { xmlns: "urn:xmpp:blocking" })
+					.tag("item", { jid: chatId }).up().up(),
+				(response) -> {}
+			);
+		}
+	}
+
 	/**
 		An ID of the most recent message in this chat
 	**/
@@ -581,8 +621,8 @@ abstract class Chat {
 #end
 class DirectChat extends Chat {
 	@:allow(snikket)
-	private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, extensions: Null<Stanza> = null, readUpToId: Null<String> = null, readUpToBy: Null<String> = null) {
-		super(client, stream, persistence, chatId, uiState, extensions, readUpToId, readUpToBy);
+	private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, isBlocked = false, extensions: Null<Stanza> = null, readUpToId: Null<String> = null, readUpToBy: Null<String> = null) {
+		super(client, stream, persistence, chatId, uiState, isBlocked, extensions, readUpToId, readUpToBy);
 	}
 
 	@HaxeCBridge.noemit // on superclass as abstract
@@ -813,8 +853,8 @@ class Channel extends Chat {
 	private var inSync = true;
 
 	@:allow(snikket)
-	private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, extensions = null, readUpToId = null, readUpToBy = null, ?disco: Caps) {
-		super(client, stream, persistence, chatId, uiState, extensions, readUpToId, readUpToBy);
+	private function new(client:Client, stream:GenericStream, persistence:Persistence, chatId:String, uiState = Open, isBlocked = false, extensions = null, readUpToId = null, readUpToBy = null, ?disco: Caps) {
+		super(client, stream, persistence, chatId, uiState, isBlocked, extensions, readUpToId, readUpToBy);
 		if (disco != null) this.disco = disco;
 	}
 
@@ -1290,19 +1330,21 @@ class SerializedChat {
 	public final presence:Map<String, Presence>;
 	public final displayName:Null<String>;
 	public final uiState:UiState;
+	public final isBlocked:Bool;
 	public final extensions:String;
 	public final readUpToId:Null<String>;
 	public final readUpToBy:Null<String>;
 	public final disco:Null<Caps>;
 	public final klass:String;
 
-	public function new(chatId: String, trusted: Bool, avatarSha1: Null<BytesData>, presence: Map<String, Presence>, displayName: Null<String>, uiState: Null<UiState>, extensions: Null<String>, readUpToId: Null<String>, readUpToBy: Null<String>, disco: Null<Caps>, klass: String) {
+	public function new(chatId: String, trusted: Bool, avatarSha1: Null<BytesData>, presence: Map<String, Presence>, displayName: Null<String>, uiState: Null<UiState>, isBlocked: Null<Bool>, extensions: Null<String>, readUpToId: Null<String>, readUpToBy: Null<String>, disco: Null<Caps>, klass: String) {
 		this.chatId = chatId;
 		this.trusted = trusted;
 		this.avatarSha1 = avatarSha1;
 		this.presence = presence;
 		this.displayName = displayName;
 		this.uiState = uiState ?? Open;
+		this.isBlocked = isBlocked ?? false;
 		this.extensions = extensions ?? "<extensions xmlns='urn:app:bookmarks:1' />";
 		this.readUpToId = readUpToId;
 		this.readUpToBy = readUpToBy;
@@ -1314,9 +1356,9 @@ class SerializedChat {
 		final extensionsStanza = Stanza.fromXml(Xml.parse(extensions));
 
 		final chat = if (klass == "DirectChat") {
-			new DirectChat(client, stream, persistence, chatId, uiState, extensionsStanza, readUpToId, readUpToBy);
+			new DirectChat(client, stream, persistence, chatId, uiState, isBlocked, extensionsStanza, readUpToId, readUpToBy);
 		} else if (klass == "Channel") {
-			final channel = new Channel(client, stream, persistence, chatId, uiState, extensionsStanza, readUpToId, readUpToBy);
+			final channel = new Channel(client, stream, persistence, chatId, uiState, isBlocked, extensionsStanza, readUpToId, readUpToBy);
 			channel.disco = disco ?? new Caps("", [], ["http://jabber.org/protocol/muc"]);
 			channel;
 		} else {
diff --git a/snikket/Client.hx b/snikket/Client.hx
index eae4e2a..c7d0a54 100644
--- a/snikket/Client.hx
+++ b/snikket/Client.hx
@@ -16,6 +16,7 @@ import snikket.EventHandler;
 import snikket.PubsubEvent;
 import snikket.Stream;
 import snikket.jingle.Session;
+import snikket.queries.BlocklistGet;
 import snikket.queries.BoB;
 import snikket.queries.DiscoInfoGet;
 import snikket.queries.DiscoItemsGet;
@@ -389,6 +390,44 @@ class Client extends EventEmitter {
 			return IqResult;
 		});
 
+		stream.onIq(Set, "block", "urn:xmpp:blocking", (stanza) -> {
+			if (
+				stanza.attr.get("from") != null &&
+				stanza.attr.get("from") != jid.domain
+			) {
+				return IqNoResult;
+			}
+
+			for (item in stanza.getChild("block", "urn:xmpp:blocking")?.allTags("item") ?? []) {
+				if (item.attr.get("jid") != null) serverBlocked(item.attr.get("jid"));
+			}
+
+			return IqResult;
+		});
+
+		stream.onIq(Set, "unblock", "urn:xmpp:blocking", (stanza) -> {
+			if (
+				stanza.attr.get("from") != null &&
+				stanza.attr.get("from") != jid.domain
+			) {
+				return IqNoResult;
+			}
+
+			final unblocks = stanza.getChild("unblock", "urn:xmpp:blocking")?.allTags("item");
+			if (unblocks == null) {
+				// unblock all
+				for (chat in chats) {
+					if (chat.isBlocked) chat.unblock(false);
+				}
+			} else {
+				for (item in unblocks) {
+					if (item.attr.get("jid") != null) getChat(item.attr.get("jid"))?.unblock(false);
+				}
+			}
+
+			return IqResult;
+		});
+
 		stream.on("presence", function(event) {
 			final stanza:Stanza = event.stanza;
 			final c = stanza.getChild("c", "http://jabber.org/protocol/caps");
@@ -805,7 +844,7 @@ class Client extends EventEmitter {
 		}
 
 		final chat = if (availableChat.isChannel()) {
-			final channel = new Channel(this, this.stream, this.persistence, availableChat.chatId, Open, null, availableChat.caps);
+			final channel = new Channel(this, this.stream, this.persistence, availableChat.chatId, Open, false, null, availableChat.caps);
 			chats.unshift(channel);
 			channel.selfPing(false);
 			channel;
@@ -1066,6 +1105,7 @@ class Client extends EventEmitter {
 
 	@:allow(snikket)
 	private function chatActivity(chat: Chat, trigger = true) {
+		if (chat.isBlocked) return; // Don't notify blocked chats
 		if (chat.uiState == Closed) {
 			chat.uiState = Open;
 			persistence.storeChat(accountId(), chat);
@@ -1154,6 +1194,8 @@ class Client extends EventEmitter {
 
 	@:allow(snikket)
 	private function notifyMessageHandlers(message: ChatMessage) {
+		final chat = getChat(message.chatId());
+		if (chat != null && chat.isBlocked) return; // Don't notify blocked chats
 		for (handler in chatMessageHandlers) {
 			handler(message);
 		}
@@ -1188,7 +1230,7 @@ class Client extends EventEmitter {
 				persistence.storeCaps(resultCaps);
 				final uiState = handleCaps(resultCaps);
 				if (resultCaps.isChannel(jid)) {
-					final chat = new Channel(this, this.stream, this.persistence, jid, uiState, null, resultCaps);
+					final chat = new Channel(this, this.stream, this.persistence, jid, uiState, false, null, resultCaps);
 					handleChat(chat);
 					chats.unshift(chat);
 					persistence.storeChat(accountId(), chat);
@@ -1202,8 +1244,25 @@ class Client extends EventEmitter {
 		sendQuery(discoGet);
 	}
 
+	private function serverBlocked(blocked: String) {
+		final chat = getChat(blocked);
+		if (chat == null) {
+			startChatWith(blocked, (caps) -> Closed, (chat) -> chat.block(null, false));
+		} else {
+			chat.block(null, false);
+		}
+	}
+
 	// This is called right before we're going to trigger for all chats anyway, so don't bother with single triggers
 	private function bookmarksGet(callback: ()->Void) {
+		final blockingGet = new BlocklistGet();
+		blockingGet.onFinished(() -> {
+			for (blocked in blockingGet.getResult()) {
+				serverBlocked(blocked);
+			}
+		});
+		sendQuery(blockingGet);
+
 		final mdsGet = new PubsubGet(null, "urn:xmpp:mds:displayed:0");
 		mdsGet.onFinished(() -> {
 			for (item in mdsGet.getResult()) {
diff --git a/snikket/persistence/Sqlite.hx b/snikket/persistence/Sqlite.hx
index cf88f5a..0869cbe 100644
--- a/snikket/persistence/Sqlite.hx
+++ b/snikket/persistence/Sqlite.hx
@@ -57,7 +57,8 @@ class Sqlite implements Persistence {
 				trusted BOOLEAN NOT NULL,
 				avatar_sha1 BLOB,
 				fn TEXT,
-				ui_state TEXT,
+				ui_state TEXT NOT NULL,
+				blocked BOOLEAN NOT NULL,
 				extensions TEXT,
 				read_up_to_id TEXT,
 				read_up_to_by TEXT,
@@ -133,6 +134,8 @@ class Sqlite implements Persistence {
 		q.add(",");
 		db.addValue(q, chat.uiState);
 		q.add(",");
+		db.addValue(q, chat.isBlocked);
+		q.add(",");
 		db.addValue(q, chat.extensions);
 		q.add(",");
 		db.addValue(q, chat.readUpTo());
@@ -149,12 +152,12 @@ class Sqlite implements Persistence {
 		// TODO: presence
 		// TODO: disco
 		final q = new StringBuf();
-		q.add("SELECT chat_id, trusted, avatar_sha1, fn, ui_state, extensions, read_up_to_id, read_up_to_by, class FROM chats WHERE account_id=");
+		q.add("SELECT chat_id, trusted, avatar_sha1, fn, ui_state, blocked, extensions, read_up_to_id, read_up_to_by, class FROM chats WHERE account_id=");
 		db.addValue(q, accountId);
 		final result = db.request(q.toString());
 		final chats = [];
 		for (row in result) {
-			chats.push(new SerializedChat(row.chat_id, row.trusted, row.avatar_sha1, [], row.fn, row.ui_state, row.extensions, row.read_up_to_id, row.read_up_to_by, null, Reflect.field(row, "class")));
+			chats.push(new SerializedChat(row.chat_id, row.trusted, row.avatar_sha1, [], row.fn, row.ui_state, row.blocked, row.extensions, row.read_up_to_id, row.read_up_to_by, null, Reflect.field(row, "class")));
 		}
 		callback(chats);
 	}
diff --git a/snikket/persistence/browser.js b/snikket/persistence/browser.js
index c0901c2..3c3251d 100644
--- a/snikket/persistence/browser.js
+++ b/snikket/persistence/browser.js
@@ -208,6 +208,7 @@ const browser = (dbname, tokenize, stemmer) => {
 				presence: new Map([...chat.presence.entries()].map(([k, p]) => [k, { caps: p.caps?.ver(), mucUser: p.mucUser?.toString() }])),
 				displayName: chat.displayName,
 				uiState: chat.uiState,
+				isBlocked: chat.isBlocked,
 				extensions: chat.extensions?.toString(),
 				readUpToId: chat.readUpToId,
 				readUpToBy: chat.readUpToBy,
@@ -231,6 +232,7 @@ const browser = (dbname, tokenize, stemmer) => {
 					))),
 					r.displayName,
 					r.uiState,
+					r.isBlocked,
 					r.extensions,
 					r.readUpToId,
 					r.readUpToBy,
diff --git a/snikket/queries/BlocklistGet.hx b/snikket/queries/BlocklistGet.hx
new file mode 100644
index 0000000..a4a54f0
--- /dev/null
+++ b/snikket/queries/BlocklistGet.hx
@@ -0,0 +1,45 @@
+package snikket.queries;
+
+import haxe.DynamicAccess;
+import haxe.Exception;
+
+import snikket.ID;
+import snikket.ResultSet;
+import snikket.Stanza;
+import snikket.Stream;
+import snikket.queries.GenericQuery;
+
+class BlocklistGet extends GenericQuery {
+	public var xmlns(default, null) = "urn:xmpp:blocking";
+	public var queryId:String = null;
+	public var ver:String = null;
+	private var responseStanza:Stanza;
+	private var result: Array<String>;
+
+	public function new() {
+		/* Build basic query */
+		queryId = ID.short();
+		queryStanza = new Stanza("iq", { type: "get", id: queryId })
+			.tag("blocklist", { xmlns: xmlns }).up();
+	}
+
+	public function handleResponse(stanza:Stanza) {
+		responseStanza = stanza;
+		finish();
+	}
+
+	public function getResult() {
+		if (responseStanza == null) {
+			return [];
+		}
+		if(result == null) {
+			final q = responseStanza.getChild("blocklist", xmlns);
+			if(q == null) {
+				return [];
+			}
+			// TODO: cannot specify namespace here due to bugs in namespace handling in allTags
+			result = q.allTags("item").map(el -> el.attr.get("jid"));
+		}
+		return result;
+	}
+}