[PATCH strfry-push-notify v1] Do not send push notifications if event is muted

1 view
Skip to first unread message

Daniel D’Aquino

unread,
May 13, 2024, 11:38:11 PMMay 13
to pat...@damus.io, Daniel D’Aquino
This commit suppresses push notifications from being sent if the event
being notified should be muted according to the recipient's public mute
list

Testing
-------

PASS

Device: iPhone 15 simulator
iOS: 17.4
Damus: 46185c55d1dcc376842ca26e6ba47caa99a55b66
strfry-push-notify: This commit
Setup:
- Mock APNS server
- Local strfry relay setup with the notification plugin
- Device setup with experimental push notification support and device token set to be sent to localhost
- Two Nostr accounts (Main one under test is called "A". The other one is called "B")
- Both Nostr accounts are connected to the local relay.
Steps:
1. From account "A", mute user "B".
2. From user "B", add a comment to user "A"'s note.
3. Watch the mock APNS server logs. There should be no incoming notifications. PASS
4. From account "A", unmute user "B".
5. From account "B", add a second comment to user "A"'s note.
6. Watch the mock APNS server logs. There should be an incoming notification. PASS
7. From account "A", mute the word "airdrop".
8. From account "B", add a comment with that word, but changing the capitalization.
9. Check that no push notification shows up on the APNS mock server. PASS
10. Remove that word from the mute list and repeat step 8. Push notification should appear. PASS
11. Repeat this style of testing for hashtag muting and thread muting. PASS

Note: If the user is not always connected to the relay that sends push notifications, there could be some de-sync in mute lists

Changelog-Added: Mute push notifications according to the user's public mute preferences
Closes: https://github.com/damus-io/damus/issues/2200
Signed-off-by: Daniel D’Aquino <dan...@daquino.me>
---
Hi Will,

Here is the first iteration of muting push notifications on the server side.

It is functional, however I have not optimized it for performance or written automated tests for it yet.

Please let me know if you'd like those things to be done at a later ticket (to free up time to focus on other tickets) or if you'd like me to do them before merging.

Also please let me know if you have any questions or feedback in general!


Thank you,
Daniel


README.md | 2 +
src/MuteManager.ts | 78 ++++++++++++++++++++++++++++++++++++++
src/NotificationManager.ts | 42 +++++++++++++++-----
3 files changed, 112 insertions(+), 10 deletions(-)
create mode 100644 src/MuteManager.ts

diff --git a/README.md b/README.md
index c835138..391a4ad 100644
--- a/README.md
+++ b/README.md
@@ -47,6 +47,7 @@ APNS_TOPIC="com.jb55.damus2" # Your app's bundle ID
APNS_CERTIFICATE_FILE_PATH=./apns_cert.pem # Only if APNS_AUTH_METHOD is "certificate". Path to your APNS certificate file
APNS_CERTIFICATE_KEY_FILE_PATH=./apns_key.pem # Only if APNS_AUTH_METHOD is "certificate". Path to your APNS certificate key file
DB_PATH=./apns_notifications.db # Path to the SQLite database file that will be used to store data about sent notifications
+RELAY_URL=ws://localhost # URL to the relay server which will be consulted to get information such as mute lists.
```

6. Start strfry
@@ -82,6 +83,7 @@ APNS_AUTH_METHOD="token"
APNS_AUTH_TOKEN="" # Can be anything if using the mock APNS server
APNS_TOPIC="com.jb55.damus2" # Your app's bundle ID
DB_PATH=./apns_notifications.db # Path to the SQLite database file that will be used to store data about sent notifications
+RELAY_URL=ws://localhost:7777 # URL to the relay server which will be consulted to get information such as mute lists.
```

