git » sdk » commit 408e5c3

Notification settings API

author Stephen Paul Weber
2025-03-26 18:22:41 UTC
committer Stephen Paul Weber
2025-03-26 18:22:41 UTC
parent 34d27d2e8544a825daf9b38d2ac80b556ea46413

Notification settings API

store locally per chat and also send to push service when registered

snikket/Chat.hx +56 -1
snikket/Client.hx +22 -5
snikket/persistence/IDB.js +4 -0
snikket/persistence/Sqlite.hx +14 -6
snikket/queries/Push2Enable.hx +7 -6

diff --git a/snikket/Chat.hx b/snikket/Chat.hx
index d85150c..02f980c 100644
--- a/snikket/Chat.hx
+++ b/snikket/Chat.hx
@@ -75,6 +75,7 @@ abstract class Chat {
 	private var typingTimer: haxe.Timer = null;
 	private var isActive: Null<Bool> = null;
 	private var activeThread: Null<String> = null;
+	private var notificationSettings: Null<{reply: Bool, mention: Bool}> = null;
 
 	@:allow(snikket)
 	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) {
@@ -339,6 +340,42 @@ abstract class Chat {
 		}
 	}
 
+	/**
+		Update notification preferences
+	**/
+	public function setNotifications(filtered: Bool, mention: Bool, reply: Bool) {
+		if (filtered) {
+			notificationSettings = { mention: mention, reply: reply };
+		} else {
+			notificationSettings = null;
+		}
+		persistence.storeChats(client.accountId(), [this]);
+		#if js
+		client.updatePushIfEnabled();
+		#end
+	}
+
+	/**
+		Should notifications be filtered?
+	**/
+	public function notificationsFiltered() {
+		return notificationSettings != null;
+	}
+
+	/**
+		Should a mention produce a notification?
+	**/
+	public function notifyMention() {
+		return notificationSettings == null || notificationSettings.mention;
+	}
+
+	/**
+		Should a reply produce a notification?
+	**/
+	public function notifyReply() {
+		return notificationSettings == null || notificationSettings.reply;
+	}
+
 	/**
 		An ID of the most recent message in this chat
 	**/
@@ -1126,12 +1163,18 @@ class Channel extends Chat {
 		return uiState != Closed;
 	}
 
