Rewrote notifications, again

This commit is contained in:
Lim Chee Aun 2023-02-12 17:38:50 +08:00
parent 30b747527e
commit 0b6dd07eee
5 changed files with 217 additions and 169 deletions

View file

@ -55,6 +55,7 @@ import { getAccessToken } from './utils/auth';
import states, { getStatus, saveStatus } from './utils/states'; import states, { getStatus, saveStatus } from './utils/states';
import store from './utils/store'; import store from './utils/store';
import { getCurrentAccount } from './utils/store-utils'; import { getCurrentAccount } from './utils/store-utils';
import usePageVisibility from './utils/usePageVisibility';
window.__STATES__ = states; window.__STATES__ = states;
@ -110,6 +111,7 @@ function App() {
if (account) { if (account) {
store.session.set('currentAccount', account.info.id); store.session.set('currentAccount', account.info.id);
const { masto } = api({ account }); const { masto } = api({ account });
console.log('masto', masto);
initPreferences(masto); initPreferences(masto);
setUIState('loading'); setUIState('loading');
(async () => { (async () => {
@ -164,6 +166,54 @@ function App() {
// } // }
// }, [isLoggedIn]); // }, [isLoggedIn]);
// Notifications service
// - WebSocket to receive notifications when page is visible
const [visible, setVisible] = useState(true);
usePageVisibility(setVisible);
const notificationStream = useRef();
useEffect(() => {
if (isLoggedIn && visible) {
const { masto } = api();
(async () => {
// 1. Get the latest notification
if (states.notificationsLast) {
const notificationsIterator = masto.v1.notifications.list({
limit: 1,
since_id: states.notificationsLast.id,
});
const { value: notifications } = await notificationsIterator.next();
if (notifications?.length) {
states.notificationsShowNew = true;
}
}
// 2. Start streaming
notificationStream.current = await masto.ws.stream(
'/api/v1/streaming',
{
stream: 'user:notification',
},
);
console.log('🎏 Streaming notification', notificationStream.current);
notificationStream.current.on('notification', (notification) => {
console.log('🔔🔔 Notification', notification);
states.notificationsShowNew = true;
});
notificationStream.current.ws.onclose = () => {
console.log('🔔🔔 Notification stream closed');
};
})();
}
return () => {
if (notificationStream.current) {
notificationStream.current.ws.close();
notificationStream.current = null;
}
};
}, [visible, isLoggedIn]);
const { prevLocation } = snapStates; const { prevLocation } = snapStates;
const backgroundLocation = useRef(prevLocation || null); const backgroundLocation = useRef(prevLocation || null);
const isModalPage = const isModalPage =
@ -360,164 +410,164 @@ function App() {
); );
} }
let ws; // let ws;
async function startStream() { // async function startStream() {
const { masto, instance } = api(); // const { masto, instance } = api();
if ( // if (
ws && // ws &&
(ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) // (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)
) { // ) {
return; // return;
} // }
const stream = await masto.v1.stream.streamUser(); // const stream = await masto.v1.stream.streamUser();
console.log('STREAM START', { stream }); // console.log('STREAM START', { stream });
ws = stream.ws; // ws = stream.ws;
const handleNewStatus = debounce((status) => { // const handleNewStatus = debounce((status) => {
console.log('UPDATE', status); // console.log('UPDATE', status);
if (document.visibilityState === 'hidden') return; // if (document.visibilityState === 'hidden') return;
const inHomeNew = states.homeNew.find((s) => s.id === status.id); // const inHomeNew = states.homeNew.find((s) => s.id === status.id);
const inHome = status.id === states.homeLast?.id; // const inHome = status.id === states.homeLast?.id;
if (!inHomeNew && !inHome) { // if (!inHomeNew && !inHome) {
if (states.settings.boostsCarousel && status.reblog) { // if (states.settings.boostsCarousel && status.reblog) {
// do nothing // // do nothing
} else { // } else {
states.homeNew.unshift({ // states.homeNew.unshift({
id: status.id, // id: status.id,
reblog: status.reblog?.id, // reblog: status.reblog?.id,
reply: !!status.inReplyToAccountId, // reply: !!status.inReplyToAccountId,
}); // });
console.log('homeNew 1', [...states.homeNew]); // console.log('homeNew 1', [...states.homeNew]);
} // }
} // }
saveStatus(status, instance); // saveStatus(status, instance);
}, 5000); // }, 5000);
stream.on('update', handleNewStatus); // stream.on('update', handleNewStatus);
stream.on('status.update', (status) => { // stream.on('status.update', (status) => {
console.log('STATUS.UPDATE', status); // console.log('STATUS.UPDATE', status);
saveStatus(status, instance); // saveStatus(status, instance);
}); // });
stream.on('delete', (statusID) => { // stream.on('delete', (statusID) => {
console.log('DELETE', statusID); // console.log('DELETE', statusID);
// delete states.statuses[statusID]; // // delete states.statuses[statusID];
const s = getStatus(statusID); // const s = getStatus(statusID);
if (s) s._deleted = true; // if (s) s._deleted = true;
}); // });
stream.on('notification', (notification) => { // stream.on('notification', (notification) => {
console.log('NOTIFICATION', notification); // console.log('NOTIFICATION', notification);
const inNotificationsNew = states.notificationsNew.find( // const inNotificationsNew = states.notificationsNew.find(
(n) => n.id === notification.id, // (n) => n.id === notification.id,
); // );
const inNotifications = notification.id === states.notificationsLast?.id; // const inNotifications = notification.id === states.notificationsLast?.id;
if (!inNotificationsNew && !inNotifications) { // if (!inNotificationsNew && !inNotifications) {
states.notificationsNew.unshift(notification); // states.notificationsNew.unshift(notification);
} // }
saveStatus(notification.status, instance, { override: false }); // saveStatus(notification.status, instance, { override: false });
}); // });
stream.ws.onclose = () => { // stream.ws.onclose = () => {
console.log('STREAM CLOSED!'); // console.log('STREAM CLOSED!');
if (document.visibilityState !== 'hidden') { // if (document.visibilityState !== 'hidden') {
startStream(); // startStream();
} // }
}; // };
return { // return {
stream, // stream,
stopStream: () => { // stopStream: () => {
stream.ws.close(); // stream.ws.close();
}, // },
}; // };
} // }
let lastHidden; // let lastHidden;
function startVisibility() { // function startVisibility() {
const { masto, instance } = api(); // const { masto, instance } = api();
const handleVisible = (visible) => { // const handleVisible = (visible) => {
if (!visible) { // if (!visible) {
const timestamp = Date.now(); // const timestamp = Date.now();
lastHidden = timestamp; // lastHidden = timestamp;
} else { // } else {
const timestamp = Date.now(); // const timestamp = Date.now();
const diff = timestamp - lastHidden; // const diff = timestamp - lastHidden;
const diffMins = Math.round(diff / 1000 / 60); // const diffMins = Math.round(diff / 1000 / 60);
console.log(`visible: ${visible}`, { lastHidden, diffMins }); // console.log(`visible: ${visible}`, { lastHidden, diffMins });
if (!lastHidden || diffMins > 1) { // if (!lastHidden || diffMins > 1) {
(async () => { // (async () => {
try { // try {
const firstStatusID = states.homeLast?.id; // const firstStatusID = states.homeLast?.id;
const firstNotificationID = states.notificationsLast?.id; // const firstNotificationID = states.notificationsLast?.id;
console.log({ states, firstNotificationID, firstStatusID }); // console.log({ states, firstNotificationID, firstStatusID });
const fetchHome = masto.v1.timelines.listHome({ // const fetchHome = masto.v1.timelines.listHome({
limit: 5, // limit: 5,
...(firstStatusID && { sinceId: firstStatusID }), // ...(firstStatusID && { sinceId: firstStatusID }),
}); // });
const fetchNotifications = masto.v1.notifications.list({ // const fetchNotifications = masto.v1.notifications.list({
limit: 1, // limit: 1,
...(firstNotificationID && { sinceId: firstNotificationID }), // ...(firstNotificationID && { sinceId: firstNotificationID }),
}); // });
const newStatuses = await fetchHome; // const newStatuses = await fetchHome;
const hasOneAndReblog = // const hasOneAndReblog =
newStatuses.length === 1 && newStatuses?.[0]?.reblog; // newStatuses.length === 1 && newStatuses?.[0]?.reblog;
if (newStatuses.length) { // if (newStatuses.length) {
if (states.settings.boostsCarousel && hasOneAndReblog) { // if (states.settings.boostsCarousel && hasOneAndReblog) {
// do nothing // // do nothing
} else { // } else {
states.homeNew = newStatuses.map((status) => { // states.homeNew = newStatuses.map((status) => {
saveStatus(status, instance); // saveStatus(status, instance);
return { // return {
id: status.id, // id: status.id,
reblog: status.reblog?.id, // reblog: status.reblog?.id,
reply: !!status.inReplyToAccountId, // reply: !!status.inReplyToAccountId,
}; // };
}); // });
console.log('homeNew 2', [...states.homeNew]); // console.log('homeNew 2', [...states.homeNew]);
} // }
} // }
const newNotifications = await fetchNotifications; // const newNotifications = await fetchNotifications;
if (newNotifications.length) { // if (newNotifications.length) {
const notification = newNotifications[0]; // const notification = newNotifications[0];
const inNotificationsNew = states.notificationsNew.find( // const inNotificationsNew = states.notificationsNew.find(
(n) => n.id === notification.id, // (n) => n.id === notification.id,
); // );
const inNotifications = // const inNotifications =
notification.id === states.notificationsLast?.id; // notification.id === states.notificationsLast?.id;
if (!inNotificationsNew && !inNotifications) { // if (!inNotificationsNew && !inNotifications) {
states.notificationsNew.unshift(notification); // states.notificationsNew.unshift(notification);
} // }
saveStatus(notification.status, instance, { override: false }); // saveStatus(notification.status, instance, { override: false });
} // }
} catch (e) { // } catch (e) {
// Silently fail // // Silently fail
console.error(e); // console.error(e);
} finally { // } finally {
startStream(); // startStream();
} // }
})(); // })();
} // }
} // }
}; // };
const handleVisibilityChange = () => { // const handleVisibilityChange = () => {
const hidden = document.visibilityState === 'hidden'; // const hidden = document.visibilityState === 'hidden';
handleVisible(!hidden); // handleVisible(!hidden);
console.log('VISIBILITY: ' + (hidden ? 'hidden' : 'visible')); // console.log('VISIBILITY: ' + (hidden ? 'hidden' : 'visible'));
}; // };
document.addEventListener('visibilitychange', handleVisibilityChange); // document.addEventListener('visibilitychange', handleVisibilityChange);
requestAnimationFrame(handleVisibilityChange); // requestAnimationFrame(handleVisibilityChange);
return { // return {
stop: () => { // stop: () => {
document.removeEventListener('visibilitychange', handleVisibilityChange); // document.removeEventListener('visibilitychange', handleVisibilityChange);
}, // },
}; // };
} // }
export { App }; export { App };

View file

@ -1,4 +1,5 @@
import { FocusableItem, Menu, MenuDivider, MenuItem } from '@szhsin/react-menu'; import { FocusableItem, Menu, MenuDivider, MenuItem } from '@szhsin/react-menu';
import { useSnapshot } from 'valtio';
import { api } from '../utils/api'; import { api } from '../utils/api';
import states from '../utils/states'; import states from '../utils/states';
@ -7,6 +8,7 @@ import Icon from './icon';
import Link from './link'; import Link from './link';
function NavMenu(props) { function NavMenu(props) {
const snapStates = useSnapshot(states);
const { instance, authenticated } = api(); const { instance, authenticated } = api();
return ( return (
<Menu <Menu
@ -30,6 +32,12 @@ function NavMenu(props) {
<> <>
<MenuLink to="/notifications"> <MenuLink to="/notifications">
<Icon icon="notification" size="l" /> <span>Notifications</span> <Icon icon="notification" size="l" /> <span>Notifications</span>
{snapStates.notificationsShowNew && (
<sup title="New" style={{ opacity: 0.5 }}>
{' '}
&bull;
</sup>
)}
</MenuLink> </MenuLink>
<MenuDivider /> <MenuDivider />
<MenuLink to="/l"> <MenuLink to="/l">

View file

@ -90,17 +90,6 @@ function Following({ title, path, id, headerStart }) {
if (s) s._deleted = true; if (s) s._deleted = true;
}); });
stream.on('notification', (notification) => {
console.log('🔔 Notification', notification);
const inNotifications =
notification.id === snapStates.notificationsLast?.id;
if (inNotifications) return;
states.notificationsNew.unshift(notification);
saveStatus(notification.status, instance, {
override: false,
});
});
stream.ws.onclose = () => { stream.ws.onclose = () => {
console.log('🎏 Streaming user closed'); console.log('🎏 Streaming user closed');
}; };
@ -124,7 +113,7 @@ function Following({ title, path, id, headerStart }) {
<Link <Link
to="/notifications" to="/notifications"
class={`button plain ${ class={`button plain ${
snapStates.notificationsNew.length > 0 ? 'has-badge' : '' snapStates.notificationsShowNew ? 'has-badge' : ''
}`} }`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();

View file

@ -64,32 +64,34 @@ function Notifications() {
const notificationsIterator = useRef(); const notificationsIterator = useRef();
async function fetchNotifications(firstLoad) { async function fetchNotifications(firstLoad) {
if (firstLoad) { if (firstLoad || !notificationsIterator.current) {
// Reset iterator // Reset iterator
notificationsIterator.current = masto.v1.notifications.list({ notificationsIterator.current = masto.v1.notifications.list({
limit: LIMIT, limit: LIMIT,
}); });
states.notificationsNew = [];
} }
const allNotifications = await notificationsIterator.current.next(); const allNotifications = await notificationsIterator.current.next();
if (allNotifications.value?.length) { const notifications = allNotifications.value;
const notificationsValues = allNotifications.value.map((notification) => {
if (notifications?.length) {
notifications.forEach((notification) => {
saveStatus(notification.status, { saveStatus(notification.status, {
skipThreading: true, skipThreading: true,
override: false, override: false,
}); });
return notification;
}); });
const groupedNotifications = groupNotifications(notificationsValues); const groupedNotifications = groupNotifications(notifications);
if (firstLoad) { if (firstLoad) {
states.notificationsLast = notificationsValues[0]; states.notificationsLast = notifications[0];
states.notifications = groupedNotifications; states.notifications = groupedNotifications;
} else { } else {
states.notifications.push(...groupedNotifications); states.notifications.push(...groupedNotifications);
} }
} }
states.notificationsShowNew = false;
states.notificationsLastFetchTime = Date.now(); states.notificationsLastFetchTime = Date.now();
return allNotifications; return allNotifications;
} }
@ -161,14 +163,12 @@ function Notifications() {
</div> </div>
</div> </div>
</header> </header>
{snapStates.notificationsNew.length > 0 && uiState !== 'loading' && ( {snapStates.notificationsShowNew && uiState !== 'loading' && (
<button <button
class="updates-button" class="updates-button"
type="button" type="button"
onClick={() => { onClick={() => {
loadNotifications(true); loadNotifications(true);
states.notificationsNew = [];
scrollableRef.current?.scrollTo({ scrollableRef.current?.scrollTo({
top: 0, top: 0,
behavior: 'smooth', behavior: 'smooth',

View file

@ -16,8 +16,9 @@ const states = proxy({
homeLast: null, // Last item in 'home' list homeLast: null, // Last item in 'home' list
homeLastFetchTime: null, homeLastFetchTime: null,
notifications: [], notifications: [],
notificationsLast: store.account.get('notificationsLast') || null, // Last item in 'notifications' list notificationsLast: store.account.get('notificationsLast') || null, // Last read notification
notificationsNew: [], notificationsNew: [],
notificationsShowNew: false,
notificationsLastFetchTime: null, notificationsLastFetchTime: null,
accounts: {}, accounts: {},
reloadStatusPage: 0, reloadStatusPage: 0,