mirror of
https://github.com/cheeaun/phanpy.git
synced 2024-11-22 01:05:34 +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);
|
||||
|
||||
// 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 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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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',
|
||||
|
|
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: {},
|
||||
statusQuotes: {},
|
||||
accounts: {},
|
||||
routeNotification: null,
|
||||
// Modals
|
||||
showCompose: false,
|
||||
showSettings: false,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue