filter ui-ish

+152 -116
+5
atproto-notifications/src/App.tsx
··· 36 36 } 37 37 } 38 38 39 + let autoreg; 39 40 async function subscribeToPush() { 40 41 const registration = await navigator.serviceWorker.register('/service-worker.js'); 42 + 43 + // auto-update in case they keep it open in a tab for a long time 44 + clearInterval(autoreg); 45 + autoreg = setInterval(() => registration.update(), 4 * 60 * 60 * 1000); // every 4h 41 46 42 47 const subscribeOptions = { 43 48 userVisibleOnly: true,
+45 -35
atproto-notifications/src/components/Buttons.css
··· 1 - button.bg:focus-visible { 2 - outline: 4px auto -webkit-focus-ring-color; 3 - } 4 - 5 - button.bg { 6 - font: inherit; 1 + button { 2 + border-radius: 0.5rem; 3 + border: 1px solid transparent; 4 + padding: 0.6em 1.2em; 5 + font-size: 1em; 6 + font-weight: 500; 7 + font-family: inherit; 8 + background-color: #0b0d0f; 7 9 cursor: pointer; 8 - padding: 0 0.5rem; 10 + border: 1px solid hsla(0, 0%, 50%, 0.3); 11 + border-bottom-color: hsla(0, 0%, 0%, 0.3); 12 + border-right-color: hsla(0, 0%, 0%, 0.3); 13 + box-shadow: 0 42px 42px -42px inset #221828; 9 14 } 10 - button.bg:not(.bg-subtle) { 11 - background: #111; 12 - border: 1px solid hsla(0, 0%, 50%, 0.5); 13 - border-radius: 0.5rem; 15 + button:hover { 16 + border-color: #646cff; 17 + } 18 + button:focus, 19 + button:focus-visible { 20 + outline: 4px auto -webkit-focus-ring-color; 14 21 } 15 22 16 - button.bg.bg-subtle { 23 + button.subtle { 17 24 background: transparent; 18 - border: 0; 19 - } 20 - button.bg.bg-subtle:hover { 21 - color: inherit; 22 - background: hsla(0, 0%, 0%, 0.3); 25 + border: none; 26 + margin: 0; 27 + padding: 0; 28 + font: inherit; 29 + box-shadow: none; 23 30 } 24 - 25 - button.bg.big { 26 - padding: 0.25rem 0.75rem; 31 + button.bad { 32 + color: tomato; 27 33 } 28 34 29 - .button-group.vertical { 35 + .button-group { 30 36 display: flex; 31 - flex-direction: column; 37 + overflow-x: auto; 38 + /*justify-content: center;*/ 39 + max-width: 100%; 32 40 } 33 - 34 - .button-group > button.bg { 35 - color: #888; 41 + .button-group button:not(:first-child) { 42 + border-top-left-radius: 0; 43 + border-bottom-left-radius: 0; 44 + border-left-width: 0.5px; 36 45 } 37 - .button-group > button.bg.current { 38 - font-weight: bold; 39 - color: #99bb77; 46 + .button-group button:not(:last-child) { 47 + border-top-right-radius: 0; 48 + border-bottom-right-radius: 0; 49 + border-right-width: 0.5px; 40 50 } 41 - .button-group > button.bg.current.bg-subtle { 42 - background: #111; /*hsla(0, 0%, 0%, 0.2);*/ 51 + .button-group > button { 52 + color: #888; 43 53 } 44 - 45 - @media screen and (max-width: 600px) { 46 - button.bg.bg-subtle { 47 - font-size: 0.85rem; 48 - } 54 + .button-group > button.current { 55 + font-weight: bold; 56 + /*color: #88cc77;*/ 57 + color: skyblue; 58 + box-shadow: 0 -42px 42px -42px inset #221828; 49 59 }
+3 -3
atproto-notifications/src/components/Buttons.tsx
··· 1 1 import './Buttons.css'; 2 2 3 - export function ButtonGroup({ options, current, onChange, subtle, big, vertical }) { 3 + export function ButtonGroup({ options, current, onChange }) { 4 4 return ( 5 - <div className={`button-group ${vertical ? 'vertical' : ''}`}> 5 + <div className='button-group'> 6 6 {options.map(({val, label}) => ( 7 7 <button 8 8 key={val} 9 - className={`bg ${subtle ? 'bg-subtle' : ''} ${big ? 'big' : ''} ${val === current ? 'current' : ''}`} 9 + className={val === current ? 'current' : ''} 10 10 onClick={() => onChange(val)} 11 11 > 12 12 {label ?? val}
+23
atproto-notifications/src/components/Feed.css
··· 1 + .feed-filter-type, 2 + .feed-filter-secondary { 3 + align-items: baseline; 4 + display: flex; 5 + gap: 0.5rem; 6 + justify-content: center; 7 + max-width: calc(100vw - 2rem); 8 + margin-top: 0.5rem; 9 + } 10 + 11 + .feed-filter-type h4, 12 + .feed-filter-secondary { 13 + margin: 0; 14 + } 15 + 16 + .app-icon { 17 + height: 1rem; 18 + display: inline-block; 19 + vertical-align: baseline; 20 + position: relative; 21 + top: 0.1rem; 22 + margin-right: 0.25rem; 23 + }
+75 -44
atproto-notifications/src/components/Feed.tsx
··· 4 4 import psl from 'psl'; 5 5 import lexicons from 'lexicons'; 6 6 7 - function Asdf({ inc, secondary }) { 7 + import './feed.css'; 8 + 9 + function SecondaryFilter({ inc, secondary, current, onUpdate }) { 8 10 const [secondaries, setSecondaries] = useState([]); 9 - const [selected, setSelected] = useState(null); 10 11 11 12 useEffect(() => { 12 13 (async () => { 13 14 const secondaries = await getSecondary(secondary); 14 15 secondaries.sort((a, b) => b.unread - a.unread); 15 16 setSecondaries(secondaries); 16 - // setSelected(secondaries[0]?.k); // TODO 17 + // onUpdate(secondaries[0]?.k); // TODO 17 18 })(); 18 19 }, [inc, secondary]); 19 20 21 + // reset secondary filter only when leaving due to secondary change 22 + useEffect(() => () => onUpdate(null), [secondary]); 23 + 20 24 return ( 21 - <div> 22 - <ButtonGroup 23 - options={secondaries.map(({ k, unread, total }) => { 25 + <ButtonGroup 26 + options={secondaries.map(({ k, unread, total }) => { 24 27 25 28 26 - let title = k; 27 - if (secondary === 'source') { 28 - // TODO: clean up / move this to lexicons package? 29 - let app; 30 - let appPrefix; 31 - try { 32 - const [nsid, ...rp] = k.split(':'); 33 - const parts = nsid.split('.'); 34 - const unreversed = parts.toReversed().join('.'); 35 - app = psl.parse(unreversed)?.domain ?? 'unknown'; 36 - appPrefix = app.split('.').toReversed().join('.'); 37 - } catch (e) { 38 - console.error('getting top app failed', e); 39 - } 40 - const lex = lexicons[appPrefix]; 41 - const icon = lex?.clients[0]?.icon; 42 - title = lex?.known_sources[k.slice(app.length + 1)] ?? k; 29 + let title = k; 30 + let icon; 31 + let app; 32 + let appName; 33 + if (secondary === 'source') { 34 + // TODO: clean up / move this to lexicons package? 35 + let appPrefix; 36 + try { 37 + const [nsid, ...rp] = k.split(':'); 38 + const parts = nsid.split('.'); 39 + const unreversed = parts.toReversed().join('.'); 40 + app = psl.parse(unreversed)?.domain ?? 'unknown'; 41 + appPrefix = app.split('.').toReversed().join('.'); 42 + } catch (e) { 43 + console.error('getting top app failed', e); 43 44 } 45 + const lex = lexicons[appPrefix]; 46 + icon = lex?.clients[0]?.icon; 47 + appName = lex?.name; 48 + title = lex?.known_sources[k.slice(app.length + 1)] ?? k; 49 + } 50 + if (secondary === 'app') { 51 + const appReversed = k.split('.').toReversed().join('.'); 52 + const lex = lexicons[appReversed]; 53 + icon = lex?.clients[0]?.icon; 54 + title = appName = lex?.name; 55 + } 44 56 45 - return { val: k, label: `${title} (${total})` }; 46 - })} 47 - current={selected} 48 - onChange={setSelected} 49 - subtle 50 - /> 51 - 52 - 53 - </div> 57 + return { 58 + val: k, 59 + label: ( 60 + <> 61 + {icon && ( 62 + <img className="app-icon" src={icon} title={appName ?? app} alt="" /> 63 + )} 64 + {title} ({total}) 65 + </> 66 + ), 67 + }; 68 + })} 69 + current={current} 70 + onChange={onUpdate} 71 + /> 54 72 ); 55 73 } 56 74 57 75 export function Feed() { 58 76 const [secondary, setSecondary] = useState('all'); 77 + const [secondaryFilter, setSecondaryFilter] = useState(null); 59 78 60 79 // for now, we just increment a counter when a new notif comes in, which forces a re-render 61 80 const [inc, setInc] = useState(0); ··· 78 97 } 79 98 return ( 80 99 <div className="feed"> 81 - <ButtonGroup 82 - options={[ 83 - {val: 'all', label: 'All'}, 84 - {val: 'app', label: 'App'}, 85 - {val: 'group', label: 'Lexicon group'}, 86 - {val: 'source', label: 'Every source'}, 87 - ]} 88 - current={secondary} 89 - onChange={setSecondary} 90 - subtle 91 - /> 92 - <Asdf inc={inc} secondary={secondary} /> 100 + <div className="feed-filter-type"> 101 + <h4>Filter by:</h4> 102 + <ButtonGroup 103 + options={[ 104 + {val: 'all', label: 'All'}, 105 + {val: 'app', label: 'App'}, 106 + {val: 'group', label: 'Lexicon group'}, 107 + {val: 'source', label: 'Every source'}, 108 + ]} 109 + current={secondary} 110 + onChange={setSecondary} 111 + /> 112 + </div> 113 + {secondary !== 'all' && ( 114 + <div className="feed-filter-secondary"> 115 + <h4>Filter:</h4> 116 + <SecondaryFilter 117 + inc={inc} 118 + secondary={secondary} 119 + current={secondaryFilter} 120 + onUpdate={setSecondaryFilter} 121 + /> 122 + </div> 123 + )} 93 124 {feed.map(([k, n]) => ( 94 125 <p key={k}>{k}: {n.source} ({n.source_record}) <code>{JSON.stringify(n)}</code></p> 95 126 ))}
-34
atproto-notifications/src/index.css
··· 33 33 line-height: 1.1; 34 34 } 35 35 36 - button { 37 - border-radius: 0.5rem; 38 - border: 1px solid transparent; 39 - padding: 0.6em 1.2em; 40 - font-size: 1em; 41 - font-weight: 500; 42 - font-family: inherit; 43 - background-color: #0b0d0f; 44 - cursor: pointer; 45 - border: 1px solid hsla(0, 0%, 50%, 0.3); 46 - border-bottom-color: hsla(0, 0%, 0%, 0.3); 47 - border-right-color: hsla(0, 0%, 0%, 0.3); 48 - box-shadow: 0 42px 42px -42px inset #221828; 49 - } 50 - button:hover { 51 - border-color: #646cff; 52 - } 53 - button:focus, 54 - button:focus-visible { 55 - outline: 4px auto -webkit-focus-ring-color; 56 - } 57 - 58 - button.subtle { 59 - background: transparent; 60 - border: none; 61 - margin: 0; 62 - padding: 0; 63 - font: inherit; 64 - box-shadow: none; 65 - } 66 - button.bad { 67 - color: tomato; 68 - } 69 - 70 36 @media (prefers-color-scheme: light) { 71 37 :root { 72 38 color: #213547;
+1
atproto-notifications/src/service-worker.ts
··· 3 3 import { resolveDid } from './atproto/resolve'; 4 4 import { insertNotification } from './db'; 5 5 6 + self.addEventListener('install', () => self.skipWaiting()); 6 7 self.addEventListener('push', handlePush); 7 8 self.addEventListener('notificationclick', handleNotificationClick); 8 9