notification filters ui -> db

Changed files
+147 -13
atproto-notifications
src
server
+4
atproto-notifications/src/pages/Feed.css
··· 27 27 margin: 2rem auto; 28 28 } 29 29 30 + .filter-pref-wrapper { 31 + display: inline-block; 32 + } 33 + 30 34 .filter-pref-trigger { 31 35 display: inline-block; 32 36 padding: 0 0.25rem;
+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: () => <>&hellip;</>, 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
··· 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
··· 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
··· 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')),