| author | Stephen Paul Weber
<singpolyma@singpolyma.net> 2025-03-26 18:22:41 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2025-03-26 18:22:41 UTC |
| parent | 34d27d2e8544a825daf9b38d2ac80b556ea46413 |
| 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));