diff --git a/public/sw.js b/public/sw.js
index 1f63d459..31ba4e73 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -94,3 +94,100 @@ const apiRoute = new RegExpRoute(
}),
);
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);
+});
diff --git a/src/app.jsx b/src/app.jsx
index 3cdbbdf0..73a56b85 100644
--- a/src/app.jsx
+++ b/src/app.jsx
@@ -22,9 +22,11 @@ import AccountSheet from './components/account-sheet';
import Compose from './components/compose';
import Drafts from './components/drafts';
import Icon, { ICONS } from './components/icon';
+import Link from './components/link';
import Loader from './components/loader';
import MediaModal from './components/media-modal';
import Modal from './components/modal';
+import Notification from './components/notification';
import Shortcuts from './components/shortcuts';
import ShortcutsSettings from './components/shortcuts-settings';
import NotFound from './pages/404';
@@ -60,7 +62,11 @@ import openCompose from './utils/open-compose';
import showToast from './utils/show-toast';
import states, { initStates, saveStatus } from './utils/states';
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 usePageVisibility from './utils/usePageVisibility';
@@ -115,6 +121,7 @@ function App() {
const clientID = store.session.get('clientID');
const clientSecret = store.session.get('clientSecret');
+ const vapidKey = store.session.get('vapidKey');
(async () => {
setUIState('loading');
@@ -128,7 +135,7 @@ function App() {
const masto = initClient({ instance: instanceURL, accessToken });
await Promise.allSettled([
initInstance(masto, instanceURL),
- initAccount(masto, instanceURL, accessToken),
+ initAccount(masto, instanceURL, accessToken, vapidKey),
]);
initStates();
initPreferences(masto);
@@ -446,6 +453,7 @@ function App() {
/>
)}
+
>
);
@@ -537,6 +545,166 @@ function BackgroundService({ isLoggedIn }) {
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 (
+ {
+ if (e.target === e.currentTarget) {
+ onClose();
+ }
+ }}
+ >
+
+
+
+
+ {!sameInstance && (
+ This notification is from your other account.
+ )}
+ {
+ const { target } = e;
+ // If button or links
+ if (e.target.tagName === 'BUTTON' || e.target.tagName === 'A') {
+ onClose();
+ }
+ }}
+ >
+
+
+
+
+ View all notifications
+
+
+
+
+
+ );
+ }
+
+ return null;
+}
+
function StatusRoute() {
const params = useParams();
const { id, instance } = params;
diff --git a/src/components/notification.jsx b/src/components/notification.jsx
index b7a50523..5b26aa74 100644
--- a/src/components/notification.jsx
+++ b/src/components/notification.jsx
@@ -56,12 +56,13 @@ const contentText = {
'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;
let { type } = notification;
// 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 isSelf = currentAccount === account?.id;
@@ -242,7 +243,11 @@ function Notification({ notification, instance, reload }) {
: `/s/${actualStatusID}`
}
>
-
+ {isStatic ? (
+
+ ) : (
+
+ )}
)}
diff --git a/src/pages/login.jsx b/src/pages/login.jsx
index 53f9f388..ce5ec8c0 100644
--- a/src/pages/login.jsx
+++ b/src/pages/login.jsx
@@ -1,6 +1,7 @@
import './login.css';
import { useEffect, useRef, useState } from 'preact/hooks';
+import { useSearchParams } from 'react-router-dom';
import Link from '../components/link';
import Loader from '../components/loader';
@@ -14,8 +15,10 @@ function Login() {
const instanceURLRef = useRef();
const cachedInstanceURL = store.local.get('instanceURL');
const [uiState, setUIState] = useState('default');
+ const [searchParams] = useSearchParams();
+ const instance = searchParams.get('instance');
const [instanceText, setInstanceText] = useState(
- cachedInstanceURL?.toLowerCase() || '',
+ instance || cachedInstanceURL?.toLowerCase() || '',
);
const [instancesList, setInstancesList] = useState([]);
@@ -44,13 +47,15 @@ function Login() {
(async () => {
setUIState('loading');
try {
- const { client_id, client_secret } = await registerApplication({
- instanceURL,
- });
+ const { client_id, client_secret, vapid_key } =
+ await registerApplication({
+ instanceURL,
+ });
if (client_id && client_secret) {
store.session.set('clientID', client_id);
store.session.set('clientSecret', client_secret);
+ store.session.set('vapidKey', vapid_key);
location.href = await getAuthorizationURL({
instanceURL,
diff --git a/src/pages/notifications.jsx b/src/pages/notifications.jsx
index 2228d4fd..7e3edfbb 100644
--- a/src/pages/notifications.jsx
+++ b/src/pages/notifications.jsx
@@ -3,6 +3,7 @@ import './notifications.css';
import { useIdle } from '@uidotdev/usehooks';
import { memo } from 'preact/compat';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
+import { useSearchParams } from 'react-router-dom';
import { useSnapshot } from 'valtio';
import AccountBlock from '../components/account-block';
@@ -17,6 +18,7 @@ import enhanceContent from '../utils/enhance-content';
import groupNotifications from '../utils/group-notifications';
import handleContentLinks from '../utils/handle-content-links';
import niceDateTime from '../utils/nice-date-time';
+import { getRegistration } from '../utils/push-notifications';
import shortenNumber from '../utils/shorten-number';
import states, { saveStatus } from '../utils/states';
import { getCurrentInstance } from '../utils/store-utils';
@@ -24,12 +26,16 @@ import useScroll from '../utils/useScroll';
import useTitle from '../utils/useTitle';
const LIMIT = 30; // 30 is the maximum limit :(
+const emptySearchParams = new URLSearchParams();
-function Notifications() {
+function Notifications({ columnMode }) {
useTitle('Notifications', '/notifications');
const { masto, instance } = api();
const snapStates = useSnapshot(states);
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 [onlyMentions, setOnlyMentions] = useState(false);
const scrollableRef = useRef();
@@ -188,6 +194,31 @@ function Notifications() {
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 (
+
About
{
+ (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 (
+
+ );
+}
+
export default Settings;
diff --git a/src/utils/api.js b/src/utils/api.js
index c9c6a589..c1d73889 100644
--- a/src/utils/api.js
+++ b/src/utils/api.js
@@ -1,7 +1,12 @@
import { createClient } from 'masto';
import store from './store';
-import { getAccount, getCurrentAccount, saveAccount } from './store-utils';
+import {
+ getAccount,
+ getAccountByAccessToken,
+ getCurrentAccount,
+ saveAccount,
+} from './store-utils';
// Default *fallback* instance
const DEFAULT_INSTANCE = 'mastodon.social';
@@ -18,6 +23,7 @@ const apis = {};
// Just in case if I need this one day.
// E.g. accountApis['mastodon.social']['ACCESS_TOKEN']
const accountApis = {};
+window.__ACCOUNT_APIS__ = accountApis;
// Current account masto instance
let currentAccountApi;
@@ -92,7 +98,7 @@ export async function initInstance(client, instance) {
}
// 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 mastoAccount = await masto.v1.accounts.verifyCredentials();
@@ -102,6 +108,7 @@ export async function initAccount(client, instance, accessToken) {
info: mastoAccount,
instanceURL: instance.toLowerCase(),
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 || accountID) {
account = account || getAccount(accountID);
diff --git a/src/utils/auth.js b/src/utils/auth.js
index 600b0c0c..4c0ddee8 100644
--- a/src/utils/auth.js
+++ b/src/utils/auth.js
@@ -1,11 +1,13 @@
const { VITE_CLIENT_NAME: CLIENT_NAME, VITE_WEBSITE: WEBSITE } = import.meta
.env;
+const SCOPES = 'read write follow push';
+
export async function registerApplication({ instanceURL }) {
const registrationParams = new URLSearchParams({
client_name: CLIENT_NAME,
- scopes: 'read write follow',
redirect_uris: location.origin + location.pathname,
+ scopes: SCOPES,
website: WEBSITE,
});
const registrationResponse = await fetch(
@@ -26,7 +28,7 @@ export async function registerApplication({ instanceURL }) {
export async function getAuthorizationURL({ instanceURL, client_id }) {
const authorizationParams = new URLSearchParams({
client_id,
- scope: 'read write follow',
+ scope: SCOPES,
redirect_uri: location.origin + location.pathname,
// redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
response_type: 'code',
@@ -47,7 +49,7 @@ export async function getAccessToken({
redirect_uri: location.origin + location.pathname,
grant_type: 'authorization_code',
code,
- scope: 'read write follow',
+ scope: SCOPES,
});
const tokenResponse = await fetch(`https://${instanceURL}/oauth/token`, {
method: 'POST',
diff --git a/src/utils/push-notifications.js b/src/utils/push-notifications.js
new file mode 100644
index 00000000..013dd5b3
--- /dev/null
+++ b/src/utils/push-notifications.js
@@ -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();
+ }
+}
diff --git a/src/utils/states.js b/src/utils/states.js
index dde99563..bcb56d9e 100644
--- a/src/utils/states.js
+++ b/src/utils/states.js
@@ -29,6 +29,7 @@ const states = proxy({
unfurledLinks: {},
statusQuotes: {},
accounts: {},
+ routeNotification: null,
// Modals
showCompose: false,
showSettings: false,
diff --git a/src/utils/store-utils.js b/src/utils/store-utils.js
index f624f9cd..bd228392 100644
--- a/src/utils/store-utils.js
+++ b/src/utils/store-utils.js
@@ -5,6 +5,11 @@ export function getAccount(id) {
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() {
const currentAccount = store.session.get('currentAccount');
const account = getAccount(currentAccount);
@@ -27,6 +32,7 @@ export function saveAccount(account) {
acc.info = account.info;
acc.instanceURL = account.instanceURL;
acc.accessToken = account.accessToken;
+ acc.vapidKey = account.vapidKey;
} else {
accounts.push(account);
}