popup ui for notification filtering

Changed files
+138 -7
atproto-notifications
lexicons
server
+15 -1
atproto-notifications/package-lock.json
··· 15 15 "react": "^19.1.0", 16 16 "react-dom": "^19.1.0", 17 17 "react-router": "^7.6.3", 18 - "react-time-ago": "^7.3.3" 18 + "react-time-ago": "^7.3.3", 19 + "reactjs-popup": "^2.0.6" 19 20 }, 20 21 "devDependencies": { 21 22 "@eslint/js": "^9.29.0", ··· 3115 3116 "javascript-time-ago": "^2.3.7", 3116 3117 "react": ">=0.16.8", 3117 3118 "react-dom": ">=0.16.8" 3119 + } 3120 + }, 3121 + "node_modules/reactjs-popup": { 3122 + "version": "2.0.6", 3123 + "resolved": "https://registry.npmjs.org/reactjs-popup/-/reactjs-popup-2.0.6.tgz", 3124 + "integrity": "sha512-A+tt+x9wdgZiZjv0e2WzYLD3IfFwJALaRaqwrCSXGjo0iQdsry/EtBEbQXRSmQs7cHmOi5eytCiSlOm8k4C+dg==", 3125 + "license": "MIT", 3126 + "engines": { 3127 + "node": ">=10" 3128 + }, 3129 + "peerDependencies": { 3130 + "react": ">=16", 3131 + "react-dom": ">=16" 3118 3132 } 3119 3133 }, 3120 3134 "node_modules/relative-time-format": {
+2 -1
atproto-notifications/package.json
··· 18 18 "react": "^19.1.0", 19 19 "react-dom": "^19.1.0", 20 20 "react-router": "^7.6.3", 21 - "react-time-ago": "^7.3.3" 21 + "react-time-ago": "^7.3.3", 22 + "reactjs-popup": "^2.0.6" 22 23 }, 23 24 "devDependencies": { 24 25 "@eslint/js": "^9.29.0",
atproto-notifications/public/icons/microcosm.png

This is a binary file and will not be displayed.

+22
atproto-notifications/src/components/NotificationSettings.tsx
··· 1 + import { useState } from 'react'; 2 + 3 + 1 4 export function NotificationSettings({ secondary, secondaryFilter }) { 5 + 6 + 7 + // const [notifyToggleCounter, setNotifyToggleCounter] = useState(0); 8 + 9 + // // TODO move up (to chrome?) so it syncs 10 + // const setGlobalNotifications = useCallback(async enabled => { 11 + // const host = import.meta.env.VITE_NOTIFICATIONS_HOST; 12 + // const url = new URL('/global-notify', host); 13 + // try { 14 + // await postJson(url, JSON.stringify({ notify_enabled: enabled }), true) 15 + // } catch (err) { 16 + // console.error('failed to set self-notify setting', err); 17 + // } 18 + // setNotifyToggleCounter(n => n + 1); 19 + // }); 20 + 21 + 22 + 23 + 2 24 if (secondary === 'all') { 3 25 return <p>Notifications default: [todo: toggle mute], unknown sources: [toggle mute]</p>; 4 26 }
-3
atproto-notifications/src/pages/Early.tsx
··· 39 39 40 40 // TODO move up (to chrome?) so it syncs 41 41 const setGlobalNotifications = useCallback(async enabled => { 42 - // setSecretDevStatus('pending'); 43 42 const host = import.meta.env.VITE_NOTIFICATIONS_HOST; 44 43 const url = new URL('/global-notify', host); 45 44 try { 46 45 await postJson(url, JSON.stringify({ notify_enabled: enabled }), true) 47 - // setSecretDevStatus(null); 48 46 } catch (err) { 49 47 console.error('failed to set self-notify setting', err); 50 - // setSecretDevStatus('failed'); 51 48 } 52 49 setNotifyToggleCounter(n => n + 1); 53 50 });
+40
atproto-notifications/src/pages/Feed.css
··· 26 26 text-align: left; 27 27 margin: 2rem auto; 28 28 } 29 + 30 + .filter-pref-trigger { 31 + display: inline-block; 32 + padding: 0 0.25rem; 33 + } 34 + .filter-pref-trigger:hover { 35 + background: hsla(0, 0%, 50%, 0.333); 36 + border-radius: 0.3333rem; 37 + } 38 + 39 + .popup-arrow { 40 + color: #2c343c; 41 + stroke-width: 1.5px; 42 + stroke: hsla(0, 0%, 50%, 0.333); 43 + stroke-dasharray: 30px; 44 + stroke-dashoffset: -54px; 45 + } 46 + 47 + .popup-overlay { 48 + background: hsla(0, 0%, 0%, 0.1); 49 + } 50 + 51 + .popup-content { 52 + background: #2c343c; 53 + padding: 0.25rem 0.333rem; 54 + font-size: 0.8rem; 55 + border: 0.5px solid hsla(0, 0%, 50%, 0.333); 56 + border-radius: 0.25rem; 57 + } 58 + .filter-pref-popup { 59 + text-align: center; 60 + } 61 + .filter-pref-popup h4 { 62 + margin: 0 0 0.25rem; 63 + font-size: 0.8rem; 64 + color: #bbb; 65 + } 66 + .filter-pref.option { 67 + display: block; 68 + }
+40 -1
atproto-notifications/src/pages/Feed.tsx
··· 1 1 import { useEffect, useState } from 'react'; 2 + import Popup from 'reactjs-popup'; 2 3 import { getNotifications, getSecondary } from '../db'; 3 4 import { ButtonGroup } from '../components/Buttons'; 4 5 import { NotificationSettings } from '../components/NotificationSettings'; 5 6 import { Notification } from '../components/Notification'; 7 + import { GetJson } from '../components/Fetch'; 6 8 import psl from 'psl'; 7 9 import lexicons from 'lexicons'; 8 10 9 11 import './feed.css'; 12 + 13 + function FilterPref({ secondary, value }) { 14 + return ( 15 + <Popup 16 + trigger={ 17 + <div className="filter-pref-trigger"> 18 + 19 + </div> 20 + } 21 + position={['bottom center']} 22 + closeOnDocumentClick 23 + > 24 + <div className="filter-pref-popup"> 25 + <h4>filter notifications</h4> 26 + <ButtonGroup 27 + options={[ 28 + { val: 'notify', label: 'notify' }, 29 + { val: 'mute' }, 30 + ]} 31 + current={null} 32 + /> 33 + {/*<button className="subtle">reset</button>*/} 34 + </div> 35 + </Popup> 36 + ); 37 + } 10 38 11 39 function SecondaryFilter({ inc, secondary, current, onUpdate }) { 12 40 const [secondaries, setSecondaries] = useState([]); ··· 80 108 {icon && ( 81 109 <img className="app-icon" src={icon} title={appName ?? app} alt="" /> 82 110 )} 83 - {title} ({total}) 111 + {title} 112 + <small style={{ 113 + display: 'inline-block', 114 + fontSize: '0.6rem', 115 + padding: '0 0.2rem', 116 + color: '#f90', 117 + fontFamily: 'monospace', 118 + verticalAlign: 'top', 119 + }}> 120 + {total >= 30 ? '30+' : total} 121 + </small> 122 + <FilterPref secondary={secondary} value={k} /> 84 123 </> 85 124 ), 86 125 };
+6 -1
lexicons/index.js
··· 2 2 'blue.microcosm': { 3 3 name: 'microcosm', 4 4 clients: [ 5 - {}, 5 + { 6 + app_name: 'Spacedust notifications demo', 7 + canonical: true, 8 + main: 'https://notifications.microcosm.blue', 9 + icon: '/icons/microcosm.png', 10 + }, 6 11 ], 7 12 known_sources: { 8 13 'test.notification:hello': 'Hello spacedust!',
+13
server/schema.sql
··· 30 30 31 31 check(length(password) >= 3) 32 32 ) strict; 33 + 34 + create table if not exists mute_by_secondary ( 35 + account_did text not null, 36 + selector text not null, 37 + selection text not null, 38 + mute integer not null default true, 39 + 40 + primary key(account_did, selector, selection), 41 + check(selector in ('all', 'app', 'group', 'source')), 42 + 43 + foreign key(account_did) references accounts(did) 44 + on delete cascade on update cascade 45 + ) strict;