mirror of
https://github.com/cheeaun/phanpy.git
synced 2024-11-22 09:15:33 +03:00
Yes, push notifications (beta).
Heck this feature is tough.
This commit is contained in:
parent
0b04e01d60
commit
0e745663f0
12 changed files with 854 additions and 15 deletions
97
public/sw.js
97
public/sw.js
|
@ -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);
|
||||||
|
});
|
||||||
|
|
172
src/app.jsx
172
src/app.jsx
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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',
|
||||||
|
|
233
src/utils/push-notifications.js
Normal file
233
src/utils/push-notifications.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue