+4
atproto-notifications/src/pages/Feed.css
+4
atproto-notifications/src/pages/Feed.css
+57
-11
atproto-notifications/src/pages/Feed.tsx
+57
-11
atproto-notifications/src/pages/Feed.tsx
···
1
-
import { useEffect, useState } from 'react';
1
+
import { useCallback, useEffect, useState } from 'react';
2
2
import Popup from 'reactjs-popup';
3
3
import { getNotifications, getSecondary } from '../db';
4
4
import { ButtonGroup } from '../components/Buttons';
5
5
import { NotificationSettings } from '../components/NotificationSettings';
6
6
import { Notification } from '../components/Notification';
7
-
import { GetJson } from '../components/Fetch';
7
+
import { GetJson, PostJson } from '../components/Fetch';
8
8
import psl from 'psl';
9
9
import lexicons from 'lexicons';
10
10
11
11
import './feed.css';
12
12
13
13
function FilterPref({ secondary, value }) {
14
-
return (
14
+
const [wanted, setWanted] = useState(null);
15
+
const [updateCount, setUpdateCount] = useState(0);
16
+
const v = `${updateCount}:${wanted}`;
17
+
18
+
const setFilterBool = useCallback(val => {
19
+
setUpdateCount(n => n + 1);
20
+
setWanted(val === 'notify');
21
+
});
22
+
const resetFilter = useCallback(() => {
23
+
setUpdateCount(n => n + 1);
24
+
setWanted(null);
25
+
});
26
+
27
+
const trigger = useCallback(notify => {
28
+
let icon = '⚙', title = 'Default (inherit)';
29
+
if (notify === true) {
30
+
icon = '🔊';
31
+
title = 'Always notify';
32
+
} else if (notify === false) {
33
+
icon = '🚫';
34
+
title = 'Notifications muted';
35
+
}
36
+
return (
37
+
<div className="filter-pref-trigger" title={title}>
38
+
{icon}
39
+
</div>
40
+
);
41
+
});
42
+
43
+
const renderFilter = useCallback(({ notify }) => (
15
44
<Popup
16
-
trigger={
17
-
<div className="filter-pref-trigger">
18
-
⚙
19
-
</div>
20
-
}
45
+
key="x"
46
+
trigger={trigger(notify)}
21
47
position={['bottom center']}
22
48
closeOnDocumentClick
23
49
>
···
28
54
{ val: 'notify', label: 'notify' },
29
55
{ val: 'mute' },
30
56
]}
31
-
current={null}
57
+
current={notify === null ? null : notify ? 'notify' : 'mute'}
58
+
onChange={setFilterBool}
32
59
/>
33
-
{/*<button className="subtle">reset</button>*/}
60
+
{notify !== null && (
61
+
<button className="subtle" onClick={resetFilter}>reset</button>
62
+
)}
34
63
</div>
35
64
</Popup>
36
-
);
65
+
));
66
+
67
+
const common = {
68
+
endpoint: '/notification-filter',
69
+
credentials: true,
70
+
ok: renderFilter,
71
+
loading: () => <>…</>,
72
+
};
73
+
74
+
return updateCount === 0
75
+
? <GetJson key={v}
76
+
params={{ selector: secondary, selection: value }}
77
+
{...common}
78
+
/>
79
+
: <PostJson key={v}
80
+
data={{ selector: secondary, selection: value, notify: wanted }}
81
+
{...common}
82
+
/>;
37
83
}
38
84
39
85
function SecondaryFilter({ inc, secondary, current, onUpdate }) {
+29
server/api.js
+29
server/api.js
···
183
183
return gotIt(res);
184
184
};
185
185
186
+
const handleGetNotificationFilter = async (db, user, searchParams, res) => {
187
+
const selector = searchParams.get('selector');
188
+
if (!selector) return badRequest(res, '"selector" required in search query');
189
+
190
+
const selection = searchParams.get('selection');
191
+
if (!selection) return badRequest(res, '"selection" required in search query');
192
+
193
+
const { did } = user;
194
+
195
+
const notify = db.getNotificationFilter(did, selector, selection) ?? null;
196
+
return ok(res, { notify });
197
+
};
198
+
199
+
const handleSetNotificationFilter = async (db, user, req, res) => {
200
+
const body = await getRequesBody(req);
201
+
const { selector, selection, notify } = JSON.parse(body);
202
+
const { did } = user;
203
+
db.setNotificationFilter(did, selector, selection, notify);
204
+
return ok(res, { notify });
205
+
};
206
+
186
207
/// admin stuff
187
208
188
209
const handleListSecrets = async (db, res) => {
···
286
307
if (method === 'POST' && pathname === '/global-notify') {
287
308
if (!user) return unauthorized(res);
288
309
return handleSetGlobalNotifySettings(db, user, req, res);
310
+
}
311
+
if (method === 'GET' && pathname === '/notification-filter') {
312
+
if (!user) return unauthorized(res);
313
+
return handleGetNotificationFilter(db, user, searchParams, res);
314
+
}
315
+
if (method === 'POST' && pathname === '/notification-filter') {
316
+
if (!user) return unauthorized(res);
317
+
return handleSetNotificationFilter(db, user, req, res);
289
318
}
290
319
291
320
// non-public access required
+55
server/db.js
+55
server/db.js
···
2
2
import Database from 'better-sqlite3';
3
3
4
4
const SUBS_PER_ACCOUNT_LIMIT = 5;
5
+
const SECONDARY_FILTERS_LIMIT = 100;
6
+
5
7
const SCHEMA_FNAME = './schema.sql';
6
8
7
9
export class DB {
···
18
20
#stmt_set_role;
19
21
#stmt_get_notify_account_globals;
20
22
#stmt_set_notify_account_globals;
23
+
#stmt_set_notification_filter;
24
+
#stmt_get_notification_filter;
25
+
#stmt_count_notification_filters;
26
+
#stmt_rm_notification_filter;
21
27
22
28
#stmt_admin_add_secret;
23
29
#stmt_admin_expire_secret;
···
127
133
notify_self = :notify_self
128
134
where did = :did`);
129
135
136
+
this.#stmt_set_notification_filter = db.prepare(
137
+
`insert into notification_filters (account_did, selector, selection, notify)
138
+
values (:did, :selector, :selection, :notify)
139
+
on conflict do update
140
+
set notify = excluded.notify`);
141
+
142
+
this.#stmt_get_notification_filter = db.prepare(
143
+
`select notify
144
+
from notification_filters
145
+
where account_did = :did
146
+
and selector = :selector
147
+
and selection = :selection`);
148
+
149
+
this.#stmt_count_notification_filters = db.prepare(
150
+
`select count(*) as n
151
+
from notification_filters
152
+
where account_did = :did`);
153
+
154
+
this.#stmt_rm_notification_filter = db.prepare(
155
+
`delete from notification_filters
156
+
where account_did = :did
157
+
and selector = :selector
158
+
and selection = :selection`);
159
+
130
160
131
161
this.#stmt_admin_add_secret = db.prepare(
132
162
`insert into top_secret_passwords (password)
···
233
263
update.did = did;
234
264
this.#stmt_set_notify_account_globals.run(update);
235
265
});
266
+
}
267
+
268
+
getNotificationFilter(did, selector, selection) {
269
+
const res = this.#stmt_get_notification_filter.get({ did, selector, selection });
270
+
const dbNotify = res?.notify;
271
+
if (dbNotify === 1) return true;
272
+
else if (dbNotify === 0) return false;
273
+
else return null;
274
+
}
275
+
276
+
setNotificationFilter(did, selector, selection, notify) {
277
+
if (notify === null) {
278
+
this.#stmt_rm_notification_filter.run({ did, selector, selection });
279
+
} else {
280
+
this.#transactionally(() => {
281
+
const { n } = this.#stmt_count_notification_filters.get({ did });
282
+
if (n >= SECONDARY_FILTERS_LIMIT) {
283
+
throw new Error('max filters set for account');
284
+
}
285
+
let dbNotify = null;
286
+
if (notify === true) dbNotify = 1;
287
+
else if (notify === false) dbNotify = 0;
288
+
this.#stmt_set_notification_filter.run({ did, selector, selection, notify: dbNotify });
289
+
});
290
+
}
236
291
}
237
292
238
293
+2
-2
server/schema.sql
+2
-2
server/schema.sql
···
31
31
check(length(password) >= 3)
32
32
) strict;
33
33
34
-
create table if not exists mute_by_secondary (
34
+
create table if not exists notification_filters (
35
35
account_did text not null,
36
36
selector text not null,
37
37
selection text not null,
38
-
mute integer not null default true,
38
+
notify integer null,
39
39
40
40
primary key(account_did, selector, selection),
41
41
check(selector in ('all', 'app', 'group', 'source')),