Yes, push notifications (beta).

Heck this feature is tough.
This commit is contained in:
Lim Chee Aun 2023-09-01 15:40:00 +08:00
parent 0b04e01d60
commit 0e745663f0
12 changed files with 854 additions and 15 deletions

View file

@ -94,3 +94,100 @@ const apiRoute = new RegExpRoute(
}), }),
); );
registerRoute(apiRoute); registerRoute(apiRoute);
// PUSH NOTIFICATIONS
// ==================
self.addEventListener('push', (event) => {
const { data } = event;
if (data) {
const payload = data.json();
console.log('PUSH payload', payload);
const {
access_token,
title,
body,
icon,
notification_id,
notification_type,
preferred_locale,
} = payload;
if (!!navigator.setAppBadge) {
if (notification_type === 'mention') {
navigator.setAppBadge(1);
}
}
event.waitUntil(
self.registration.showNotification(title, {
body,
icon,
dir: 'auto',
badge: '/logo-192.png',
lang: preferred_locale,
tag: notification_id,
timestamp: Date.now(),
data: {
access_token,
notification_type,
},
}),
);
}
});
self.addEventListener('notificationclick', (event) => {
const payload = event.notification;
console.log('NOTIFICATION CLICK payload', payload);
const { badge, body, data, dir, icon, lang, tag, timestamp, title } = payload;
const { access_token, notification_type } = data;
const actions = new Promise((resolve) => {
event.notification.close();
const url = `/#/notifications?id=${tag}&access_token=${btoa(access_token)}`;
self.clients
.matchAll({
type: 'window',
includeUncontrolled: true,
})
.then((clients) => {
console.log('NOTIFICATION CLICK clients 1', clients);
if (clients.length && 'navigate' in clients[0]) {
console.log('NOTIFICATION CLICK clients 2', clients);
const bestClient =
clients.find(
(client) =>
client.focused || client.visibilityState === 'visible',
) || clients[0];
console.log('NOTIFICATION CLICK navigate', url);
// Check if URL is root / or /notifications
// const clientURL = new URL(bestClient.url);
// if (
// /^#\/?$/.test(clientURL.hash) ||
// /^#\/notifications/i.test(clientURL.hash)
// ) {
// bestClient.navigate(url).then((client) => client?.focus());
// } else {
// User might be on a different page (e.g. composing a post), so don't navigate anywhere else
if (bestClient) {
console.log('NOTIFICATION CLICK postMessage', bestClient);
bestClient.postMessage?.({
type: 'notification',
id: tag,
accessToken: access_token,
});
bestClient.focus();
} else {
console.log('NOTIFICATION CLICK openWindow', url);
self.clients.openWindow(url);
}
// }
} else {
console.log('NOTIFICATION CLICK openWindow', url);
self.clients.openWindow(url);
}
resolve();
});
});
event.waitUntil(actions);
});

View file

