phanpy/src/pages/notifications.jsx

530 lines
16 KiB
React
Raw Normal View History

2022-12-10 12:14:48 +03:00
import './notifications.css';
import { memo } from 'preact/compat';
2022-12-10 12:14:48 +03:00
import { useEffect, useRef, useState } from 'preact/hooks';
import { useSnapshot } from 'valtio';
import Avatar from '../components/avatar';
2022-12-10 12:14:48 +03:00
import Icon from '../components/icon';
import Link from '../components/link';
2022-12-10 12:14:48 +03:00
import Loader from '../components/loader';
import Menu from '../components/menu';
2022-12-10 12:14:48 +03:00
import NameText from '../components/name-text';
import RelativeTime from '../components/relative-time';
2022-12-10 12:14:48 +03:00
import Status from '../components/status';
import { api } from '../utils/api';
2023-03-01 15:07:22 +03:00
import niceDateTime from '../utils/nice-date-time';
import states, { saveStatus } from '../utils/states';
import store from '../utils/store';
import useScroll from '../utils/useScroll';
2022-12-10 12:14:48 +03:00
import useTitle from '../utils/useTitle';
/*
Notification types
==================
mention = Someone mentioned you in their status
status = Someone you enabled notifications for has posted a status
reblog = Someone boosted one of your statuses
follow = Someone followed you
follow_request = Someone requested to follow you
favourite = Someone favourited one of your statuses
poll = A poll you have voted in or created has ended
update = A status you interacted with has been edited
admin.sign_up = Someone signed up (optionally sent to admins)
admin.report = A new report has been filed
*/
const contentText = {
mention: 'mentioned you in their status.',
status: 'posted a status.',
reblog: 'boosted your status.',
follow: 'followed you.',
follow_request: 'requested to follow you.',
favourite: 'favourited your status.',
poll: 'A poll you have voted in or created has ended.',
'poll-self': 'A poll you have created has ended.',
'poll-voted': 'A poll you have voted in has ended.',
2022-12-10 12:14:48 +03:00
update: 'A status you interacted with has been edited.',
2023-03-03 06:38:10 +03:00
'favourite+reblog': 'boosted & favourited your status.',
};
const NOTIFICATION_ICONS = {
mention: 'comment',
status: 'notification',
reblog: 'rocket',
follow: 'follow',
follow_request: 'follow-add',
favourite: 'heart',
poll: 'poll',
update: 'pencil',
2022-12-10 12:14:48 +03:00
};
2022-12-19 19:11:55 +03:00
const LIMIT = 30; // 30 is the maximum limit :(
2022-12-10 12:14:48 +03:00
2022-12-16 08:27:04 +03:00
function Notifications() {
useTitle('Notifications', '/notifications');
2023-02-18 19:05:46 +03:00
const { masto, instance } = api();
2022-12-10 12:14:48 +03:00
const snapStates = useSnapshot(states);
const [uiState, setUIState] = useState('default');
const [showMore, setShowMore] = useState(false);
const [onlyMentions, setOnlyMentions] = useState(false);
const scrollableRef = useRef();
const { nearReachEnd, scrollDirection, reachStart, nearReachStart } =
useScroll({
2023-02-28 16:56:41 +03:00
scrollableRef,
});
const hiddenUI = scrollDirection === 'end' && !nearReachStart;
2022-12-10 12:14:48 +03:00
console.debug('RENDER Notifications');
const notificationsIterator = useRef();
2022-12-10 12:14:48 +03:00
async function fetchNotifications(firstLoad) {
2023-02-12 12:38:50 +03:00
if (firstLoad || !notificationsIterator.current) {
// Reset iterator
notificationsIterator.current = masto.v1.notifications.list({
limit: LIMIT,
});
}
const allNotifications = await notificationsIterator.current.next();
2023-02-12 12:38:50 +03:00
const notifications = allNotifications.value;
if (notifications?.length) {
notifications.forEach((notification) => {
saveStatus(notification.status, instance, {
skipThreading: true,
});
});
2023-02-12 12:38:50 +03:00
const groupedNotifications = groupNotifications(notifications);
if (firstLoad) {
2023-02-12 12:38:50 +03:00
states.notificationsLast = notifications[0];
states.notifications = groupedNotifications;
} else {
states.notifications.push(...groupedNotifications);
2022-12-10 12:14:48 +03:00
}
}
2023-02-12 12:38:50 +03:00
states.notificationsShowNew = false;
2022-12-10 12:14:48 +03:00
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]);
2022-12-10 12:14:48 +03:00
useEffect(() => {
if (nearReachEnd && showMore) {
loadNotifications();
}
}, [nearReachEnd, showMore]);
2022-12-10 12:14:48 +03:00
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(),
2022-12-10 12:14:48 +03:00
);
2022-12-10 12:14:48 +03:00
return (
2022-12-30 15:37:57 +03:00
<div
id="notifications-page"
class="deck-container"
ref={scrollableRef}
tabIndex="-1"
>
<div class={`timeline-deck deck ${onlyMentions ? 'only-mentions' : ''}`}>
2022-12-24 12:54:42 +03:00
<header
hidden={hiddenUI}
2023-02-15 06:20:48 +03:00
onClick={(e) => {
if (!e.target.closest('a, button')) {
scrollableRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
}
2022-12-24 12:54:42 +03:00
}}
>
2023-02-08 14:11:33 +03:00
<div class="header-grid">
<div class="header-side">
2023-02-15 06:20:48 +03:00
<Menu />
2023-02-08 14:11:33 +03:00
<Link to="/" class="button plain">
<Icon icon="home" size="l" />
</Link>
</div>
<h1>Notifications</h1>
<div class="header-side">
<Loader hidden={uiState !== 'loading'} />
</div>
2022-12-10 12:14:48 +03:00
</div>
{snapStates.notificationsShowNew && uiState !== 'loading' && (
<button
2023-02-23 10:56:58 +03:00
class="updates-button shiny-pill"
type="button"
onClick={() => {
loadNotifications(true);
scrollableRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
});
}}
>
<Icon icon="arrow-up" /> New notifications
</button>
)}
2022-12-10 12:14:48 +03:00
</header>
<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>
)}
2022-12-10 12:14:48 +03:00
{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'
2023-03-01 17:18:45 +03:00
: niceDateTime(currentDay, {
2023-03-01 15:07:22 +03:00
hideTime: true,
});
return (
<>
{differentDay && <h2 class="timeline-header">{heading}</h2>}
<Notification
2023-02-18 19:05:46 +03:00
instance={instance}
notification={notification}
key={notification.id}
/>
</>
);
})}
2022-12-10 12:14:48 +03:00
</>
) : (
<>
{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>
2022-12-10 12:14:48 +03:00
)}
</>
)}
{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>
);
}
2023-02-18 19:05:46 +03:00
function Notification({ notification, instance }) {
2023-03-03 06:38:10 +03:00
const { id, status, account, _accounts } = 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 currentAccount = store.session.get('currentAccount');
const isSelf = currentAccount === account?.id;
const isVoted = status?.poll?.voted;
2023-03-03 06:38:10 +03:00
let favsCount = 0;
let reblogsCount = 0;
if (type === 'favourite+reblog') {
for (const account of _accounts) {
if (account._types?.includes('favourite')) {
favsCount++;
}
if (account._types?.includes('reblog')) {
2023-03-03 06:38:10 +03:00
reblogsCount++;
}
}
if (!reblogsCount && favsCount) type = 'favourite';
if (!favsCount && reblogsCount) type = 'reblog';
}
const text =
type === 'poll'
? contentText[isSelf ? 'poll-self' : isVoted ? 'poll-voted' : 'poll']
: contentText[type];
return (
<div class={`notification ${type}`} tabIndex="0">
<div
class={`notification-type notification-${type}`}
title={new Date(notification.createdAt).toLocaleString()}
>
2023-03-03 06:38:10 +03:00
{type === 'favourite+reblog' ? (
<>
<Icon icon="rocket" size="xl" alt={type} class="reblog-icon" />
<Icon icon="heart" size="xl" alt={type} class="favourite-icon" />
</>
) : (
<Icon
icon={NOTIFICATION_ICONS[type] || 'notification'}
size="xl"
alt={type}
/>
)}
</div>
<div class="notification-content">
{type !== 'mention' && (
<>
<p>
{!/poll|update/i.test(type) && (
<>
{_accounts?.length > 1 ? (
<>
<b>{_accounts.length} people</b>{' '}
</>
) : (
<>
<NameText account={account} showAvatar />{' '}
</>
)}
</>
)}
{text}
{type === 'mention' && (
<span class="insignificant">
{' '}
{' '}
<RelativeTime
datetime={notification.createdAt}
format="micro"
/>
</span>
)}
</p>
{type === 'follow_request' && (
<FollowRequestButtons
accountID={account.id}
onChange={() => {
loadNotifications(true);
}}
/>
)}
</>
)}
{_accounts?.length > 1 && (
<p class="avatars-stack">
{_accounts.map((account, i) => (
<>
<a
href={account.url}
rel="noopener noreferrer"
2023-03-03 06:38:10 +03:00
class="account-avatar-stack"
onClick={(e) => {
e.preventDefault();
states.showAccount = account;
}}
>
<Avatar
url={account.avatarStatic}
size={
_accounts.length <= 10
? 'xxl'
: _accounts.length < 100
? 'xl'
: _accounts.length < 1000
? 'l'
: _accounts.length < 2000
? 'm'
: 's' // My god, this person is popular!
}
key={account.id}
alt={`${account.displayName} @${account.acct}`}
/>
2023-03-03 06:38:10 +03:00
{type === 'favourite+reblog' && (
<div class="account-sub-icons">
{account._types.map((type) => (
<Icon
icon={NOTIFICATION_ICONS[type]}
size="s"
class={`${type}-icon`}
/>
))}
</div>
)}
</a>{' '}
</>
))}
</p>
)}
{status && (
<Link
class={`status-link status-type-${type}`}
2023-02-18 19:05:46 +03:00
to={
instance
? `/${instance}/s/${actualStatusID}`
: `/s/${actualStatusID}`
}
>
<Status status={status} size="s" />
</Link>
)}
2022-12-10 12:14:48 +03:00
</div>
</div>
);
2022-12-16 08:27:04 +03:00
}
function FollowRequestButtons({ accountID, onChange }) {
2023-02-18 19:05:46 +03:00
const { masto } = api();
const [uiState, setUIState] = useState('default');
return (
<p>
<button
type="button"
disabled={uiState === 'loading'}
onClick={() => {
setUIState('loading');
(async () => {
try {
await masto.v1.followRequests.authorize(accountID);
onChange();
} catch (e) {
console.error(e);
setUIState('default');
}
})();
}}
>
Accept
</button>{' '}
<button
type="button"
disabled={uiState === 'loading'}
class="light danger"
onClick={() => {
setUIState('loading');
(async () => {
try {
await masto.v1.followRequests.reject(accountID);
onChange();
} catch (e) {
console.error(e);
setUIState('default');
}
})();
}}
>
Reject
</button>
<Loader hidden={uiState !== 'loading'} />
</p>
);
}
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];
2023-01-31 21:09:07 +03:00
const { status, account, type, createdAt } = notification;
const date = new Date(createdAt).toLocaleDateString();
2023-03-03 06:38:10 +03:00
let virtualType = type;
if (type === 'favourite' || type === 'reblog') {
virtualType = 'favourite+reblog';
}
const key = `${status?.id}-${virtualType}-${date}`;
const mappedNotification = notificationsMap[key];
2023-03-03 06:38:10 +03:00
if (virtualType === 'follow_request') {
2023-02-15 09:49:15 +03:00
cleanNotifications[j++] = notification;
} else if (mappedNotification?.account) {
2023-03-03 06:38:10 +03:00
const mappedAccount = mappedNotification._accounts.find(
(a) => a.id === account.id,
);
if (mappedAccount) {
mappedAccount._types.push(type);
mappedAccount._types.sort().reverse();
} else {
account._types = [type];
mappedNotification._accounts.push(account);
}
} else {
2023-03-03 06:38:10 +03:00
account._types = [type];
let n = (notificationsMap[key] = {
...notification,
2023-03-03 06:38:10 +03:00
type: virtualType,
_accounts: [account],
});
cleanNotifications[j++] = n;
}
}
return cleanNotifications;
}
export default memo(Notifications);