From 0e745663f076dd5a6660c65c59d16ea7bf460ba2 Mon Sep 17 00:00:00 2001
From: Lim Chee Aun <cheeaun@gmail.com>
Date: Fri, 1 Sep 2023 15:40:00 +0800
Subject: [PATCH] Yes, push notifications (beta).

Heck this feature is tough.
---
 public/sw.js                    |  97 +++++++++++++
 src/app.jsx                     | 172 +++++++++++++++++++++-
 src/components/notification.jsx |  11 +-
 src/pages/login.jsx             |  13 +-
 src/pages/notifications.jsx     |  33 ++++-
 src/pages/settings.css          |   7 +
 src/pages/settings.jsx          | 248 ++++++++++++++++++++++++++++++++
 src/utils/api.js                |  40 +++++-
 src/utils/auth.js               |   8 +-
 src/utils/push-notifications.js | 233 ++++++++++++++++++++++++++++++
 src/utils/states.js             |   1 +
 src/utils/store-utils.js        |   6 +
 12 files changed, 854 insertions(+), 15 deletions(-)
 create mode 100644 src/utils/push-notifications.js

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() {
           />
         </Modal>
       )}
+      <NotificationService />
       <BackgroundService isLoggedIn={isLoggedIn} />
     </>
   );
@@ -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 (
+      <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() {
   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}`
             }
           >
-            <Status statusID={actualStatusID} size="s" />
+            {isStatic ? (
+              <Status status={actualStatus} size="s" />
+            ) : (
+              <Status statusID={actualStatusID} size="s" />
+            )}
           </Link>
         )}
       </div>
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 (
     <div
       id="notifications-page"
diff --git a/src/pages/settings.css b/src/pages/settings.css
index 1a4d8230..0b150d31 100644
--- a/src/pages/settings.css
+++ b/src/pages/settings.css
@@ -7,6 +7,7 @@
   text-transform: uppercase;
   color: var(--text-insignificant-color);
   font-weight: normal;
+  padding-inline: 16px;
 }
 
 #settings-container section {
@@ -128,3 +129,9 @@
   gap: 4px;
   align-items: flex-start;
 }
+
+#settings-container .section-postnote {
+  margin-bottom: 48px;
+  padding-inline: 16px;
+  color: var(--text-insignificant-color);
+}
diff --git a/src/pages/settings.jsx b/src/pages/settings.jsx
index f54aedc3..f31c849e 100644
--- a/src/pages/settings.jsx
+++ b/src/pages/settings.jsx
@@ -5,11 +5,18 @@ import { useSnapshot } from 'valtio';
 
 import logo from '../assets/logo.svg';
 import Icon from '../components/icon';
+import Link from '../components/link';
 import RelativeTime from '../components/relative-time';
 import targetLanguages from '../data/lingva-target-languages';
 import { api } from '../utils/api';
 import getTranslateTargetLanguage from '../utils/get-translate-target-language';
 import localeCode2Text from '../utils/localeCode2Text';
+import {
+  initSubscription,
+  isPushSupported,
+  removeSubscription,
+  updateSubscription,
+} from '../utils/push-notifications';
 import states from '../utils/states';
 import store from '../utils/store';
 
@@ -391,6 +398,7 @@ function Settings({ onClose }) {
             </li>
           </ul>
         </section>
+        <PushNotificationsSection onClose={onClose} />
         <h3>About</h3>
         <section>
           <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;
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);
   }