@ -22,9 +22,11 @@ import AccountSheet from './components/account-sheet';
import Compose from './components/compose'; import Compose from './components/compose';
import Drafts from './components/drafts'; import Drafts from './components/drafts';
import Icon, { ICONS } from './components/icon'; import Icon, { ICONS } from './components/icon';
import Link from './components/link';
import Loader from './components/loader'; import Loader from './components/loader';
import MediaModal from './components/media-modal'; import MediaModal from './components/media-modal';
import Modal from './components/modal'; import Modal from './components/modal';
import Notification from './components/notification';
import Shortcuts from './components/shortcuts'; import Shortcuts from './components/shortcuts';
import ShortcutsSettings from './components/shortcuts-settings'; import ShortcutsSettings from './components/shortcuts-settings';
import NotFound from './pages/404'; import NotFound from './pages/404';
@ -60,7 +62,11 @@ import openCompose from './utils/open-compose';
import showToast from './utils/show-toast'; import showToast from './utils/show-toast';
import states, { initStates, saveStatus } from './utils/states'; import states, { initStates, saveStatus } from './utils/states';
import store from './utils/store'; import store from './utils/store';
import { getCurrentAccount } from './utils/store-utils'; import {
getAccountByAccessToken,
getCurrentAccount,
} from './utils/store-utils';
import './utils/toast-alert';
import useInterval from './utils/useInterval'; import useInterval from './utils/useInterval';
import usePageVisibility from './utils/usePageVisibility'; import usePageVisibility from './utils/usePageVisibility';
@ -115,6 +121,7 @@ function App() {
const clientID = store.session.get('clientID'); const clientID = store.session.get('clientID');
const clientSecret = store.session.get('clientSecret'); const clientSecret = store.session.get('clientSecret');
const vapidKey = store.session.get('vapidKey');
(async () => { (async () => {
setUIState('loading'); setUIState('loading');
@ -128,7 +135,7 @@ function App() {
const masto = initClient({ instance: instanceURL, accessToken }); const masto = initClient({ instance: instanceURL, accessToken });
await Promise.allSettled([ await Promise.allSettled([
initInstance(masto, instanceURL), initInstance(masto, instanceURL),
initAccount(masto, instanceURL, accessToken), initAccount(masto, instanceURL, accessToken, vapidKey),
]); ]);
initStates(); initStates();
initPreferences(masto); initPreferences(masto);
@ -446,6 +453,7 @@ function App() {
/> />
</Modal> </Modal>
)} )}
<NotificationService />
<BackgroundService isLoggedIn={isLoggedIn} /> <BackgroundService isLoggedIn={isLoggedIn} />
</> </>
); );
@ -537,6 +545,166 @@ function BackgroundService({ isLoggedIn }) {
return null; return null;
} }
function NotificationService() {
if (!('serviceWorker' in navigator)) return null;
const snapStates = useSnapshot(states);
const { routeNotification } = snapStates;
console.log('🛎️ Notification service', routeNotification);
const { id, accessToken } = routeNotification || {};
const [showNotificationSheet, setShowNotificationSheet] = useState(false);
useLayoutEffect(() => {
if (!id || !accessToken) return;
const { instance: currentInstance } = api();
const { masto, instance } = api({
accessToken,
});
console.log('API', { accessToken, currentInstance, instance });
const sameInstance = currentInstance === instance;
const account = accessToken
? getAccountByAccessToken(accessToken)
: getCurrentAccount();
(async () => {
const notification = await masto.v1.notifications.fetch(id);
if (notification && account) {
console.log('🛎️ Notification', { id, notification, account });
const accountInstance = account.instanceURL;
const { type, status, account: notificationAccount } = notification;
const hasModal = !!document.querySelector('#modal-container > *');
const isFollow = type === 'follow' && !!notificationAccount?.id;
const hasAccount = !!notificationAccount?.id;
const hasStatus = !!status?.id;
if (isFollow && sameInstance) {
// Show account sheet, can handle different instances
states.showAccount = {
account: notificationAccount,
instance: accountInstance,
};
} else if (hasModal || !sameInstance || (hasAccount && hasStatus)) {
// Show sheet of notification, if
// - there is a modal open
// - the notification is from another instance
// - the notification has both account and status, gives choice for users to go to account or status
setShowNotificationSheet({
id,
account,
notification,
sameInstance,
});
} else {
if (hasStatus) {
// Go to status page
location.hash = `/${currentInstance}/s/${status.id}`;
} else if (isFollow) {
// Go to profile page
location.hash = `/${currentInstance}/a/${notificationAccount.id}`;
} else {
// Go to notifications page
location.hash = '/notifications';
}
}
} else {
console.warn(
'🛎️ Notification not found',
notificationID,
notificationAccessToken,
);
}
})();
}, [id, accessToken]);
useLayoutEffect(() => {
// Listen to message from service worker
const handleMessage = (event) => {
console.log('💥💥💥 Message event', event);
const { type, id, accessToken } = event?.data || {};
if (type === 'notification') {
states.routeNotification = {
id,
accessToken,
};
}
};
console.log('👂👂👂 Listen to message');
navigator.serviceWorker.addEventListener('message', handleMessage);
return () => {
console.log('👂👂👂 Remove listen to message');
navigator.serviceWorker.removeEventListener('message', handleMessage);
};
}, []);
const onClose = () => {
setShowNotificationSheet(false);
states.routeNotification = null;
// If url is #/notifications?id=123, go to #/notifications
if (/\/notifications\?id=/i.test(location.hash)) {
location.hash = '/notifications';
}
};
if (showNotificationSheet) {
const { id, account, notification, sameInstance } = showNotificationSheet;
return (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div class="sheet" tabIndex="-1">
<button type="button" class="sheet-close" onClick={onClose}>
<Icon icon="x" />
</button>
<header>
<b>Notification</b>
</header>
<main>
{!sameInstance && (
<p>This notification is from your other account.</p>
)}
<div
class="notification-peek"
// style={{
// pointerEvents: sameInstance ? '' : 'none',
// }}
onClick={(e) => {
const { target } = e;
// If button or links
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'A') {
onClose();
}
}}
>
<Notification
instance={account.instanceURL}
notification={notification}
isStatic
/>
</div>
<div
style={{
textAlign: 'end',
}}
>
<Link to="/notifications" class="button light">
<span>View all notifications</span> <Icon icon="arrow-right" />
</Link>
</div>
</main>
</div>
</Modal>
);
}
return null;
}
function StatusRoute() { function StatusRoute() {
const params = useParams(); const params = useParams();
const { id, instance } = params; const { id, instance } = params;

View file

@ -56,12 +56,13 @@ const contentText = {
'favourite+reblog_reply': 'boosted & favourited your reply.', 'favourite+reblog_reply': 'boosted & favourited your reply.',
}; };
function Notification({ notification, instance, reload }) { function Notification({ notification, instance, reload, isStatic }) {
const { id, status, account, _accounts, _statuses } = notification; const { id, status, account, _accounts, _statuses } = notification;
let { type } = notification; let { type } = notification;
// status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update // status = Attached when type of the notification is favourite, reblog, status, mention, poll, or update
const actualStatusID = status?.reblog?.id || status?.id; const actualStatus = status?.reblog || status;
const actualStatusID = actualStatus?.id;
const currentAccount = store.session.get('currentAccount'); const currentAccount = store.session.get('currentAccount');
const isSelf = currentAccount === account?.id; const isSelf = currentAccount === account?.id;
@ -242,7 +243,11 @@ function Notification({ notification, instance, reload }) {
: `/s/${actualStatusID}` : `/s/${actualStatusID}`
} }
> >
<Status statusID={actualStatusID} size="s" /> {isStatic ? (
<Status status={actualStatus} size="s" />
) : (
<Status statusID={actualStatusID} size="s" />
)}
</Link> </Link>
)} )}
</div> </div>