6. Start strfry
diff --git a/src/MuteManager.ts b/src/MuteManager.ts
new file mode 100644
index 0000000..342840f
--- /dev/null
+++ b/src/MuteManager.ts
@@ -0,0 +1,78 @@
+import { NostrEvent } from "./NostrEvent.ts";
+import { Pubkey } from "./types.ts";
+import { Relay, kinds, NostrEvent as NostrToolsEvent } from "npm:nostr...@2.5.2"
+
+export class MuteManager {
+ private relayUrl: string;
+ private relay: Relay | undefined = undefined;
+
+ constructor(relayUrl: string) {
+ this.relayUrl = relayUrl;
+ Relay.connect(this.relayUrl).then(relay => {
+ this.relay = relay;
+ });
+ }
+
+ async shouldMuteNotificationForPubkey(event: NostrEvent, pubkey: Pubkey): Promise<boolean> {
+ const muteList = await this.getPublicMuteList(pubkey);
+ if (muteList === null) {
+ return false;
+ }
+ for(const tag of muteList.tags) {
+ switch (tag[0]) {
+ case 'p':
+ // Pubkey mute
+ if (event.info.pubkey === tag[1]) {
+ return true;
+ }
+ break;
+ case 'e':
+ // Direct event or thread mute
+ if (event.info.id === tag[1] || event.referencedEventIds().has(tag[1])) {
+ return true;
+ }
+ break;
+ case 't':
+ // Hashtag mute
+ if (event.getTags('h').includes(tag[1])) {
+ return true;
+ }
+ break;
+ case 'word':
+ // Word mute
+ if (event.info.content.includes(tag[1])) {
+ return true;
+ }
+ break;
+ }
+ }
+ return false;
+ }
+
+ async getPublicMuteList(pubkey: Pubkey): Promise<NostrToolsEvent|null> {
+ return await new Promise((resolve, reject) => {
+ const muteLists: NostrToolsEvent[] = [];
+ const sub = this.relay?.subscribe([
+ {
+ kinds: [kinds.Mutelist],
+ authors: [pubkey],
+ },
+ ], {
+ onevent(event) {
+ muteLists.push(event);
+
+ },
+ oneose() {
+ sub?.close()
+ if (muteLists.length === 0) {
+ resolve(null);
+ }
+ // Sort the mutelists by the `created_at` timestamp in ascending order
+ muteLists.sort((a, b) => a.created_at - b.created_at);
+ // Get the latest mutelist based on the latest `created_at` timestamp
+ resolve(muteLists[muteLists.length - 1]);
+ }
+ })
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/NotificationManager.ts b/src/NotificationManager.ts
index 05218c8..d9b4897 100644
--- a/src/NotificationManager.ts
+++ b/src/NotificationManager.ts
@@ -4,6 +4,7 @@ import { NostrEvent } from "./NostrEvent.ts";
import { load } from "https://deno.land/s...@0.205.0/dotenv/mod.ts";
import https from "node:https";
import fs from "node:fs";
+import { MuteManager } from "./MuteManager.ts";

const env = await load();
const APNS_SERVER_BASE_URL = env["APNS_SERVER_BASE_URL"] || "http://localhost:8001/push-notification/"; // Probably api.development.push.apple.com/3/device for development, api.push.apple.com/3/device for production
@@ -13,6 +14,7 @@ const APNS_TOPIC = env["APNS_TOPIC"] || "com.jb55.damus2";
const APNS_CERTIFICATE_FILE_PATH = env["APNS_CERTIFICATE_FILE_PATH"] || "./apns_cert.pem";
const APNS_CERTIFICATE_KEY_FILE_PATH = env["APNS_CERTIFICATE_KEY_FILE_PATH"] || "./apns_key.pem";
const DB_PATH = env["DB_PATH"] || "./apns_notifications.db";
+const RELAY_URL = env["RELAY_URL"] || "ws://localhost";

// The NotificationManager has three main responsibilities:
// 1. Keep track of pubkeys and associated iOS device tokens
@@ -22,11 +24,13 @@ export class NotificationManager {
private dbPath: string;
private db: DB;
private isDatabaseSetup: boolean;
+ private muteManager: MuteManager;

- constructor(dbPath?: string | undefined) {
+ constructor(dbPath?: string | undefined, relayUrl?: string | undefined) {
this.dbPath = dbPath || DB_PATH;
this.db = new DB(this.dbPath);
this.isDatabaseSetup = false;
+ this.muteManager = new MuteManager(relayUrl || RELAY_URL);
}

async setupDatabase() {
@@ -81,21 +85,39 @@ export class NotificationManager {
}

// 1. Determine which pubkeys to notify
+ const pubkeysToNotify = await this.pubkeysToNotifyForEvent(event);
+
+ // 2. Send the notifications to them and record that we sent them
+ for(const pubkey of pubkeysToNotify) {
+ await this.sendEventNotificationsToPubkey(event, pubkey);
+ // Record that we sent the notification
+ await this.db.query('INSERT OR REPLACE INTO notifications (id, event_id, pubkey, received_notification, sent_at) VALUES (?, ?, ?, ?, ?)', [event.info.id + ":" + pubkey, event.info.id, pubkey, true, currentTimeUnix]);
+ }
+ };
+
+ /**
+ * Retrieves the set of public keys to notify for a given event.
+ *
+ * @param event - The NostrEvent object representing the event.
+ * @returns A Promise that resolves to a Set of Pubkey objects representing the public keys to notify.
+ */
+ async pubkeysToNotifyForEvent(event: NostrEvent): Promise<Set<Pubkey>> {
const notificationStatusForThisEvent: NotificationStatus = await this.getNotificationStatus(event);
const relevantPubkeys: Set<Pubkey> = await this.pubkeysRelevantToEvent(event);
const pubkeysThatReceivedNotification = notificationStatusForThisEvent.pubkeysThatReceivedNotification();
- const pubkeysToNotify = new Set<Pubkey>(
+ const relevantPubkeysYetToReceive = new Set<Pubkey>(
[...relevantPubkeys].filter(x => !pubkeysThatReceivedNotification.has(x) && x !== event.info.pubkey)
);
-
- // 2. Send the notifications to them
- for(const pubkey of pubkeysToNotify) {
- await this.sendEventNotificationsToPubkey(event, pubkey);
+
+ const pubkeysToNotify = new Set<Pubkey>();
+ for (const pubkey of relevantPubkeysYetToReceive) {
+ const shouldMuteNotification = await this.muteManager.shouldMuteNotificationForPubkey(event, pubkey);
+ if (!shouldMuteNotification) {
+ pubkeysToNotify.add(pubkey);
+ }
}
-
- // 3. Record who we sent notifications to
- await this.db.query('INSERT OR REPLACE INTO notifications (id, event_id, pubkey, received_notification, sent_at) VALUES (?, ?, ?, ?, ?)', [event.info.id + ":" + event.info.pubkey, event.info.id, event.info.pubkey, true, currentTimeUnix]);
- };
+ return pubkeysToNotify;
+ }

async pubkeysRelevantToEvent(event: NostrEvent): Promise<Set<Pubkey>> {
await this.throwIfDatabaseNotSetup();

base-commit: 78dc303d306bab8e53a95537804f4246cf048fe4
--
2.44.0


William Casarin

unread,
May 14, 2024, 10:49:34 AMMay 14
to Daniel D’Aquino, pat...@damus.io
Thanks! Just one comment below

>It is functional, however I have not optimized it for performance or
>written automated tests for it yet.
>
>Please let me know if you'd like those things to be done at a later
>ticket (to free up time to focus on other tickets) or if you'd like me
>to do them before merging.
>
>Also please let me know if you have any questions or feedback in general!
>
> [ ..]
>
>+ async getPublicMuteList(pubkey: Pubkey): Promise<NostrToolsEvent|null> {
>+ return await new Promise((resolve, reject) => {
>+ const muteLists: NostrToolsEvent[] = [];
>+ const sub = this.relay?.subscribe([
>+ {
>+ kinds: [kinds.Mutelist],
>+ authors: [pubkey],
>+ },
>+ ], {
>+ onevent(event) {
>+ muteLists.push(event);
>+
>+ },
>+ oneose() {
>+ sub?.close()
>+ if (muteLists.length === 0) {
>+ resolve(null);
>+ }
>+ // Sort the mutelists by the `created_at` timestamp in ascending order
>+ muteLists.sort((a, b) => a.created_at - b.created_at);

I'm not sure this logic makes sense. Mutelists are replaceable events,
meaning you will only ever return one from the relay, the latest one. If
you wanted to be sure you could always just add `limit:1` which is
guaranteed to return the latest note by created_at

Daniel D'Aquino

unread,
May 15, 2024, 3:06:15 PMMay 15
to William Casarin, pat...@damus.io
I see! I was not aware that relays had this behaviour. I thought it would return all versions it had.

I will modify the code and send an updated patch!


Reply all
Reply to author
Forward
0 new messages