Rewrite Notifications page + experimental fix on getting/showing updates

This commit is contained in:
Lim Chee Aun 2023-01-27 11:47:30 +08:00
parent 7c6157d47c
commit e83d128f62
5 changed files with 346 additions and 325 deletions

View file

@ -165,7 +165,7 @@ function App() {
if (isLoggedIn) { if (isLoggedIn) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
startStream(); // startStream();
startVisibility(); startVisibility();
// Collect instance info // Collect instance info
@ -342,20 +342,35 @@ function App() {
); );
} }
let ws;
async function startStream() { async function startStream() {
if (
ws &&
(ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)
) {
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;
const handleNewStatus = debounce((status) => { const handleNewStatus = debounce((status) => {
console.log('UPDATE', status); console.log('UPDATE', status);
const inHomeNew = states.homeNew.find((s) => s.id === status.id); const inHomeNew = states.homeNew.find((s) => s.id === status.id);
const inHome = states.home.find((s) => s.id === status.id); const inHome = status.id === states.homeLast?.id;
if (!inHomeNew && !inHome) { if (!inHomeNew && !inHome) {
if (states.settings.boostsCarousel && status.reblog) {
// do nothing
} 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]);
}
} }
saveStatus(status); saveStatus(status);
@ -377,9 +392,7 @@ async function startStream() {
const inNotificationsNew = states.notificationsNew.find( const inNotificationsNew = states.notificationsNew.find(
(n) => n.id === notification.id, (n) => n.id === notification.id,
); );
const inNotifications = states.notifications.find( const inNotifications = notification.id === states.notificationLast?.id;
(n) => n.id === notification.id,
);
if (!inNotificationsNew && !inNotifications) { if (!inNotificationsNew && !inNotifications) {
states.notificationsNew.unshift(notification); states.notificationsNew.unshift(notification);
} }
@ -389,10 +402,9 @@ async function startStream() {
stream.ws.onclose = () => { stream.ws.onclose = () => {
console.log('STREAM CLOSED!'); console.log('STREAM CLOSED!');
if (document.visibilityState !== 'hidden') {
requestAnimationFrame(() => {
startStream(); startStream();
}); }
}; };
return { return {
@ -404,8 +416,8 @@ async function startStream() {
} }
function startVisibility() { function startVisibility() {
const handleVisibilityChange = () => { const handleVisible = (visible) => {
if (document.visibilityState === 'hidden') { if (!visible) {
const timestamp = Date.now(); const timestamp = Date.now();
store.session.set('lastHidden', timestamp); store.session.set('lastHidden', timestamp);
} else { } else {
@ -415,8 +427,6 @@ function startVisibility() {
const diffMins = Math.round(diff / 1000 / 60); const diffMins = Math.round(diff / 1000 / 60);
if (diffMins > 1) { if (diffMins > 1) {
console.log('visible', { lastHidden, diffMins }); console.log('visible', { lastHidden, diffMins });
setTimeout(() => {
// Buffer for WS reconnect
(async () => { (async () => {
try { try {
const firstStatusID = states.home[0]?.id; const firstStatusID = states.home[0]?.id;
@ -431,10 +441,12 @@ function startVisibility() {
}); });
const newStatuses = await fetchHome; const newStatuses = await fetchHome;
if ( const newStatus = newStatuses?.[0];
newStatuses.length && const inHome = newStatus?.id !== states.homeLast?.id;
newStatuses[0].id !== states.home[0].id if (newStatuses.length && !inHome) {
) { if (states.settings.boostsCarousel && newStatus.reblog) {
// do nothing
} else {
states.homeNew = newStatuses.map((status) => { states.homeNew = newStatuses.map((status) => {
saveStatus(status); saveStatus(status);
return { return {
@ -443,6 +455,8 @@ function startVisibility() {
reply: !!status.inReplyToAccountId, reply: !!status.inReplyToAccountId,
}; };
}); });
console.log('homeNew 2', [...states.homeNew]);
}
} }
const newNotifications = await fetchNotifications; const newNotifications = await fetchNotifications;
@ -451,9 +465,8 @@ function startVisibility() {
const inNotificationsNew = states.notificationsNew.find( const inNotificationsNew = states.notificationsNew.find(
(n) => n.id === notification.id, (n) => n.id === notification.id,
); );
const inNotifications = states.notifications.find( const inNotifications =
(n) => n.id === notification.id, notification.id === states.notificationLast?.id;
);
if (!inNotificationsNew && !inNotifications) { if (!inNotificationsNew && !inNotifications) {
states.notificationsNew.unshift(notification); states.notificationsNew.unshift(notification);
} }
@ -463,12 +476,19 @@ function startVisibility() {
} catch (e) { } catch (e) {
// Silently fail // Silently fail
console.error(e); console.error(e);
} finally {
startStream();
} }
})(); })();
}, 100);
} }
} }
}; };
const handleVisibilityChange = () => {
const hidden = document.visibilityState === 'hidden';
handleVisible(!hidden);
console.log('VISIBILITY: ' + (hidden ? 'hidden' : 'visible'));
};
document.addEventListener('visibilitychange', handleVisibilityChange); document.addEventListener('visibilitychange', handleVisibilityChange);
return { return {
stop: () => { stop: () => {

View file

@ -22,11 +22,7 @@ function Home({ hidden }) {
console.debug('RENDER Home'); console.debug('RENDER Home');
const homeIterator = useRef( const homeIterator = useRef();
masto.v1.timelines.listHome({
limit: LIMIT,
}),
);
async function fetchStatuses(firstLoad) { async function fetchStatuses(firstLoad) {
if (firstLoad) { if (firstLoad) {
// Reset iterator // Reset iterator
@ -94,12 +90,14 @@ function Home({ hidden }) {
specialHome, specialHome,
}); });
if (firstLoad) { if (firstLoad) {
states.homeLast = specialHome[0];
states.home = specialHome; states.home = specialHome;
} else { } else {
states.home.push(...specialHome); states.home.push(...specialHome);
} }
} else { } else {
if (firstLoad) { if (firstLoad) {
states.homeLast = homeValues[0];
states.home = homeValues; states.home = homeValues;
} else { } else {
states.home.push(...homeValues); states.home.push(...homeValues);
@ -272,6 +270,13 @@ function Home({ hidden }) {
})(); })();
}, []); }, []);
// const showUpdatesButton = snapStates.homeNew.length > 0 && reachStart;
const [showUpdatesButton, setShowUpdatesButton] = useState(false);
useEffect(() => {
const isNewAndTop = snapStates.homeNew.length > 0 && reachStart;
setShowUpdatesButton(isNewAndTop);
}, [snapStates.homeNew.length]);
return ( return (
<> <>
<div <div
@ -321,9 +326,10 @@ function Home({ hidden }) {
</div> </div>
</header> </header>
{snapStates.homeNew.length > 0 && {snapStates.homeNew.length > 0 &&
scrollDirection === 'start' && ((scrollDirection === 'start' &&
!nearReachStart && !nearReachStart &&
!nearReachEnd && ( !nearReachEnd) ||
showUpdatesButton) && (
<button <button
class="updates-button" class="updates-button"
type="button" type="button"

View file

@ -2,6 +2,7 @@
display: flex; display: flex;
padding: 16px !important; padding: 16px !important;
gap: 12px; gap: 12px;
animation: appear 0.2s ease-out;
} }
.notification.mention { .notification.mention {
margin-top: 16px; margin-top: 16px;

View file

@ -13,6 +13,7 @@ import RelativeTime from '../components/relative-time';
import Status from '../components/status'; import Status from '../components/status';
import states, { saveStatus } from '../utils/states'; import states, { saveStatus } from '../utils/states';
import store from '../utils/store'; import store from '../utils/store';
import useScroll from '../utils/useScroll';
import useTitle from '../utils/useTitle'; import useTitle from '../utils/useTitle';
/* /*
@ -45,6 +46,228 @@ const contentText = {
const LIMIT = 30; // 30 is the maximum limit :( const LIMIT = 30; // 30 is the maximum limit :(
function Notifications() {
useTitle('Notifications');
const snapStates = useSnapshot(states);
const [uiState, setUIState] = useState('default');
const [showMore, setShowMore] = useState(false);
const [onlyMentions, setOnlyMentions] = useState(false);
const scrollableRef = useRef();
const { nearReachEnd, reachStart } = useScroll({
scrollableElement: scrollableRef.current,
});
console.debug('RENDER Notifications');
const notificationsIterator = useRef();
async function fetchNotifications(firstLoad) {
if (firstLoad) {
// Reset iterator
notificationsIterator.current = masto.v1.notifications.list({
limit: LIMIT,
});
states.notificationsNew = [];
}
const allNotifications = await notificationsIterator.current.next();
if (allNotifications.value?.length) {
const notificationsValues = allNotifications.value.map((notification) => {
saveStatus(notification.status, {
skipThreading: true,
override: false,
});
return notification;
});
const groupedNotifications = groupNotifications(notificationsValues);
if (firstLoad) {
states.notificationLast = notificationsValues[0];
states.notifications = groupedNotifications;
} else {
states.notifications.push(...groupedNotifications);
}
}
states.notificationsLastFetchTime = Date.now();
return allNotifications;
}
const loadNotifications = (firstLoad) => {
setUIState('loading');
(async () => {
try {
const { done } = await fetchNotifications(firstLoad);
setShowMore(!done);
setUIState('default');
} catch (e) {
setUIState('error');
}
})();
};
useEffect(() => {
loadNotifications(true);
}, []);
useEffect(() => {
if (reachStart) {
loadNotifications(true);
}
}, [reachStart]);
useEffect(() => {
if (nearReachEnd && showMore) {
loadNotifications();
}
}, [nearReachEnd, showMore]);
const todayDate = new Date();
const yesterdayDate = new Date(todayDate - 24 * 60 * 60 * 1000);
let currentDay = new Date();
const showTodayEmpty = !snapStates.notifications.some(
(notification) =>
new Date(notification.createdAt).toDateString() ===
todayDate.toDateString(),
);
return (
<div
id="notifications-page"
class="deck-container"
ref={scrollableRef}
tabIndex="-1"
>
<div class={`timeline-deck deck ${onlyMentions ? 'only-mentions' : ''}`}>
<header
onClick={() => {
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
}}
>
<div class="header-side">
<Link to="/" class="button plain">
<Icon icon="home" size="l" />
</Link>
</div>
<h1>Notifications</h1>
<div class="header-side">
<Loader hidden={uiState !== 'loading'} />
</div>
</header>
{snapStates.notificationsNew.length > 0 && (
<button
class="updates-button"
type="button"
onClick={() => {
loadNotifications(true);
states.notificationsNew = [];
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}}
>
<Icon icon="arrow-up" /> New notifications
</button>
)}
<div id="mentions-option">
<label>
<input
type="checkbox"
checked={onlyMentions}
onChange={(e) => {
setOnlyMentions(e.target.checked);
}}
/>{' '}
Only mentions
</label>
</div>
<h2 class="timeline-header">Today</h2>
{showTodayEmpty && !!snapStates.notifications.length && (
<p class="ui-state insignificant">
{uiState === 'default' ? "You're all caught up." : <>&hellip;</>}
</p>
)}
{snapStates.notifications.length ? (
<>
{snapStates.notifications.map((notification) => {
if (onlyMentions && notification.type !== 'mention') {
return null;
}
const notificationDay = new Date(notification.createdAt);
const differentDay =
notificationDay.toDateString() !== currentDay.toDateString();
if (differentDay) {
currentDay = notificationDay;
}
// if notificationDay is yesterday, show "Yesterday"
// if notificationDay is before yesterday, show date
const heading =
notificationDay.toDateString() === yesterdayDate.toDateString()
? 'Yesterday'
: Intl.DateTimeFormat('en', {
// Show year if not current year
year:
currentDay.getFullYear() === todayDate.getFullYear()
? undefined
: 'numeric',
month: 'short',
day: 'numeric',
}).format(currentDay);
return (
<>
{differentDay && <h2 class="timeline-header">{heading}</h2>}
<Notification
notification={notification}
key={notification.id}
/>
</>
);
})}
</>
) : (
<>
{uiState === 'loading' && (
<>
<ul class="timeline flat">
{Array.from({ length: 5 }).map((_, i) => (
<li class="notification skeleton">
<div class="notification-type">
<Icon icon="notification" size="xl" />
</div>
<div class="notification-content">
<p> </p>
</div>
</li>
))}
</ul>
</>
)}
{uiState === 'error' && (
<p class="ui-state">
Unable to load notifications
<br />
<br />
<button type="button" onClick={() => loadNotifications(true)}>
Try again
</button>
</p>
)}
</>
)}
{showMore && (
<button
type="button"
class="plain block"
disabled={uiState === 'loading'}
onClick={() => loadNotifications()}
style={{ marginBlockEnd: '6em' }}
>
{uiState === 'loading' ? <Loader abrupt /> : <>Show more&hellip;</>}
</button>
)}
</div>
</div>
);
}
function Notification({ notification }) { function Notification({ notification }) {
const { id, type, status, account, _accounts } = notification; const { id, type, status, account, _accounts } = notification;
@ -61,7 +284,7 @@ function Notification({ notification }) {
: contentText[type]; : contentText[type];
return ( return (
<> <div class={`notification ${type}`} tabIndex="0">
<div <div
class={`notification-type notification-${type}`} class={`notification-type notification-${type}`}
title={new Date(notification.createdAt).toLocaleString()} title={new Date(notification.createdAt).toLocaleString()}
@ -137,11 +360,13 @@ function Notification({ notification }) {
<Avatar <Avatar
url={account.avatarStatic} url={account.avatarStatic}
size={ size={
_accounts.length < 30 _accounts.length <= 10
? 'xl' ? 'xxl'
: _accounts.length < 100 : _accounts.length < 100
? 'l' ? 'xl'
: _accounts.length < 1000 : _accounts.length < 1000
? 'l'
: _accounts.length < 2000
? 'm' ? 'm'
: 's' // My god, this person is popular! : 's' // My god, this person is popular!
} }
@ -162,264 +387,6 @@ function Notification({ notification }) {
</Link> </Link>
)} )}
</div> </div>
</>
);
}
function NotificationsList({ notifications, emptyCopy }) {
if (!notifications.length && emptyCopy) {
return <p class="timeline-empty">{emptyCopy}</p>;
}
// Create new flat list of notifications
// Combine sibling notifications based on type and status id, ignore the id
// Concat all notification.account into an array of _accounts
const notificationsMap = {};
const cleanNotifications = [];
for (let i = 0, j = 0; i < notifications.length; i++) {
const notification = notifications[i];
// const cleanNotification = cleanNotifications[j];
const { status, account, type, created_at } = notification;
const createdAt = new Date(created_at).toLocaleDateString();
const key = `${status?.id}-${type}-${createdAt}`;
const mappedNotification = notificationsMap[key];
if (mappedNotification?.account) {
mappedNotification._accounts.push(account);
} else {
let n = (notificationsMap[key] = {
...notification,
_accounts: [account],
});
cleanNotifications[j++] = n;
}
}
// console.log({ notifications, cleanNotifications });
return (
<ul class="timeline flat">
{cleanNotifications.map((notification, i) => {
const { id, type } = notification;
return (
<li key={id} class={`notification ${type}`} tabIndex="0">
<Notification notification={notification} />
</li>
);
})}
</ul>
);
}
function Notifications() {
useTitle('Notifications');
const snapStates = useSnapshot(states);
const [uiState, setUIState] = useState('default');
const [showMore, setShowMore] = useState(false);
const [onlyMentions, setOnlyMentions] = useState(false);
console.debug('RENDER Notifications');
const notificationsIterator = useRef(
masto.v1.notifications.list({
limit: LIMIT,
}),
);
async function fetchNotifications(firstLoad) {
if (firstLoad) {
// Reset iterator
notificationsIterator.current = masto.v1.notifications.list({
limit: LIMIT,
});
states.notificationsNew = [];
}
const allNotifications = await notificationsIterator.current.next();
if (allNotifications.value?.length) {
const notificationsValues = allNotifications.value.map((notification) => {
saveStatus(notification.status, {
skipThreading: true,
override: false,
});
return notification;
});
if (firstLoad) {
states.notifications = notificationsValues;
} else {
states.notifications.push(...notificationsValues);
}
}
states.notificationsLastFetchTime = Date.now();
return allNotifications;
}
const loadNotifications = (firstLoad) => {
setUIState('loading');
(async () => {
try {
const { done } = await fetchNotifications(firstLoad);
setShowMore(!done);
setUIState('default');
} catch (e) {
setUIState('error');
}
})();
};
useEffect(() => {
loadNotifications(true);
}, []);
const scrollableRef = useRef();
// Group notifications by today, yesterday, and older
const groupedNotifications = snapStates.notifications.reduce(
(acc, notification) => {
const date = new Date(notification.createdAt);
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
if (
date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear()
) {
acc.today.push(notification);
} else if (
date.getDate() === yesterday.getDate() &&
date.getMonth() === yesterday.getMonth() &&
date.getFullYear() === yesterday.getFullYear()
) {
acc.yesterday.push(notification);
} else {
acc.older.push(notification);
}
return acc;
},
{ today: [], yesterday: [], older: [] },
);
// console.log(groupedNotifications);
return (
<div
id="notifications-page"
class="deck-container"
ref={scrollableRef}
tabIndex="-1"
>
<div class={`timeline-deck deck ${onlyMentions ? 'only-mentions' : ''}`}>
<header
onClick={() => {
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
}}
>
<div class="header-side">
<Link to="/" class="button plain">
<Icon icon="home" size="l" />
</Link>
</div>
<h1>Notifications</h1>
<div class="header-side">
<Loader hidden={uiState !== 'loading'} />
</div>
</header>
{snapStates.notificationsNew.length > 0 && (
<button
class="updates-button"
type="button"
onClick={() => {
const uniqueNotificationsNew = snapStates.notificationsNew.filter(
(notification) =>
!snapStates.notifications.some(
(n) => n.id === notification.id,
),
);
states.notifications.unshift(...uniqueNotificationsNew);
loadNotifications(true);
states.notificationsNew = [];
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}}
>
<Icon icon="arrow-up" /> New notifications
</button>
)}
<div id="mentions-option">
<label>
<input
type="checkbox"
checked={onlyMentions}
onChange={(e) => {
setOnlyMentions(e.target.checked);
}}
/>{' '}
Only mentions
</label>
</div>
{snapStates.notifications.length ? (
<>
<h2 class="timeline-header">Today</h2>
<NotificationsList
notifications={groupedNotifications.today}
emptyCopy="You're all caught up."
/>
{groupedNotifications.yesterday.length > 0 && (
<>
<h2 class="timeline-header">Yesterday</h2>
<NotificationsList
notifications={groupedNotifications.yesterday}
/>
</>
)}
{groupedNotifications.older.length > 0 && (
<>
<h2 class="timeline-header">Older</h2>
<NotificationsList notifications={groupedNotifications.older} />
</>
)}
{showMore && (
<button
type="button"
class="plain block"
disabled={uiState === 'loading'}
onClick={() => loadNotifications()}
style={{ marginBlockEnd: '6em' }}
>
{uiState === 'loading' ? <Loader /> : <>Show more&hellip;</>}
</button>
)}
</>
) : (
<>
{uiState === 'loading' && (
<>
<h2 class="timeline-header">Today</h2>
<ul class="timeline flat">
{Array.from({ length: 5 }).map((_, i) => (
<li class="notification skeleton">
<div class="notification-type">
<Icon icon="notification" size="xl" />
</div>
<div class="notification-content">
<p> </p>
</div>
</li>
))}
</ul>
</>
)}
{uiState === 'error' && (
<p class="ui-state">
Unable to load notifications
<br />
<br />
<button type="button" onClick={() => loadNotifications(true)}>
Try again
</button>
</p>
)}
</>
)}
</div>
</div> </div>
); );
} }
@ -470,4 +437,29 @@ function FollowRequestButtons({ accountID, onChange }) {
); );
} }
function groupNotifications(notifications) {
// Create new flat list of notifications
// Combine sibling notifications based on type and status id
// Concat all notification.account into an array of _accounts
const notificationsMap = {};
const cleanNotifications = [];
for (let i = 0, j = 0; i < notifications.length; i++) {
const notification = notifications[i];
const { status, account, type, created_at } = notification;
const createdAt = new Date(created_at).toLocaleDateString();
const key = `${status?.id}-${type}-${createdAt}`;
const mappedNotification = notificationsMap[key];
if (mappedNotification?.account) {
mappedNotification._accounts.push(account);
} else {
let n = (notificationsMap[key] = {
...notification,
_accounts: [account],
});
cleanNotifications[j++] = n;
}
}
return cleanNotifications;
}
export default memo(Notifications); export default memo(Notifications);

View file

@ -9,8 +9,10 @@ const states = proxy({
home: [], home: [],
specialHome: [], specialHome: [],
homeNew: [], homeNew: [],
homeLast: null, // Last item in 'home' list
homeLastFetchTime: null, homeLastFetchTime: null,
notifications: [], notifications: [],
notificationLast: null, // Last item in 'notifications' list
notificationsNew: [], notificationsNew: [],
notificationsLastFetchTime: null, notificationsLastFetchTime: null,
accounts: {}, accounts: {},