this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Yes, push notifications (beta).

Heck this feature is tough.

+854 -15
+97
public/sw.js
··· 94 94 }), 95 95 ); 96 96 registerRoute(apiRoute); 97 + 98 + // PUSH NOTIFICATIONS 99 + // ================== 100 + 101 + self.addEventListener('push', (event) => { 102 + const { data } = event; 103 + if (data) { 104 + const payload = data.json(); 105 + console.log('PUSH payload', payload); 106 + const { 107 + access_token, 108 + title, 109 + body, 110 + icon, 111 + notification_id, 112 + notification_type, 113 + preferred_locale, 114 + } = payload; 115 + 116 + if (!!navigator.setAppBadge) { 117 + if (notification_type === 'mention') { 118 + navigator.setAppBadge(1); 119 + } 120 + } 121 + 122 + event.waitUntil( 123 + self.registration.showNotification(title, { 124 + body, 125 + icon, 126 + dir: 'auto', 127 + badge: '/logo-192.png', 128 + lang: preferred_locale, 129 + tag: notification_id, 130 + timestamp: Date.now(), 131 + data: { 132 + access_token, 133 + notification_type, 134 + }, 135 + }), 136 + ); 137 + } 138 + }); 139 + 140 + self.addEventListener('notificationclick', (event) => { 141 + const payload = event.notification; 142 + console.log('NOTIFICATION CLICK payload', payload); 143 + const { badge, body, data, dir, icon, lang, tag, timestamp, title } = payload; 144 + const { access_token, notification_type } = data; 145 + const actions = new Promise((resolve) => { 146 + event.notification.close(); 147 + const url = `/#/notifications?id=${tag}&access_token=${btoa(access_token)}`; 148 + self.clients 149 + .matchAll({ 150 + type: 'window', 151 + includeUncontrolled: true, 152 + }) 153 + .then((clients) => { 154 + console.log('NOTIFICATION CLICK clients 1', clients); 155 + if (clients.length && 'navigate' in clients[0]) { 156 + console.log('NOTIFICATION CLICK clients 2', clients); 157 + const bestClient = 158 + clients.find( 159 + (client) => 160 + client.focused || client.visibilityState === 'visible', 161 + ) || clients[0]; 162 + console.log('NOTIFICATION CLICK navigate', url); 163 + // Check if URL is root / or /notifications 164 + // const clientURL = new URL(bestClient.url); 165 + // if ( 166 + // /^#\/?$/.test(clientURL.hash) || 167 + // /^#\/notifications/i.test(clientURL.hash) 168 + // ) { 169 + // bestClient.navigate(url).then((client) => client?.focus()); 170 + // } else { 171 + // User might be on a different page (e.g. composing a post), so don't navigate anywhere else 172 + if (bestClient) { 173 + console.log('NOTIFICATION CLICK postMessage', bestClient); 174 + bestClient.postMessage?.({ 175 + type: 'notification', 176 + id: tag, 177 + accessToken: access_token, 178 + }); 179 + bestClient.focus(); 180 + } else { 181 + console.log('NOTIFICATION CLICK openWindow', url); 182 + self.clients.openWindow(url); 183 + } 184 + // } 185 + } else { 186 + console.log('NOTIFICATION CLICK openWindow', url); 187 + self.clients.openWindow(url); 188 + } 189 + resolve(); 190 + }); 191 + }); 192 + event.waitUntil(actions); 193 + });
+170 -2
src/app.jsx
··· 22 22 import Compose from './components/compose'; 23 23 import Drafts from './components/drafts'; 24 24 import Icon, { ICONS } from './components/icon'; 25 + import Link from './components/link'; 25 26 import Loader from './components/loader'; 26 27 import MediaModal from './components/media-modal'; 27 28 import Modal from './components/modal'; 29 + import Notification from './components/notification'; 28 30 import Shortcuts from './components/shortcuts'; 29 31 import ShortcutsSettings from './components/shortcuts-settings'; 30 32 import NotFound from './pages/404'; ··· 60 62 import showToast from './utils/show-toast'; 61 63 import states, { initStates, saveStatus } from './utils/states'; 62 64 import store from './utils/store'; 63 - import { getCurrentAccount } from './utils/store-utils'; 65 + import { 66 + getAccountByAccessToken, 67 + getCurrentAccount, 68 + } from './utils/store-utils'; 69 + import './utils/toast-alert'; 64 70 import useInterval from './utils/useInterval'; 65 71 import usePageVisibility from './utils/usePageVisibility'; 66 72 ··· 115 121 116 122 const clientID = store.session.get('clientID'); 117 123 const clientSecret = store.session.get('clientSecret'); 124 + const vapidKey = store.session.get('vapidKey'); 118 125 119 126 (async () => { 120 127 setUIState('loading'); ··· 128 135 const masto = initClient({ instance: instanceURL, accessToken }); 129 136 await Promise.allSettled([ 130 137 initInstance(masto, instanceURL), 131 - initAccount(masto, instanceURL, accessToken), 138 + initAccount(masto, instanceURL, accessToken, vapidKey), 132 139 ]); 133 140 initStates(); 134 141 initPreferences(masto); ··· 446 453 /> 447 454 </Modal> 448 455 )} 456 + <NotificationService /> 449 457 <BackgroundService isLoggedIn={isLoggedIn} /> 450 458 </> 451 459 ); ··· 533 541 } 534 542 } 535 543 }); 544 + 545 + return null; 546 + } 547 + 548 + function NotificationService() { 549 + if (!('serviceWorker' in navigator)) return null; 550 + 551 + const snapStates = useSnapshot(states); 552 + const { routeNotification } = snapStates; 553 + 554 + console.log('🛎️ Notification service', routeNotification); 555 + 556 + const { id, accessToken } = routeNotification || {}; 557 + const [showNotificationSheet, setShowNotificationSheet] = useState(false); 558 + 559 + useLayoutEffect(() => { 560 + if (!id || !accessToken) return; 561 + const { instance: currentInstance } = api(); 562 + const { masto, instance } = api({ 563 + accessToken, 564 + }); 565 + console.log('API', { accessToken, currentInstance, instance }); 566 + const sameInstance = currentInstance === instance; 567 + const account = accessToken 568 + ? getAccountByAccessToken(accessToken) 569 + : getCurrentAccount(); 570 + (async () => { 571 + const notification = await masto.v1.notifications.fetch(id); 572 + if (notification && account) { 573 + console.log('🛎️ Notification', { id, notification, account }); 574 + const accountInstance = account.instanceURL; 575 + const { type, status, account: notificationAccount } = notification; 576 + const hasModal = !!document.querySelector('#modal-container > *'); 577 + const isFollow = type === 'follow' && !!notificationAccount?.id; 578 + const hasAccount = !!notificationAccount?.id; 579 + const hasStatus = !!status?.id; 580 + if (isFollow && sameInstance) { 581 + // Show account sheet, can handle different instances 582 + states.showAccount = { 583 + account: notificationAccount, 584 + instance: accountInstance, 585 + }; 586 + } else if (hasModal || !sameInstance || (hasAccount && hasStatus)) { 587 + // Show sheet of notification, if 588 + // - there is a modal open 589 + // - the notification is from another instance 590 + // - the notification has both account and status, gives choice for users to go to account or status 591 + setShowNotificationSheet({ 592 + id, 593 + account, 594 + notification, 595 + sameInstance, 596 + }); 597 + } else { 598 + if (hasStatus) { 599 + // Go to status page 600 + location.hash = `/${currentInstance}/s/${status.id}`; 601 + } else if (isFollow) { 602 + // Go to profile page 603 + location.hash = `/${currentInstance}/a/${notificationAccount.id}`; 604 + } else { 605 + // Go to notifications page 606 + location.hash = '/notifications'; 607 + } 608 + } 609 + } else { 610 + console.warn( 611 + '🛎️ Notification not found', 612 + notificationID, 613 + notificationAccessToken, 614 + ); 615 + } 616 + })(); 617 + }, [id, accessToken]); 618 + 619 + useLayoutEffect(() => { 620 + // Listen to message from service worker 621 + const handleMessage = (event) => { 622 + console.log('💥💥💥 Message event', event); 623 + const { type, id, accessToken } = event?.data || {}; 624 + if (type === 'notification') { 625 + states.routeNotification = { 626 + id, 627 + accessToken, 628 + }; 629 + } 630 + }; 631 + console.log('👂👂👂 Listen to message'); 632 + navigator.serviceWorker.addEventListener('message', handleMessage); 633 + return () => { 634 + console.log('👂👂👂 Remove listen to message'); 635 + navigator.serviceWorker.removeEventListener('message', handleMessage); 636 + }; 637 + }, []); 638 + 639 + const onClose = () => { 640 + setShowNotificationSheet(false); 641 + states.routeNotification = null; 642 + 643 + // If url is #/notifications?id=123, go to #/notifications 644 + if (/\/notifications\?id=/i.test(location.hash)) { 645 + location.hash = '/notifications'; 646 + } 647 + }; 648 + 649 + if (showNotificationSheet) { 650 + const { id, account, notification, sameInstance } = showNotificationSheet; 651 + return ( 652 + <Modal 653 + class="light" 654 + onClick={(e) => { 655 + if (e.target === e.currentTarget) { 656 + onClose(); 657 + } 658 + }} 659 + > 660 + <div class="sheet" tabIndex="-1"> 661 + <button type="button" class="sheet-close" onClick={onClose}> 662 + <Icon icon="x" /> 663 + </button> 664 + <header> 665 + <b>Notification</b> 666 + </header> 667 + <main> 668 + {!sameInstance && ( 669 + <p>This notification is from your other account.</p> 670 + )} 671 + <div 672 + class="notification-peek" 673 + // style={{ 674 + // pointerEvents: sameInstance ? '' : 'none', 675 + // }} 676 + onClick={(e) => { 677 + const { target } = e; 678 + // If button or links 679 + if (e.target.tagName === 'BUTTON' || e.target.tagName === 'A') { 680 + onClose(); 681 + } 682 + }} 683 + > 684 + <Notification 685 + instance={account.instanceURL} 686 + notification={notification} 687 + isStatic 688 + /> 689 + </div> 690 + <div 691 + style={{ 692 + textAlign: 'end', 693 + }} 694 + > 695 + <Link to="/notifications" class="button light"> 696 + <span>View all notifications</span> <Icon icon="arrow-right" /> 697 + </Link> 698 + </div> 699 + </main> 700 + </div> 701 + </Modal> 702 + ); 703 + } 536 704 537 705 return null; 538 706 }
+8 -3
src/components/notification.jsx
··· 56 56 'favourite+reblog_reply': 'boosted & favourited your reply.', 57 57 }; 58 58 59 - function Notification({ notification, instance, reload }) { 59 + function Notification({ notification, instance, reload, isStatic }) { 60 60 const { id, status, account, _accounts, _statuses } = notification; 61 61 let { type } = notification; 62 62 63 63 // status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update 64 - const actualStatusID = status?.reblog?.id || status?.id; 64 + const actualStatus = status?.reblog || status; 65 + const actualStatusID = actualStatus?.id; 65 66 66 67 const currentAccount = store.session.get('currentAccount'); 67 68 const isSelf = currentAccount === account?.id; ··· 242 243 : `/s/${actualStatusID}` 243 244 } 244 245 > 245 - <Status statusID={actualStatusID} size="s" /> 246 + {isStatic ? ( 247 + <Status status={actualStatus} size="s" /> 248 + ) : ( 249 + <Status statusID={actualStatusID} size="s" /> 250 + )} 246 251 </Link> 247 252 )} 248 253 </div>
+9 -4
src/pages/login.jsx
··· 1 1 import './login.css'; 2 2 3 3 import { useEffect, useRef, useState } from 'preact/hooks'; 4 + import { useSearchParams } from 'react-router-dom'; 4 5 5 6 import Link from '../components/link'; 6 7 import Loader from '../components/loader'; ··· 14 15 const instanceURLRef = useRef(); 15 16 const cachedInstanceURL = store.local.get('instanceURL'); 16 17 const [uiState, setUIState] = useState('default'); 18 + const [searchParams] = useSearchParams(); 19 + const instance = searchParams.get('instance'); 17 20 const [instanceText, setInstanceText] = useState( 18 - cachedInstanceURL?.toLowerCase() || '', 21 + instance || cachedInstanceURL?.toLowerCase() || '', 19 22 ); 20 23 21 24 const [instancesList, setInstancesList] = useState([]); ··· 44 47 (async () => { 45 48 setUIState('loading'); 46 49 try { 47 - const { client_id, client_secret } = await registerApplication({ 48 - instanceURL, 49 - }); 50 + const { client_id, client_secret, vapid_key } = 51 + await registerApplication({ 52 + instanceURL, 53 + }); 50 54 51 55 if (client_id && client_secret) { 52 56 store.session.set('clientID', client_id); 53 57 store.session.set('clientSecret', client_secret); 58 + store.session.set('vapidKey', vapid_key); 54 59 55 60 location.href = await getAuthorizationURL({ 56 61 instanceURL,
+32 -1
src/pages/notifications.jsx
··· 3 3 import { useIdle } from '@uidotdev/usehooks'; 4 4 import { memo } from 'preact/compat'; 5 5 import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; 6 + import { useSearchParams } from 'react-router-dom'; 6 7 import { useSnapshot } from 'valtio'; 7 8 8 9 import AccountBlock from '../components/account-block'; ··· 17 18 import groupNotifications from '../utils/group-notifications'; 18 19 import handleContentLinks from '../utils/handle-content-links'; 19 20 import niceDateTime from '../utils/nice-date-time'; 21 + import { getRegistration } from '../utils/push-notifications'; 20 22 import shortenNumber from '../utils/shorten-number'; 21 23 import states, { saveStatus } from '../utils/states'; 22 24 import { getCurrentInstance } from '../utils/store-utils'; ··· 24 26 import useTitle from '../utils/useTitle'; 25 27 26 28 const LIMIT = 30; // 30 is the maximum limit :( 29 + const emptySearchParams = new URLSearchParams(); 27 30 28 - function Notifications() { 31 + function Notifications({ columnMode }) { 29 32 useTitle('Notifications', '/notifications'); 30 33 const { masto, instance } = api(); 31 34 const snapStates = useSnapshot(states); 32 35 const [uiState, setUIState] = useState('default'); 36 + const [searchParams] = columnMode ? [emptySearchParams] : useSearchParams(); 37 + const notificationID = searchParams.get('id'); 38 + const notificationAccessToken = searchParams.get('access_token'); 33 39 const [showMore, setShowMore] = useState(false); 34 40 const [onlyMentions, setOnlyMentions] = useState(false); 35 41 const scrollableRef = useRef(); ··· 187 193 ); 188 194 189 195 const announcementsListRef = useRef(); 196 + 197 + useEffect(() => { 198 + if (notificationID) { 199 + states.routeNotification = { 200 + id: notificationID, 201 + accessToken: atob(notificationAccessToken), 202 + }; 203 + } 204 + }, [notificationID, notificationAccessToken]); 205 + 206 + useEffect(() => { 207 + if (uiState === 'default') { 208 + (async () => { 209 + const registration = await getRegistration(); 210 + if (registration) { 211 + const notifications = await registration.getNotifications(); 212 + console.log('🔔 Push notifications', notifications); 213 + // Close all notifications? 214 + // notifications.forEach((notification) => { 215 + // notification.close(); 216 + // }); 217 + } 218 + })(); 219 + } 220 + }, [uiState]); 190 221 191 222 return ( 192 223 <div
+7
src/pages/settings.css
··· 7 7 text-transform: uppercase; 8 8 color: var(--text-insignificant-color); 9 9 font-weight: normal; 10 + padding-inline: 16px; 10 11 } 11 12 12 13 #settings-container section { ··· 128 129 gap: 4px; 129 130 align-items: flex-start; 130 131 } 132 + 133 + #settings-container .section-postnote { 134 + margin-bottom: 48px; 135 + padding-inline: 16px; 136 + color: var(--text-insignificant-color); 137 + }
+248
src/pages/settings.jsx
··· 5 5 6 6 import logo from '../assets/logo.svg'; 7 7 import Icon from '../components/icon'; 8 + import Link from '../components/link'; 8 9 import RelativeTime from '../components/relative-time'; 9 10 import targetLanguages from '../data/lingva-target-languages'; 10 11 import { api } from '../utils/api'; 11 12 import getTranslateTargetLanguage from '../utils/get-translate-target-language'; 12 13 import localeCode2Text from '../utils/localeCode2Text'; 14 + import { 15 + initSubscription, 16 + isPushSupported, 17 + removeSubscription, 18 + updateSubscription, 19 + } from '../utils/push-notifications'; 13 20 import states from '../utils/states'; 14 21 import store from '../utils/store'; 15 22 ··· 391 398 </li> 392 399 </ul> 393 400 </section> 401 + <PushNotificationsSection onClose={onClose} /> 394 402 <h3>About</h3> 395 403 <section> 396 404 <div ··· 472 480 </section> 473 481 </main> 474 482 </div> 483 + ); 484 + } 485 + 486 + function PushNotificationsSection({ onClose }) { 487 + if (!isPushSupported()) return null; 488 + 489 + const { instance } = api(); 490 + const [uiState, setUIState] = useState('default'); 491 + const pushFormRef = useRef(); 492 + const [allowNofitications, setAllowNotifications] = useState(false); 493 + const [needRelogin, setNeedRelogin] = useState(false); 494 + const previousPolicyRef = useRef(); 495 + useEffect(() => { 496 + (async () => { 497 + setUIState('loading'); 498 + try { 499 + const { subscription, backendSubscription } = await initSubscription(); 500 + if ( 501 + backendSubscription?.policy && 502 + backendSubscription.policy !== 'none' 503 + ) { 504 + setAllowNotifications(true); 505 + const { alerts, policy } = backendSubscription; 506 + previousPolicyRef.current = policy; 507 + const { elements } = pushFormRef.current; 508 + const policyEl = elements.namedItem(policy); 509 + if (policyEl) policyEl.value = policy; 510 + // alerts is {}, iterate it 511 + Object.keys(alerts).forEach((alert) => { 512 + const el = elements.namedItem(alert); 513 + if (el?.type === 'checkbox') { 514 + el.checked = true; 515 + } 516 + }); 517 + } 518 + setUIState('default'); 519 + } catch (err) { 520 + console.warn(err); 521 + if (/outside.*authorized/i.test(err.message)) { 522 + setNeedRelogin(true); 523 + } else { 524 + alert(err?.message || err); 525 + } 526 + setUIState('error'); 527 + } 528 + })(); 529 + }, []); 530 + 531 + const isLoading = uiState === 'loading'; 532 + 533 + return ( 534 + <form 535 + ref={pushFormRef} 536 + onChange={() => { 537 + const values = Object.fromEntries(new FormData(pushFormRef.current)); 538 + const allowNofitications = !!values['policy-allow']; 539 + const params = { 540 + policy: values.policy, 541 + data: { 542 + alerts: { 543 + mention: !!values.mention, 544 + favourite: !!values.favourite, 545 + reblog: !!values.reblog, 546 + follow: !!values.follow, 547 + follow_request: !!values.followRequest, 548 + poll: !!values.poll, 549 + update: !!values.update, 550 + status: !!values.status, 551 + }, 552 + }, 553 + }; 554 + 555 + let alertsCount = 0; 556 + // Remove false values from data.alerts 557 + // API defaults to false anyway 558 + Object.keys(params.data.alerts).forEach((key) => { 559 + if (!params.data.alerts[key]) { 560 + delete params.data.alerts[key]; 561 + } else { 562 + alertsCount++; 563 + } 564 + }); 565 + const policyChanged = previousPolicyRef.current !== params.policy; 566 + 567 + console.log('PN Form', { values, allowNofitications, params }); 568 + 569 + if (allowNofitications && alertsCount > 0) { 570 + if (policyChanged) { 571 + console.debug('Policy changed.'); 572 + removeSubscription() 573 + .then(() => { 574 + updateSubscription(params); 575 + }) 576 + .catch((err) => { 577 + console.warn(err); 578 + alert('Failed to update subscription. Please try again.'); 579 + }); 580 + } else { 581 + updateSubscription(params).catch((err) => { 582 + console.warn(err); 583 + alert('Failed to update subscription. Please try again.'); 584 + }); 585 + } 586 + } else { 587 + removeSubscription().catch((err) => { 588 + console.warn(err); 589 + alert('Failed to remove subscription. Please try again.'); 590 + }); 591 + } 592 + }} 593 + > 594 + <h3>Push Notifications (beta)</h3> 595 + <section> 596 + <ul> 597 + <li> 598 + <label> 599 + <input 600 + type="checkbox" 601 + disabled={isLoading || needRelogin} 602 + name="policy-allow" 603 + checked={allowNofitications} 604 + onChange={async (e) => { 605 + const { checked } = e.target; 606 + if (checked) { 607 + // Request permission 608 + const permission = await Notification.requestPermission(); 609 + if (permission === 'granted') { 610 + setAllowNotifications(true); 611 + } else { 612 + setAllowNotifications(false); 613 + if (permission === 'denied') { 614 + alert( 615 + 'Push notifications are blocked. Please enable them in your browser settings.', 616 + ); 617 + } 618 + } 619 + } else { 620 + setAllowNotifications(false); 621 + } 622 + }} 623 + />{' '} 624 + Allow from{' '} 625 + <select 626 + name="policy" 627 + disabled={isLoading || needRelogin || !allowNofitications} 628 + > 629 + {[ 630 + { 631 + value: 'all', 632 + label: 'anyone', 633 + }, 634 + { 635 + value: 'followed', 636 + label: 'people I follow', 637 + }, 638 + { 639 + value: 'follower', 640 + label: 'followers', 641 + }, 642 + ].map((type) => ( 643 + <option value={type.value}>{type.label}</option> 644 + ))} 645 + </select> 646 + </label> 647 + <div 648 + class="shazam-container no-animation" 649 + style={{ 650 + width: '100%', 651 + }} 652 + hidden={!allowNofitications} 653 + > 654 + <div class="shazam-container-inner"> 655 + <div class="sub-section"> 656 + <ul> 657 + {[ 658 + { 659 + value: 'mention', 660 + label: 'Mentions', 661 + }, 662 + { 663 + value: 'favourite', 664 + label: 'Favourites', 665 + }, 666 + { 667 + value: 'reblog', 668 + label: 'Boosts', 669 + }, 670 + { 671 + value: 'follow', 672 + label: 'Follows', 673 + }, 674 + { 675 + value: 'followRequest', 676 + label: 'Follow requests', 677 + }, 678 + { 679 + value: 'poll', 680 + label: 'Polls', 681 + }, 682 + { 683 + value: 'update', 684 + label: 'Post edits', 685 + }, 686 + { 687 + value: 'status', 688 + label: 'New posts', 689 + }, 690 + ].map((alert) => ( 691 + <li> 692 + <label> 693 + <input type="checkbox" name={alert.value} />{' '} 694 + {alert.label} 695 + </label> 696 + </li> 697 + ))} 698 + </ul> 699 + </div> 700 + </div> 701 + </div> 702 + {needRelogin && ( 703 + <div class="sub-section"> 704 + <p> 705 + Push permission was not granted since your last login. You'll 706 + need to{' '} 707 + <Link to={`/login?instance=${instance}`} onClick={onClose}> 708 + <b>log in</b> again to grant push permission 709 + </Link> 710 + . 711 + </p> 712 + </div> 713 + )} 714 + </li> 715 + </ul> 716 + </section> 717 + <p class="section-postnote"> 718 + <small> 719 + NOTE: Push notifications only works for <b>one account</b>. 720 + </small> 721 + </p> 722 + </form> 475 723 ); 476 724 } 477 725
+38 -2
src/utils/api.js
··· 1 1 import { createClient } from 'masto'; 2 2 3 3 import store from './store'; 4 - import { getAccount, getCurrentAccount, saveAccount } from './store-utils'; 4 + import { 5 + getAccount, 6 + getAccountByAccessToken, 7 + getCurrentAccount, 8 + saveAccount, 9 + } from './store-utils'; 5 10 6 11 // Default *fallback* instance 7 12 const DEFAULT_INSTANCE = 'mastodon.social'; ··· 18 23 // Just in case if I need this one day. 19 24 // E.g. accountApis['mastodon.social']['ACCESS_TOKEN'] 20 25 const accountApis = {}; 26 + window.__ACCOUNT_APIS__ = accountApis; 21 27 22 28 // Current account masto instance 23 29 let currentAccountApi; ··· 92 98 } 93 99 94 100 // Get the account information and store it 95 - export async function initAccount(client, instance, accessToken) { 101 + export async function initAccount(client, instance, accessToken, vapidKey) { 96 102 const masto = client; 97 103 const mastoAccount = await masto.v1.accounts.verifyCredentials(); 98 104 ··· 102 108 info: mastoAccount, 103 109 instanceURL: instance.toLowerCase(), 104 110 accessToken, 111 + vapidKey, 105 112 }); 106 113 } 107 114 ··· 134 141 authenticated: true, 135 142 instance, 136 143 }; 144 + } 145 + 146 + if (accessToken) { 147 + // If only accessToken is provided, get the masto instance for that accessToken 148 + console.log('X 1', accountApis); 149 + for (const instance in accountApis) { 150 + if (accountApis[instance][accessToken]) { 151 + console.log('X 2', accountApis, instance, accessToken); 152 + return { 153 + masto: accountApis[instance][accessToken], 154 + authenticated: true, 155 + instance, 156 + }; 157 + } else { 158 + console.log('X 3', accountApis, instance, accessToken); 159 + const account = getAccountByAccessToken(accessToken); 160 + if (account) { 161 + const accessToken = account.accessToken; 162 + const instance = account.instanceURL.toLowerCase().trim(); 163 + return { 164 + masto: initClient({ instance, accessToken }), 165 + authenticated: true, 166 + instance, 167 + }; 168 + } else { 169 + throw new Error(`Access token ${accessToken} not found`); 170 + } 171 + } 172 + } 137 173 } 138 174 139 175 // If account is provided, get the masto instance for that account
+5 -3
src/utils/auth.js
··· 1 1 const { VITE_CLIENT_NAME: CLIENT_NAME, VITE_WEBSITE: WEBSITE } = import.meta 2 2 .env; 3 3 4 + const SCOPES = 'read write follow push'; 5 + 4 6 export async function registerApplication({ instanceURL }) { 5 7 const registrationParams = new URLSearchParams({ 6 8 client_name: CLIENT_NAME, 7 - scopes: 'read write follow', 8 9 redirect_uris: location.origin + location.pathname, 10 + scopes: SCOPES, 9 11 website: WEBSITE, 10 12 }); 11 13 const registrationResponse = await fetch( ··· 26 28 export async function getAuthorizationURL({ instanceURL, client_id }) { 27 29 const authorizationParams = new URLSearchParams({ 28 30 client_id, 29 - scope: 'read write follow', 31 + scope: SCOPES, 30 32 redirect_uri: location.origin + location.pathname, 31 33 // redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', 32 34 response_type: 'code', ··· 47 49 redirect_uri: location.origin + location.pathname, 48 50 grant_type: 'authorization_code', 49 51 code, 50 - scope: 'read write follow', 52 + scope: SCOPES, 51 53 }); 52 54 const tokenResponse = await fetch(`https://${instanceURL}/oauth/token`, { 53 55 method: 'POST',
+233
src/utils/push-notifications.js
··· 1 + // Utils for push notifications 2 + import { api } from './api'; 3 + import { getCurrentAccount } from './store-utils'; 4 + 5 + // Subscription is an object with the following structure: 6 + // { 7 + // data: { 8 + // alerts: { 9 + // admin: { 10 + // report: boolean, 11 + // signUp: boolean, 12 + // }, 13 + // favourite: boolean, 14 + // follow: boolean, 15 + // mention: boolean, 16 + // poll: boolean, 17 + // reblog: boolean, 18 + // status: boolean, 19 + // update: boolean, 20 + // } 21 + // }, 22 + // policy: "all" | "followed" | "follower" | "none", 23 + // subscription: { 24 + // endpoint: string, 25 + // keys: { 26 + // auth: string, 27 + // p256dh: string, 28 + // }, 29 + // }, 30 + // } 31 + 32 + // Back-end CRUD 33 + // ============= 34 + 35 + function createBackendPushSubscription(subscription) { 36 + const { masto } = api(); 37 + return masto.v1.webPushSubscriptions.create(subscription); 38 + } 39 + 40 + function fetchBackendPushSubscription() { 41 + const { masto } = api(); 42 + return masto.v1.webPushSubscriptions.fetch(); 43 + } 44 + 45 + function updateBackendPushSubscription(subscription) { 46 + const { masto } = api(); 47 + return masto.v1.webPushSubscriptions.update(subscription); 48 + } 49 + 50 + function removeBackendPushSubscription() { 51 + const { masto } = api(); 52 + return masto.v1.webPushSubscriptions.remove(); 53 + } 54 + 55 + // Front-end 56 + // ========= 57 + 58 + export function isPushSupported() { 59 + return 'serviceWorker' in navigator && 'PushManager' in window; 60 + } 61 + 62 + export function getRegistration() { 63 + // return navigator.serviceWorker.ready; 64 + return navigator.serviceWorker.getRegistration(); 65 + } 66 + 67 + async function getSubscription() { 68 + const registration = await getRegistration(); 69 + const subscription = registration 70 + ? await registration.pushManager.getSubscription() 71 + : undefined; 72 + return { registration, subscription }; 73 + } 74 + 75 + function urlBase64ToUint8Array(base64String) { 76 + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); 77 + const base64 = `${base64String}${padding}` 78 + .replace(/-/g, '+') 79 + .replace(/_/g, '/'); 80 + 81 + const rawData = window.atob(base64); 82 + const outputArray = new Uint8Array(rawData.length); 83 + 84 + for (let i = 0; i < rawData.length; ++i) { 85 + outputArray[i] = rawData.charCodeAt(i); 86 + } 87 + 88 + return outputArray; 89 + } 90 + 91 + // Front-end <-> back-end 92 + // ====================== 93 + 94 + export async function initSubscription() { 95 + if (!isPushSupported()) return; 96 + const { subscription } = await getSubscription(); 97 + let backendSubscription = null; 98 + try { 99 + backendSubscription = await fetchBackendPushSubscription(); 100 + } catch (err) { 101 + if (/(not found|unknown)/i.test(err.message)) { 102 + // No subscription found 103 + } else { 104 + // Other error 105 + throw err; 106 + } 107 + } 108 + console.log('INIT subscription', { 109 + subscription, 110 + backendSubscription, 111 + }); 112 + 113 + // Check if the subscription changed 114 + if (backendSubscription && subscription) { 115 + const sameEndpoint = backendSubscription.endpoint === subscription.endpoint; 116 + const { vapidKey } = getCurrentAccount(); 117 + const sameKey = backendSubscription.serverKey === vapidKey; 118 + if (!sameEndpoint) { 119 + throw new Error('Backend subscription endpoint changed'); 120 + } 121 + if (sameKey) { 122 + // Subscription didn't change 123 + } else { 124 + // Subscription changed 125 + console.error('🔔 Subscription changed', { 126 + sameEndpoint, 127 + serverKey: backendSubscription.serverKey, 128 + vapIdKey: vapidKey, 129 + endpoint1: backendSubscription.endpoint, 130 + endpoint2: subscription.endpoint, 131 + sameKey, 132 + key1: backendSubscription.serverKey, 133 + key2: vapidKey, 134 + }); 135 + throw new Error('Backend subscription key and vapid key changed'); 136 + // Only unsubscribe from backend, not from browser 137 + // await removeBackendPushSubscription(); 138 + // // Now let's resubscribe 139 + // // NOTE: I have no idea if this works 140 + // return await updateSubscription({ 141 + // data: backendSubscription.data, 142 + // policy: backendSubscription.policy, 143 + // }); 144 + } 145 + } 146 + 147 + if (subscription && !backendSubscription) { 148 + // check if account's vapidKey is same as subscription's applicationServerKey 149 + const { vapidKey } = getCurrentAccount(); 150 + const { applicationServerKey } = subscription.options; 151 + const vapidKeyStr = urlBase64ToUint8Array(vapidKey).toString(); 152 + const applicationServerKeyStr = new Uint8Array( 153 + applicationServerKey, 154 + ).toString(); 155 + const sameKey = vapidKeyStr === applicationServerKeyStr; 156 + if (sameKey) { 157 + // Subscription didn't change 158 + } else { 159 + // Subscription changed 160 + console.error('🔔 Subscription changed', { 161 + vapidKeyStr, 162 + applicationServerKeyStr, 163 + sameKey, 164 + }); 165 + // Unsubscribe since backend doesn't have a subscription 166 + await subscription.unsubscribe(); 167 + throw new Error('Subscription key and vapid key changed'); 168 + } 169 + } 170 + 171 + // Check if backend subscription returns 404 172 + // if (subscription && !backendSubscription) { 173 + // // Re-subscribe to backend 174 + // backendSubscription = await createBackendPushSubscription({ 175 + // subscription, 176 + // data: {}, 177 + // policy: 'all', 178 + // }); 179 + // } 180 + 181 + return { subscription, backendSubscription }; 182 + } 183 + 184 + export async function updateSubscription({ data, policy }) { 185 + console.log('🔔 Updating subscription', { data, policy }); 186 + if (!isPushSupported()) return; 187 + let { registration, subscription } = await getSubscription(); 188 + let backendSubscription = null; 189 + 190 + if (subscription) { 191 + try { 192 + backendSubscription = await updateBackendPushSubscription({ 193 + data, 194 + policy, 195 + }); 196 + // TODO: save subscription in user settings 197 + } catch (error) { 198 + // Backend doesn't have a subscription for this user 199 + // Create a new one 200 + backendSubscription = await createBackendPushSubscription({ 201 + subscription, 202 + data, 203 + policy, 204 + }); 205 + // TODO: save subscription in user settings 206 + } 207 + } else { 208 + // User is not subscribed 209 + const { vapidKey } = getCurrentAccount(); 210 + if (!vapidKey) throw new Error('No server key found'); 211 + subscription = await registration.pushManager.subscribe({ 212 + userVisibleOnly: true, 213 + applicationServerKey: urlBase64ToUint8Array(vapidKey), 214 + }); 215 + backendSubscription = await createBackendPushSubscription({ 216 + subscription, 217 + data, 218 + policy, 219 + }); 220 + // TODO: save subscription in user settings 221 + } 222 + 223 + return { subscription, backendSubscription }; 224 + } 225 + 226 + export async function removeSubscription() { 227 + if (!isPushSupported()) return; 228 + const { subscription } = await getSubscription(); 229 + if (subscription) { 230 + await removeBackendPushSubscription(); 231 + await subscription.unsubscribe(); 232 + } 233 + }
+1
src/utils/states.js
··· 29 29 unfurledLinks: {}, 30 30 statusQuotes: {}, 31 31 accounts: {}, 32 + routeNotification: null, 32 33 // Modals 33 34 showCompose: false, 34 35 showSettings: false,
+6
src/utils/store-utils.js
··· 5 5 return accounts.find((a) => a.info.id === id) || accounts[0]; 6 6 } 7 7 8 + export function getAccountByAccessToken(accessToken) { 9 + const accounts = store.local.getJSON('accounts') || []; 10 + return accounts.find((a) => a.accessToken === accessToken); 11 + } 12 + 8 13 export function getCurrentAccount() { 9 14 const currentAccount = store.session.get('currentAccount'); 10 15 const account = getAccount(currentAccount); ··· 27 32 acc.info = account.info; 28 33 acc.instanceURL = account.instanceURL; 29 34 acc.accessToken = account.accessToken; 35 + acc.vapidKey = account.vapidKey; 30 36 } else { 31 37 accounts.push(account); 32 38 }