+	public function isPrivate() {
+		return disco.features.contains("muc_membersonly");
+	}
+
 	@:allow(snikket)
 	private function refreshDisco(?callback: ()->Void) {
 		final discoGet = new DiscoInfoGet(chatId);
 		discoGet.onFinished(() -> {
 			if (discoGet.getResult() != null) {
+				final setupNotifications = disco == null && notificationSettings == null;
 				disco = discoGet.getResult();
+				if (setupNotifications && !isPrivate()) notificationSettings = { mention: true, reply: false };
 				persistence.storeCaps(discoGet.getResult());
 				persistence.storeChats(client.accountId(), [this]);
 			}
@@ -1499,8 +1542,11 @@ class SerializedChat {
 	public final readUpToBy:Null<String>;
 	public final disco:Null<Caps>;
 	public final klass:String;
+	public final notificationsFiltered: Null<Bool>;
+	public final notifyMention: Bool;
+	public final notifyReply: Bool;
 
-	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) {
+	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>, notificationsFiltered: Null<Bool>, notifyMention: Bool, notifyReply: Bool, disco: Null<Caps>, klass: String) {
 		this.chatId = chatId;
 		this.trusted = trusted;
 		this.avatarSha1 = avatarSha1;
@@ -1511,22 +1557,31 @@ class SerializedChat {
 		this.extensions = extensions ?? "<extensions xmlns='urn:app:bookmarks:1' />";
 		this.readUpToId = readUpToId;
 		this.readUpToBy = readUpToBy;
+		this.notificationsFiltered = notificationsFiltered;
+		this.notifyMention = notifyMention;
+		this.notifyReply = notifyReply;
 		this.disco = disco;
 		this.klass = klass;
 	}
 
 	public function toChat(client: Client, stream: GenericStream, persistence: Persistence) {
 		final extensionsStanza = Stanza.fromXml(Xml.parse(extensions));
+		var filterN = notificationsFiltered ?? false;
+		var mention = notifyMention;
 
 		final chat = if (klass == "DirectChat") {
 			new DirectChat(client, stream, persistence, chatId, uiState, isBlocked, extensionsStanza, readUpToId, readUpToBy);
 		} else if (klass == "Channel") {
 			final channel = new Channel(client, stream, persistence, chatId, uiState, isBlocked, extensionsStanza, readUpToId, readUpToBy);
 			channel.disco = disco ?? new Caps("", [], ["http://jabber.org/protocol/muc"]);
+			if (notificationsFiltered == null && !channel.isPrivate()) {
+				mention = filterN = true;
+			}
 			channel;
 		} else {
 			throw "Unknown class of " + chatId + ": " + klass;
 		}
+		chat.setNotifications(filterN, mention, notifyReply);
 		if (displayName != null) chat.displayName = displayName;
 		if (avatarSha1 != null) chat.setAvatarSha1(avatarSha1);
 		chat.setTrusted(trusted);
diff --git a/snikket/Client.hx b/snikket/Client.hx
index 0a50cd8..6312f65 100644
--- a/snikket/Client.hx
+++ b/snikket/Client.hx
@@ -940,7 +940,8 @@ class Client extends EventEmitter {
 	}
 
 	#if js
-	public function subscribePush(reg: js.html.ServiceWorkerRegistration, push_service: String, vapid_key: { publicKey: js.html.CryptoKey, privateKey: js.html.CryptoKey}) {
+	private var enabledPushData: Null<{ push_service: String, vapid_private_key: js.html.CryptoKey, endpoint: String, p256dh: BytesData, auth: BytesData, grace: Int }> = null;
+	public function subscribePush(reg: js.html.ServiceWorkerRegistration, push_service: String, vapid_key: { publicKey: js.html.CryptoKey, privateKey: js.html.CryptoKey }, ?grace: Int) {
 		js.Browser.window.crypto.subtle.exportKey("raw", vapid_key.publicKey).then((vapid_public_raw) -> {
 			reg.pushManager.subscribe(untyped {
 				userVisibleOnly: true,
@@ -955,14 +956,23 @@ class Client extends EventEmitter {
 					vapid_key.privateKey,
 					pushSubscription.endpoint,
 					pushSubscription.getKey(js.html.push.PushEncryptionKeyName.P256DH),
-					pushSubscription.getKey(js.html.push.PushEncryptionKeyName.AUTH)
+					pushSubscription.getKey(js.html.push.PushEncryptionKeyName.AUTH),
+					grace ?? -1
 				);
 			});
 		});
 	}
 
-	public function enablePush(push_service: String, vapid_private_key: js.html.CryptoKey, endpoint: String, p256dh: BytesData, auth: BytesData) {
-		final chatSettings = []; // TODO
+	private function enablePush(push_service: String, vapid_private_key: js.html.CryptoKey, endpoint: String, p256dh: BytesData, auth: BytesData, grace: Int) {
+		enabledPushData = { push_service: push_service, vapid_private_key: vapid_private_key, endpoint: endpoint, p256dh: p256dh, auth: auth, grace: grace };
+
+		final filters = [];
+		for (chat in chats) {
+			if (chat.notificationsFiltered()) {
+				filters.push({ jid: chat.chatId, mention: chat.notifyMention(), reply: chat.notifyReply() });
+			}
+		}
+
 		js.Browser.window.crypto.subtle.exportKey("pkcs8", vapid_private_key).then((vapid_private_pkcs8) -> {
 			sendQuery(new Push2Enable(
 				jid.asBare().asString(),
@@ -973,10 +983,17 @@ class Client extends EventEmitter {
 				"ES256",
 				Bytes.ofData(vapid_private_pkcs8),
 				[ "aud" => new js.html.URL(endpoint).origin ],
-				chatSettings
+				grace,
+				filters
 			));
 		});
 	}
+
+	@:allow(snikket)
+	private function updatePushIfEnabled() {
+		if (enabledPushData == null) return;
+		enablePush(enabledPushData.push_service, enabledPushData.vapid_private_key, enabledPushData.endpoint, enabledPushData.p256dh, enabledPushData.auth, enabledPushData.grace);
+	}
 	#end
 
 	/**
diff --git a/snikket/persistence/IDB.js b/snikket/persistence/IDB.js
index 8be41c8..b64f137 100644
--- a/snikket/persistence/IDB.js
+++ b/snikket/persistence/IDB.js
@@ -240,6 +240,7 @@ export default (dbname, media, tokenize, stemmer) => {
 					extensions: chat.extensions?.toString(),
 					readUpToId: chat.readUpToId,
 					readUpToBy: chat.readUpToBy,
+					notificationSettings: chat.notificationsFiltered() ? { mention: chat.notifyMention(), reply: chat.notifyReply() } : null,
 					disco: chat.disco,
 					class: chat instanceof snikket.DirectChat ? "DirectChat" : (chat instanceof snikket.Channel ? "Channel" : "Chat")
 				});
@@ -265,6 +266,9 @@ export default (dbname, media, tokenize, stemmer) => {
 					r.extensions,
 					r.readUpToId,
 					r.readUpToBy,
+					r.notificationSettings === undefined ? null : r.notificationSettings != null,
+					r.notificationSettings?.mention,
+					r.notificationSettings?.reply,
 					r.disco ? new snikket.Caps(r.disco.node, r.disco.identities, r.disco.features) : null,
 					r.class
 				)));
diff --git a/snikket/persistence/Sqlite.hx b/snikket/persistence/Sqlite.hx
index d80ea90..678999a 100644
--- a/snikket/persistence/Sqlite.hx
+++ b/snikket/persistence/Sqlite.hx
@@ -112,6 +112,13 @@ class Sqlite implements Persistence implements KeyValueStore {
 					PRIMARY KEY (account_id, chat_id, sender_id, update_id)
 				) STRICT;
 				PRAGMA user_version = 1;");
+			} else if (version < 2) {
+				db.exec("ALTER TABLE chats ADD COLUMN notifications_filtered INTEGER;
+				ALTER TABLE chats ADD COLUMN notify_mention INTEGER NOT NULL DEFAULT 0;
+				ALTER TABLE chats ADD COLUMN notify_reply INTEGER NOT NULL DEFAULT 0;
+				PRAGMA user_version = 2;");
+			} else {
+				Promise.resolve(null);
 			}
 		});
 	}
@@ -189,7 +196,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 			for (_ in storeChatBuffer) {
 				if (!first) q.add(",");
 				first = false;
-				q.add("(?,?,?,?,?,?,?,?,?,?,?,jsonb(?),?)");
+				q.add("(?,?,?,?,?,?,?,?,?,?,?,jsonb(?),?,?,?,?)");
 			}
 			db.exec(
 				q.toString(),
@@ -201,7 +208,8 @@ class Sqlite implements Persistence implements KeyValueStore {
 						chat.getDisplayName(), chat.uiState, chat.isBlocked,
 						chat.extensions.toString(), chat.readUpTo(), chat.readUpToBy,
 						channel?.disco?.verRaw().hash, Json.stringify(mapPresence(chat)),
-						Type.getClassName(Type.getClass(chat)).split(".").pop()
+						Type.getClassName(Type.getClass(chat)).split(".").pop(),
+						chat.notificationsFiltered(), chat.notifyMention(), chat.notifyReply()
 					];
 					return row;
 				})
@@ -214,7 +222,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 	@HaxeCBridge.noemit
 	public function getChats(accountId: String, callback: (Array<SerializedChat>)->Void) {
 		db.exec(
-			"SELECT chat_id, trusted, avatar_sha1, fn, ui_state, blocked, extensions, read_up_to_id, read_up_to_by, json(caps) AS caps, json(presence) AS presence, class FROM chats LEFT JOIN caps ON chats.caps_ver=caps.sha1 WHERE account_id=?",
+			"SELECT chat_id, trusted, avatar_sha1, fn, ui_state, blocked, extensions, read_up_to_id, read_up_to_by, notifications_filtered, notify_mention, notify_reply, json(caps) AS caps, json(presence) AS presence, class FROM chats LEFT JOIN caps ON chats.caps_ver=caps.sha1 WHERE account_id=?",
 			[accountId]
 		).then(result -> {
 			final fetchCaps: Map<BytesData, Bool> = [];
@@ -250,7 +258,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 						presence.mucUser == null ? null : Stanza.parse(presence.mucUser)
 					);
 				}
-				chats.push(new SerializedChat(row.chat_id, row.trusted, row.avatar_sha1, presenceMap, row.fn, row.ui_state, row.blocked, row.extensions, row.read_up_to_id, row.read_up_to_by, row.capsObj, Reflect.field(row, "class")));
+				chats.push(new SerializedChat(row.chat_id, row.trusted != 0, row.avatar_sha1, presenceMap, row.fn, row.ui_state, row.blocked != 0, row.extensions, row.read_up_to_id, row.read_up_to_by, row.notifications_filtered == null ? null : row.notifications_filtered != 0, row.notify_mention != 0, row.notify_reply != 0, row.capsObj, Reflect.field(row, "class")));
 			}
 			callback(chats);
 		});
@@ -767,11 +775,11 @@ class Sqlite implements Persistence implements KeyValueStore {
 		});
 	}
 
-	private function hydrateMessages(accountId: String, rows: Iterator<{ stanza: String, timestamp: String, direction: MessageDirection, type: MessageType, mam_id: String, mam_by: String, sync_point: Bool, sender_id: String, ?stanza_id: String, ?versions: String, ?version_times: String }>): Array<ChatMessage> {
+	private function hydrateMessages(accountId: String, rows: Iterator<{ stanza: String, timestamp: String, direction: MessageDirection, type: MessageType, mam_id: String, mam_by: String, sync_point: Int, sender_id: String, ?stanza_id: String, ?versions: String, ?version_times: String }>): Array<ChatMessage> {
 		// TODO: Calls can "edit" from multiple senders, but the original direction and sender holds
 		final accountJid = JID.parse(accountId);
 		return { iterator: () -> rows }.map(row -> ChatMessage.fromStanza(Stanza.parse(row.stanza), accountJid, (builder, _) -> {
-			builder.syncPoint = row.sync_point;
+			builder.syncPoint = row.sync_point != 0;
 			builder.timestamp = row.timestamp;
 			builder.type = row.type;
 			builder.senderId = row.sender_id;
diff --git a/snikket/queries/Push2Enable.hx b/snikket/queries/Push2Enable.hx
index cdcd9ae..351d9d1 100644
--- a/snikket/queries/Push2Enable.hx
+++ b/snikket/queries/Push2Enable.hx
@@ -13,7 +13,7 @@ class Push2Enable extends GenericQuery {
 	public var ver:String = null;
 	private var responseStanza:Stanza;
 
-	public function new(to: String, service: String, client: String, ua_public: Bytes, auth_secret: Bytes, jwt_alg: Null<String>, jwt_key: Bytes, jwt_claims: Map<String, String>, chats: Array<{ jid: String, mention: Bool, reply: Bool }>) {
+	public function new(to: String, service: String, client: String, ua_public: Bytes, auth_secret: Bytes, jwt_alg: Null<String>, jwt_key: Bytes, jwt_claims: Map<String, String>, grace: Int, filters: Array<{ jid: String, mention: Bool, reply: Bool }>) {
 		queryId = ID.short();
 		queryStanza = new Stanza(
 			"iq",
@@ -23,11 +23,12 @@ class Push2Enable extends GenericQuery {
 		enable.textTag("service", service);
 		enable.textTag("client", client);
 		final match = enable.tag("match", { profile: "urn:xmpp:push2:match:important" });
-		for (chat in chats) {
-			final chatel = match.tag("chat", { jid: chat.jid });
-			if (chat.mention) chatel.tag("mention").up();
-			if (chat.reply) chatel.tag("reply").up();
-			chatel.up();
+		if (grace > 0) match.textTag("grace", Std.string(grace));
+		for (filter in filters) {
+			final filterel = match.tag("filter", { jid: filter.jid });
+			if (filter.mention) filterel.tag("mention").up();
+			if (filter.reply) filterel.tag("reply").up();
+			filterel.up();
 		}
 		final send = match.tag("send", { xmlns: "urn:xmpp:push2:send:sce+rfc8291+rfc8292:0" });
 		send.textTag("ua-public", Base64.encode(ua_public));