hello early page

Changed files
+122 -16
atproto-notifications
lexicons
server
+8 -3
atproto-notifications/src/components/Buttons.css
··· 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; ··· 12 border-right-color: hsla(0, 0%, 0%, 0.3); 13 box-shadow: 0 42px 42px -42px inset #221828; 14 } 15 - button:hover { 16 border-color: #646cff; 17 } 18 button:focus, 19 - button:focus-visible { 20 outline: 4px auto -webkit-focus-ring-color; 21 } 22
··· 1 + button, 2 + a.button { 3 border-radius: 0.5rem; 4 border: 1px solid transparent; 5 + color: inherit; 6 padding: 0.6em 1.2em; 7 font-size: 1em; 8 font-weight: 500; ··· 14 border-right-color: hsla(0, 0%, 0%, 0.3); 15 box-shadow: 0 42px 42px -42px inset #221828; 16 } 17 + button:hover, 18 + a.button:hover { 19 border-color: #646cff; 20 } 21 button:focus, 22 + button:focus-visible, 23 + a.button:focus, 24 + a.button:focus-visible { 25 outline: 4px auto -webkit-focus-ring-color; 26 } 27
+1 -1
atproto-notifications/src/components/Fetch.tsx
··· 67 ); 68 } 69 70 - async function postJson(url, body, credentials) { 71 const opts = { 72 method: 'POST', 73 headers: {'Content-Type': 'applicaiton/json'},
··· 67 ); 68 } 69 70 + export async function postJson(url, body, credentials) { 71 const opts = { 72 method: 'POST', 73 headers: {'Content-Type': 'applicaiton/json'},
+6 -5
atproto-notifications/src/components/SecretPassword.jsx
··· 38 }} 39 ok={() => ( 40 <> 41 - <p>That will do.</p> 42 <p> 43 - <button onClick={() => window.location.reload()}> 44 - Enter 45 - </button> 46 </p> 47 </> 48 )} ··· 62 {' '} 63 {begun && ( 64 <button type="submit" className="subtle"> 65 - open sesame 66 </button> 67 )} 68 </p>
··· 38 }} 39 ok={() => ( 40 <> 41 + <p style={{ color: "#9f0" }}>Secret password accepted.</p> 42 <p> 43 + {/* an <a> tag, not a <Link>, on purpose so we relaod for our role */} 44 + <a className="button" href="/early"> 45 + Continue 46 + </a> 47 </p> 48 </> 49 )} ··· 63 {' '} 64 {begun && ( 65 <button type="submit" className="subtle"> 66 + unlock 67 </button> 68 )} 69 </p>
+1 -1
atproto-notifications/src/components/setup/WithNotificationPermission.tsx
··· 15 if (currentPermission !== 'granted') { 16 return ( 17 <> 18 - <h3>Step 2: Allow notifications</h3> 19 <p>To show notifications we need permission:</p> 20 <p> 21 <button
··· 15 if (currentPermission !== 'granted') { 16 return ( 17 <> 18 + <h3>Final step: Allow notifications</h3> 19 <p>To show notifications we need permission:</p> 20 <p> 21 <button
+2
atproto-notifications/src/main.tsx
··· 5 import { App } from './App'; 6 import { Feed } from './pages/Feed'; 7 import { Admin } from './pages/Admin'; 8 9 createRoot(document.getElementById('root')!).render( 10 // <StrictMode> ··· 13 <Routes> 14 <Route index element={<Feed />} /> 15 <Route path="/admin" element={<Admin />} /> 16 </Routes> 17 </App> 18 </BrowserRouter>
··· 5 import { App } from './App'; 6 import { Feed } from './pages/Feed'; 7 import { Admin } from './pages/Admin'; 8 + import { Early } from './pages/Early'; 9 10 createRoot(document.getElementById('root')!).render( 11 // <StrictMode> ··· 14 <Routes> 15 <Route index element={<Feed />} /> 16 <Route path="/admin" element={<Admin />} /> 17 + <Route path="/early" element={<Early />} /> 18 </Routes> 19 </App> 20 </BrowserRouter>
+5
atproto-notifications/src/pages/Early.css
···
··· 1 + .early { 2 + max-width: 36rem; 3 + text-align: left; 4 + margin: 0 auto; 5 + }
+57
atproto-notifications/src/pages/Early.tsx
···
··· 1 + import { useCallback, useState } from 'react'; 2 + import { postJson } from '../components/Fetch'; 3 + import './Early.css'; 4 + 5 + export function Early({ }) { 6 + const [pushCount, setPushCount] = useState(0); 7 + const [pushStatus, setPushStatus] = useState(null); 8 + 9 + const localTest = useCallback(() => { 10 + new Notification("Hello world!", { body: "This notification never left your browser" }); 11 + }); 12 + 13 + const pushTest = useCallback(async () => { 14 + setPushStatus(n => n + 1); 15 + setPushStatus('pending'); 16 + const host = import.meta.env.VITE_NOTIFICATIONS_HOST; 17 + const url = new URL('/push-test', host); 18 + try { 19 + await postJson(url, JSON.stringify(null), true); 20 + setPushStatus(null); 21 + } catch (e) { 22 + console.error('failed push test request', e); 23 + setPushStatus('failed'); 24 + } 25 + }); 26 + 27 + return ( 28 + <div className="early"> 29 + <h2>Hello!</h2> 30 + <p>Welcome to the early preview for the spacedust notifications demo, and since you're here early: thanks so much for supporting microcosm!</p> 31 + <p>A few things to keep in mind:</p> 32 + <ol> 33 + <li>This is a demo, not a polished product</li> 34 + <li>It has a lot of moving pieces, so things not always work</li> 35 + <li>Many features can easily be added! Some others can't! Make a request and let's see :)</li> 36 + <li>It's not a long-term committed part of microcosm <em>(yet)</em></li> 37 + </ol> 38 + <p>Sadly, it doesn't really work on mobile. On iOS you can hit "share" and "add to home screen" to get things eventually mostly set up, but push delivery will stop after a few minutes. Android people might have better luck?</p> 39 + <h3>Hello hello</h3> 40 + <p>With that out of the way, let's cover some basics.</p> 41 + <p> 42 + To see a test notification, <button onClick={localTest}>click on this</button>. This is a local-only test. 43 + </p> 44 + <p> 45 + <button 46 + disabled={pushStatus === 'pending'} 47 + onClick={pushTest} 48 + > 49 + Click here {pushCount > 0 && <>({pushCount})</>} 50 + </button> 51 + {' '} 52 + to see another. This one goes over Web Push. 53 + </p> 54 + {pushStatus === 'failed' && <p>uh oh, something went wrong requesting a web push</p>} 55 + </div> 56 + ); 57 + }
+9
lexicons/index.js
··· 1 export default { 2 'app.bsky': { 3 name: 'Bluesky', 4 clients: [
··· 1 export default { 2 + 'blue.microcosm': { 3 + name: 'microcosm', 4 + clients: [ 5 + {}, 6 + ], 7 + known_sources: { 8 + 'test.notification:hello': 'Hello spacedust!', 9 + }, 10 + }, 11 'app.bsky': { 12 name: 'Bluesky', 13 clients: [
+17 -3
server/api.js
··· 133 return gotIt(res); 134 }; 135 136 const handleLogout = async (db, user, req, res, appSecret, updateSubs) => { 137 try { 138 db.deleteSub(user.session); ··· 152 const { secret_password } = JSON.parse(body); 153 const { did } = user; 154 const role = 'early'; 155 - console.log('going with', {did, role, secret_password}); 156 const updated = db.setRole({ did, role, secret_password }); 157 - console.log('updated?', updated); 158 if (updated) { 159 return okBye(res); 160 } else { ··· 226 } 227 } 228 229 - export const server = (secrets, jwks, allowedOrigin, whoamiHost, db, updateSubs, adminDid) => { 230 const handler = (req, res) => { 231 // don't love this but whatever 232 const { pathname, searchParams } = new URL(`http://localhost${req.url}`); ··· 260 if (method === 'POST' && pathname === '/subscribe') { 261 if (!user || user.role === 'public') return forbidden(res); 262 return handleSubscribe(db, user, req, res, updateSubs); 263 } 264 265 // admin required (just 404 for non-admin)
··· 133 return gotIt(res); 134 }; 135 136 + const handlePushTest = async (db, user, res, push) => { 137 + const subscription = db.getSubBySession(user.session); 138 + const payload = JSON.stringify({ 139 + subject: user.did, 140 + source: 'blue.microcosm.test.notification:hello', 141 + source_record: `at://${user.did}/blue.microcosm.test.notification/test`, 142 + timestamp: +new Date(), 143 + }); 144 + await push(db, subscription, payload); 145 + return okBye(res); 146 + }; 147 + 148 const handleLogout = async (db, user, req, res, appSecret, updateSubs) => { 149 try { 150 db.deleteSub(user.session); ··· 164 const { secret_password } = JSON.parse(body); 165 const { did } = user; 166 const role = 'early'; 167 const updated = db.setRole({ did, role, secret_password }); 168 if (updated) { 169 return okBye(res); 170 } else { ··· 236 } 237 } 238 239 + export const server = (secrets, jwks, allowedOrigin, whoamiHost, db, updateSubs, push, adminDid) => { 240 const handler = (req, res) => { 241 // don't love this but whatever 242 const { pathname, searchParams } = new URL(`http://localhost${req.url}`); ··· 270 if (method === 'POST' && pathname === '/subscribe') { 271 if (!user || user.role === 'public') return forbidden(res); 272 return handleSubscribe(db, user, req, res, updateSubs); 273 + } 274 + if (method === 'POST' && pathname === '/push-test') { 275 + if (!user || user.role === 'public') return forbidden(res); 276 + return handlePushTest(db, user, res, push); 277 } 278 279 // admin required (just 404 for non-admin)
+13
server/db.js
··· 11 #stmt_insert_push_sub; 12 #stmt_get_all_sub_dids; 13 #stmt_get_push_subs; 14 #stmt_update_push_sub; 15 #stmt_delete_push_sub; 16 #stmt_get_push_info; ··· 76 as 'since_last_push' 77 from push_subs 78 where account_did = ?`); 79 80 this.#stmt_update_push_sub = db.prepare( 81 `update push_subs ··· 157 158 getSubsByDid(did) { 159 return this.#stmt_get_push_subs.all(did); 160 } 161 162 updateLastPush(session) {
··· 11 #stmt_insert_push_sub; 12 #stmt_get_all_sub_dids; 13 #stmt_get_push_subs; 14 + #stmt_get_push_sub; 15 #stmt_update_push_sub; 16 #stmt_delete_push_sub; 17 #stmt_get_push_info; ··· 77 as 'since_last_push' 78 from push_subs 79 where account_did = ?`); 80 + 81 + this.#stmt_get_push_sub = db.prepare( 82 + `select session, 83 + subscription, 84 + (julianday(CURRENT_TIMESTAMP) - julianday(last_push)) * 24 * 60 * 60 85 + as 'since_last_push' 86 + from push_subs 87 + where session = ?`); 88 89 this.#stmt_update_push_sub = db.prepare( 90 `update push_subs ··· 166 167 getSubsByDid(did) { 168 return this.#stmt_get_push_subs.all(did); 169 + } 170 + 171 + getSubBySession(session) { 172 + return this.#stmt_get_push_sub.get(session); 173 } 174 175 updateLastPush(session) {
+2 -2
server/index.js
··· 47 const db = new DB(dbFilename, initDb); 48 49 const spacedustHost = env.SPACEDUST_HOST ?? 'wss://spacedust.microcosm.blue'; 50 - const updateSubs = connectSpacedust(db, spacedustHost); 51 52 const host = env.HOST ?? 'localhost'; 53 const port = parseInt(env.PORT ?? 8000, 10); 54 55 const allowedOrigin = env.ALLOWED_ORIGIN ?? 'http://127.0.0.1:5173'; 56 57 - server(secrets, jwks, allowedOrigin, whoamiHost, db, updateSubs, adminDid).listen( 58 port, 59 host, 60 () => console.log(`listening at http://${host}:${port} with allowed origin: ${allowedOrigin}`),
··· 47 const db = new DB(dbFilename, initDb); 48 49 const spacedustHost = env.SPACEDUST_HOST ?? 'wss://spacedust.microcosm.blue'; 50 + const { updateSubs, push } = connectSpacedust(db, spacedustHost); 51 52 const host = env.HOST ?? 'localhost'; 53 const port = parseInt(env.PORT ?? 8000, 10); 54 55 const allowedOrigin = env.ALLOWED_ORIGIN ?? 'http://127.0.0.1:5173'; 56 57 + server(secrets, jwks, allowedOrigin, whoamiHost, db, updateSubs, push, adminDid).listen( 58 port, 59 host, 60 () => console.log(`listening at http://${host}:${port} with allowed origin: ${allowedOrigin}`),
+1 -1
server/notifications.js
··· 146 restart(); 147 }; 148 149 - return updateSubs; 150 };
··· 146 restart(); 147 }; 148 149 + return { updateSubs, push }; 150 };