View file

@ -1,6 +1,7 @@
import './login.css'; import './login.css';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { useSearchParams } from 'react-router-dom';
import Link from '../components/link'; import Link from '../components/link';
import Loader from '../components/loader'; import Loader from '../components/loader';
@ -14,8 +15,10 @@ function Login() {
const instanceURLRef = useRef(); const instanceURLRef = useRef();
const cachedInstanceURL = store.local.get('instanceURL'); const cachedInstanceURL = store.local.get('instanceURL');
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
const [searchParams] = useSearchParams();
const instance = searchParams.get('instance');
const [instanceText, setInstanceText] = useState( const [instanceText, setInstanceText] = useState(
cachedInstanceURL?.toLowerCase() || '', instance || cachedInstanceURL?.toLowerCase() || '',
); );
const [instancesList, setInstancesList] = useState([]); const [instancesList, setInstancesList] = useState([]);
@ -44,13 +47,15 @@ function Login() {
(async () => { (async () => {
setUIState('loading'); setUIState('loading');
try { try {
const { client_id, client_secret } = await registerApplication({ const { client_id, client_secret, vapid_key } =
instanceURL, await registerApplication({
}); instanceURL,
});
if (client_id && client_secret) { if (client_id && client_secret) {
store.session.set('clientID', client_id); store.session.set('clientID', client_id);
store.session.set('clientSecret', client_secret); store.session.set('clientSecret', client_secret);
store.session.set('vapidKey', vapid_key);
location.href = await getAuthorizationURL({ location.href = await getAuthorizationURL({
instanceURL, instanceURL,

View file

@ -3,6 +3,7 @@ import './notifications.css';
import { useIdle } from '@uidotdev/usehooks'; import { useIdle } from '@uidotdev/usehooks';
import { memo } from 'preact/compat'; import { memo } from 'preact/compat';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { useSearchParams } from 'react-router-dom';
import { useSnapshot } from 'valtio'; import { useSnapshot } from 'valtio';
import AccountBlock from '../components/account-block'; import AccountBlock from '../components/account-block';
@ -17,6 +18,7 @@ import enhanceContent from '../utils/enhance-content';
import groupNotifications from '../utils/group-notifications'; import groupNotifications from '../utils/group-notifications';
import handleContentLinks from '../utils/handle-content-links'; import handleContentLinks from '../utils/handle-content-links';
import niceDateTime from '../utils/nice-date-time'; import niceDateTime from '../utils/nice-date-time';
import { getRegistration } from '../utils/push-notifications';
import shortenNumber from '../utils/shorten-number'; import shortenNumber from '../utils/shorten-number';
import states, { saveStatus } from '../utils/states'; import states, { saveStatus } from '../utils/states';
import { getCurrentInstance } from '../utils/store-utils'; import { getCurrentInstance } from '../utils/store-utils';
@ -24,12 +26,16 @@ import useScroll from '../utils/useScroll';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
const LIMIT = 30; // 30 is the maximum limit :( const LIMIT = 30; // 30 is the maximum limit :(
const emptySearchParams = new URLSearchParams();
function Notifications() { function Notifications({ columnMode }) {
useTitle('Notifications', '/notifications'); useTitle('Notifications', '/notifications');
const { masto, instance } = api(); const { masto, instance } = api();
const snapStates = useSnapshot(states); const snapStates = useSnapshot(states);
const [uiState, setUIState] = useState('default'); const [uiState, setUIState] = useState('default');
const [searchParams] = columnMode ? [emptySearchParams] : useSearchParams();
const notificationID = searchParams.get('id');
const notificationAccessToken = searchParams.get('access_token');
const [showMore, setShowMore] = useState(false); const [showMore, setShowMore] = useState(false);
const [onlyMentions, setOnlyMentions] = useState(false); const [onlyMentions, setOnlyMentions] = useState(false);
const scrollableRef = useRef(); const scrollableRef = useRef();
@ -188,6 +194,31 @@ function Notifications() {
const announcementsListRef = useRef(); const announcementsListRef = useRef();
useEffect(() => {
if (notificationID) {
states.routeNotification = {
id: notificationID,
accessToken: atob(notificationAccessToken),
};
}
}, [notificationID, notificationAccessToken]);
useEffect(() => {
if (uiState === 'default') {
(async () => {
const registration = await getRegistration();
if (registration) {
const notifications = await registration.getNotifications();
console.log('🔔 Push notifications', notifications);
// Close all notifications?
// notifications.forEach((notification) => {
// notification.close();
// });
}
})();
}
}, [uiState]);
return ( return (
<div <div
id="notifications-page" id="notifications-page"

View file

@ -7,6 +7,7 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--text-insignificant-color); color: var(--text-insignificant-color);
font-weight: normal; font-weight: normal;
padding-inline: 16px;
} }
#settings-container section { #settings-container section {
@ -128,3 +129,9 @@
gap: 4px; gap: 4px;
align-items: flex-start; align-items: flex-start;
} }
#settings-container .section-postnote {
margin-bottom: 48px;
padding-inline: 16px;
color: var(--text-insignificant-color);
}

View file

@ -5,11 +5,18 @@ import { useSnapshot } from 'valtio';
import logo from '../assets/logo.svg'; import logo from '../assets/logo.svg';
import Icon from '../components/icon'; import Icon from '../components/icon';
import Link from '../components/link';
import RelativeTime from '../components/relative-time'; import RelativeTime from '../components/relative-time';
import targetLanguages from '../data/lingva-target-languages'; import targetLanguages from '../data/lingva-target-languages';
import { api } from '../utils/api'; import { api } from '../utils/api';
import getTranslateTargetLanguage from '../utils/get-translate-target-language'; import getTranslateTargetLanguage from '../utils/get-translate-target-language';
import localeCode2Text from '../utils/localeCode2Text'; import localeCode2Text from '../utils/localeCode2Text';
import {
initSubscription,
isPushSupported,
removeSubscription,
updateSubscription,
} from '../utils/push-notifications';
import states from '../utils/states'; import states from '../utils/states';
import store from '../utils/store'; import store from '../utils/store';
@ -391,6 +398,7 @@ function Settings({ onClose }) {
</li> </li>
</ul> </ul>
</section> </section>
<PushNotificationsSection onClose={onClose} />
<h3>About</h3> <h3>About</h3>
<section> <section>
<div <div
@ -475,4 +483,244 @@ function Settings({ onClose }) {
); );
} }
function PushNotificationsSection({ onClose }) {
if (!isPushSupported()) return null;
const { instance } = api();
const [uiState, setUIState] = useState('default');
const pushFormRef = useRef();
const [allowNofitications, setAllowNotifications] = useState(false);
const [needRelogin, setNeedRelogin] = useState(false);
const previousPolicyRef = useRef();
useEffect(() => {
(async () => {
setUIState('loading');
try {
const { subscription, backendSubscription } = await initSubscription();
if (
backendSubscription?.policy &&
backendSubscription.policy !== 'none'
) {
setAllowNotifications(true);
const { alerts, policy } = backendSubscription;
previousPolicyRef.current = policy;
const { elements } = pushFormRef.current;
const policyEl = elements.namedItem(policy);
if (policyEl) policyEl.value = policy;
// alerts is {}, iterate it
Object.keys(alerts).forEach((alert) => {
const el = elements.namedItem(alert);
if (el?.type === 'checkbox') {
el.checked = true;
}
});
}
setUIState('default');
} catch (err) {
console.warn(err);
if (/outside.*authorized/i.test(err.message)) {
setNeedRelogin(true);
} else {
alert(err?.message || err);
}
setUIState('error');
}
})();
}, []);
const isLoading = uiState === 'loading';
return (
<form
ref={pushFormRef}
onChange={() => {
const values = Object.fromEntries(new FormData(pushFormRef.current));
const allowNofitications = !!values['policy-allow'];
const params = {
policy: values.policy,
data: {
alerts: {
mention: !!values.mention,
favourite: !!values.favourite,
reblog: !!values.reblog,
follow: !!values.follow,
follow_request: !!values.followRequest,
poll: !!values.poll,
update: !!values.update,
status: !!values.status,
},
},
};
let alertsCount = 0;
// Remove false values from data.alerts
// API defaults to false anyway
Object.keys(params.data.alerts).forEach((key) => {
if (!params.data.alerts[key]) {
delete params.data.alerts[key];
} else {
alertsCount++;
}
});
const policyChanged = previousPolicyRef.current !== params.policy;
console.log('PN Form', { values, allowNofitications, params });
if (allowNofitications && alertsCount > 0) {
if (policyChanged) {
console.debug('Policy changed.');
removeSubscription()
.then(() => {
updateSubscription(params);
})
.catch((err) => {
console.warn(err);
alert('Failed to update subscription. Please try again.');
});
} else {
updateSubscription(params).catch((err) => {
console.warn(err);
alert('Failed to update subscription. Please try again.');
});
}
} else {
removeSubscription().catch((err) => {
console.warn(err);
alert('Failed to remove subscription. Please try again.');
});
}
}}
>
<h3>Push Notifications (beta)</h3>
<section>
<ul>
<li>
<label>
<input
type="checkbox"
disabled={isLoading || needRelogin}
name="policy-allow"
checked={allowNofitications}
onChange={async (e) => {
const { checked } = e.target;
if (checked) {
// Request permission
const permission = await Notification.requestPermission();
if (permission === 'granted') {
setAllowNotifications(true);
} else {
setAllowNotifications(false);
if (permission === 'denied') {
alert(
'Push notifications are blocked. Please enable them in your browser settings.',
);
}
}
} else {
setAllowNotifications(false);
}
}}
/>{' '}
Allow from{' '}
<select
name="policy"
disabled={isLoading || needRelogin || !allowNofitications}
>
{[
{
value: 'all',
label: 'anyone',
},
{
value: 'followed',
label: 'people I follow',
},
{
value: 'follower',
label: 'followers',
},
].map((type) => (
<option value={type.value}>{type.label}</option>
))}
</select>
</label>
<div
class="shazam-container no-animation"
style={{
width: '100%',
}}
hidden={!allowNofitications}
>
<div class="shazam-container-inner">
<div class="sub-section">
<ul>
{[
{
value: 'mention',
label: 'Mentions',
},
{
value: 'favourite',
label: 'Favourites',
},
{
value: 'reblog',
label: 'Boosts',
},
{
value: 'follow',
label: 'Follows',
},
{
value: 'followRequest',
label: 'Follow requests',
},
{
value: 'poll',
label: 'Polls',
},
{
value: 'update',
label: 'Post edits',
},
{
value: 'status',
label: 'New posts',
},
].map((alert) => (
<li>
<label>
<input type="checkbox" name={alert.value} />{' '}
{alert.label}
</label>
</li>
))}
</ul>
</div>
</div>
</div>
{needRelogin && (
<div class="sub-section">
<p>
Push permission was not granted since your last login. You'll
need to{' '}
<Link to={`/login?instance=${instance}`} onClick={onClose}>
<b>log in</b> again to grant push permission
</Link>
.
</p>
</div>
)}
</li>
</ul>
</section>
<p class="section-postnote">
<small>
NOTE: Push notifications only works for <b>one account</b>.
</small>
</p>
</form>
);
}
export default Settings; export default Settings;

View file

@ -1,7 +1,12 @@
import { createClient } from 'masto'; import { createClient } from 'masto';
import store from './store'; import store from './store';
import { getAccount, getCurrentAccount, saveAccount } from './store-utils'; import {
getAccount,
getAccountByAccessToken,
getCurrentAccount,
saveAccount,
} from './store-utils';
// Default *fallback* instance // Default *fallback* instance
const DEFAULT_INSTANCE = 'mastodon.social'; const DEFAULT_INSTANCE = 'mastodon.social';
@ -18,6 +23,7 @@ const apis = {};
// Just in case if I need this one day. // Just in case if I need this one day.
// E.g. accountApis['mastodon.social']['ACCESS_TOKEN'] // E.g. accountApis['mastodon.social']['ACCESS_TOKEN']
const accountApis = {}; const accountApis = {};
window.__ACCOUNT_APIS__ = accountApis;
// Current account masto instance // Current account masto instance
let currentAccountApi; let currentAccountApi;
@ -92,7 +98,7 @@ export async function initInstance(client, instance) {
} }
// Get the account information and store it // Get the account information and store it
export async function initAccount(client, instance, accessToken) { export async function initAccount(client, instance, accessToken, vapidKey) {
const masto = client; const masto = client;
const mastoAccount = await masto.v1.accounts.verifyCredentials(); const mastoAccount = await masto.v1.accounts.verifyCredentials();
@ -102,6 +108,7 @@ export async function initAccount(client, instance, accessToken) {
info: mastoAccount, info: mastoAccount,
instanceURL: instance.toLowerCase(), instanceURL: instance.toLowerCase(),
accessToken, accessToken,
vapidKey,
}); });
} }
@ -136,6 +143,35 @@ export function api({ instance, accessToken, accountID, account } = {}) {
}; };
} }
if (accessToken) {
// If only accessToken is provided, get the masto instance for that accessToken
console.log('X 1', accountApis);
for (const instance in accountApis) {
if (accountApis[instance][accessToken]) {
console.log('X 2', accountApis, instance, accessToken);
return {
masto: accountApis[instance][accessToken],
authenticated: true,
instance,
};
} else {
console.log('X 3', accountApis, instance, accessToken);
const account = getAccountByAccessToken(accessToken);
if (account) {
const accessToken = account.accessToken;
const instance = account.instanceURL.toLowerCase().trim();
return {
masto: initClient({ instance, accessToken }),
authenticated: true,
instance,
};
} else {
throw new Error(`Access token ${accessToken} not found`);
}
}
}
}
// If account is provided, get the masto instance for that account // If account is provided, get the masto instance for that account
if (account || accountID) { if (account || accountID) {
account = account || getAccount(accountID); account = account || getAccount(accountID);

View file

@ -1,11 +1,13 @@
const { VITE_CLIENT_NAME: CLIENT_NAME, VITE_WEBSITE: WEBSITE } = import.meta const { VITE_CLIENT_NAME: CLIENT_NAME, VITE_WEBSITE: WEBSITE } = import.meta
.env; .env;
const SCOPES = 'read write follow push';
export async function registerApplication({ instanceURL }) { export async function registerApplication({ instanceURL }) {
const registrationParams = new URLSearchParams({ const registrationParams = new URLSearchParams({
client_name: CLIENT_NAME, client_name: CLIENT_NAME,
scopes: 'read write follow',
redirect_uris: location.origin + location.pathname, redirect_uris: location.origin + location.pathname,
scopes: SCOPES,
website: WEBSITE, website: WEBSITE,
}); });
const registrationResponse = await fetch( const registrationResponse = await fetch(
@ -26,7 +28,7 @@ export async function registerApplication({ instanceURL }) {
export async function getAuthorizationURL({ instanceURL, client_id }) { export async function getAuthorizationURL({ instanceURL, client_id }) {
const authorizationParams = new URLSearchParams({ const authorizationParams = new URLSearchParams({
client_id, client_id,
scope: 'read write follow', scope: SCOPES,
redirect_uri: location.origin + location.pathname, redirect_uri: location.origin + location.pathname,
// redirect_uri: 'urn:ietf:wg:oauth:2.0:oob', // redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
response_type: 'code', response_type: 'code',
@ -47,7 +49,7 @@ export async function getAccessToken({
redirect_uri: location.origin + location.pathname, redirect_uri: location.origin + location.pathname,
grant_type: 'authorization_code', grant_type: 'authorization_code',
code, code,
scope: 'read write follow', scope: SCOPES,
}); });
const tokenResponse = await fetch(`https://${instanceURL}/oauth/token`, { const tokenResponse = await fetch(`https://${instanceURL}/oauth/token`, {
method: 'POST', method: 'POST',

View file

@ -0,0 +1,233 @@
// Utils for push notifications
import { api } from './api';
import { getCurrentAccount } from './store-utils';
// Subscription is an object with the following structure:
// {
// data: {
// alerts: {
// admin: {
// report: boolean,
// signUp: boolean,
// },
// favourite: boolean,
// follow: boolean,
// mention: boolean,
// poll: boolean,
// reblog: boolean,
// status: boolean,
// update: boolean,
// }
// },
// policy: "all" | "followed" | "follower" | "none",
// subscription: {
// endpoint: string,
// keys: {
// auth: string,
// p256dh: string,
// },
// },
// }
// Back-end CRUD
// =============
function createBackendPushSubscription(subscription) {
const { masto } = api();
return masto.v1.webPushSubscriptions.create(subscription);
}
function fetchBackendPushSubscription() {
const { masto } = api();
return masto.v1.webPushSubscriptions.fetch();
}
function updateBackendPushSubscription(subscription) {
const { masto } = api();
return masto.v1.webPushSubscriptions.update(subscription);
}
function removeBackendPushSubscription() {
const { masto } = api();
return masto.v1.webPushSubscriptions.remove();
}
// Front-end
// =========
export function isPushSupported() {
return 'serviceWorker' in navigator && 'PushManager' in window;
}
export function getRegistration() {
// return navigator.serviceWorker.ready;
return navigator.serviceWorker.getRegistration();
}
async function getSubscription() {
const registration = await getRegistration();
const subscription = registration
? await registration.pushManager.getSubscription()
: undefined;
return { registration, subscription };
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = `${base64String}${padding}`
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// Front-end <-> back-end
// ======================
export async function initSubscription() {
if (!isPushSupported()) return;
const { subscription } = await getSubscription();
let backendSubscription = null;
try {
backendSubscription = await fetchBackendPushSubscription();
} catch (err) {
if (/(not found|unknown)/i.test(err.message)) {
// No subscription found
} else {
// Other error
throw err;
}
}
console.log('INIT subscription', {
subscription,
backendSubscription,
});
// Check if the subscription changed
if (backendSubscription && subscription) {
const sameEndpoint = backendSubscription.endpoint === subscription.endpoint;
const { vapidKey } = getCurrentAccount();
const sameKey = backendSubscription.serverKey === vapidKey;
if (!sameEndpoint) {
throw new Error('Backend subscription endpoint changed');
}
if (sameKey) {
// Subscription didn't change
} else {
// Subscription changed
console.error('🔔 Subscription changed', {
sameEndpoint,
serverKey: backendSubscription.serverKey,
vapIdKey: vapidKey,
endpoint1: backendSubscription.endpoint,
endpoint2: subscription.endpoint,
sameKey,
key1: backendSubscription.serverKey,
key2: vapidKey,
});
throw new Error('Backend subscription key and vapid key changed');
// Only unsubscribe from backend, not from browser
// await removeBackendPushSubscription();
// // Now let's resubscribe
// // NOTE: I have no idea if this works
// return await updateSubscription({
// data: backendSubscription.data,
// policy: backendSubscription.policy,
// });
}
}
if (subscription && !backendSubscription) {
// check if account's vapidKey is same as subscription's applicationServerKey
const { vapidKey } = getCurrentAccount();
const { applicationServerKey } = subscription.options;
const vapidKeyStr = urlBase64ToUint8Array(vapidKey).toString();
const applicationServerKeyStr = new Uint8Array(
applicationServerKey,
).toString();
const sameKey = vapidKeyStr === applicationServerKeyStr;
if (sameKey) {
// Subscription didn't change
} else {
// Subscription changed
console.error('🔔 Subscription changed', {
vapidKeyStr,
applicationServerKeyStr,
sameKey,
});
// Unsubscribe since backend doesn't have a subscription
await subscription.unsubscribe();
throw new Error('Subscription key and vapid key changed');
}
}
// Check if backend subscription returns 404
// if (subscription && !backendSubscription) {
// // Re-subscribe to backend
// backendSubscription = await createBackendPushSubscription({
// subscription,
// data: {},
// policy: 'all',
// });
// }
return { subscription, backendSubscription };
}
export async function updateSubscription({ data, policy }) {
console.log('🔔 Updating subscription', { data, policy });
if (!isPushSupported()) return;
let { registration, subscription } = await getSubscription();
let backendSubscription = null;
if (subscription) {
try {
backendSubscription = await updateBackendPushSubscription({
data,
policy,
});
// TODO: save subscription in user settings
} catch (error) {
// Backend doesn't have a subscription for this user
// Create a new one
backendSubscription = await createBackendPushSubscription({
subscription,
data,
policy,
});
// TODO: save subscription in user settings
}
} else {
// User is not subscribed
const { vapidKey } = getCurrentAccount();
if (!vapidKey) throw new Error('No server key found');
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidKey),
});
backendSubscription = await createBackendPushSubscription({
subscription,
data,
policy,
});
// TODO: save subscription in user settings
}
return { subscription, backendSubscription };
}
export async function removeSubscription() {
if (!isPushSupported()) return;
const { subscription } = await getSubscription();
if (subscription) {
await removeBackendPushSubscription();
await subscription.unsubscribe();
}
}

View file

@ -29,6 +29,7 @@ const states = proxy({
unfurledLinks: {}, unfurledLinks: {},
statusQuotes: {}, statusQuotes: {},
accounts: {}, accounts: {},
routeNotification: null,
// Modals // Modals
showCompose: false, showCompose: false,
showSettings: false, showSettings: false,

View file

@ -5,6 +5,11 @@ export function getAccount(id) {
return accounts.find((a) => a.info.id === id) || accounts[0]; return accounts.find((a) => a.info.id === id) || accounts[0];
} }
export function getAccountByAccessToken(accessToken) {
const accounts = store.local.getJSON('accounts') || [];
return accounts.find((a) => a.accessToken === accessToken);
}
export function getCurrentAccount() { export function getCurrentAccount() {
const currentAccount = store.session.get('currentAccount'); const currentAccount = store.session.get('currentAccount');
const account = getAccount(currentAccount); const account = getAccount(currentAccount);
@ -27,6 +32,7 @@ export function saveAccount(account) {
acc.info = account.info; acc.info = account.info;
acc.instanceURL = account.instanceURL; acc.instanceURL = account.instanceURL;
acc.accessToken = account.accessToken; acc.accessToken = account.accessToken;
acc.vapidKey = account.vapidKey;
} else { } else {
accounts.push(account); accounts.push(account);
} }