import './notifications.css'; import { memo } from 'preact/compat'; import { useEffect, useRef, useState } from 'preact/hooks'; import { useSnapshot } from 'valtio'; import Avatar from '../components/avatar'; import Icon from '../components/icon'; import Link from '../components/link'; import Loader from '../components/loader'; import NameText from '../components/name-text'; import NavMenu from '../components/nav-menu'; import RelativeTime from '../components/relative-time'; import Status from '../components/status'; import { api } from '../utils/api'; import niceDateTime from '../utils/nice-date-time'; import states, { saveStatus } from '../utils/states'; import store from '../utils/store'; import useScroll from '../utils/useScroll'; 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 post.', status: 'published a post.', reblog: 'boosted your post.', follow: 'followed you.', follow_request: 'requested to follow you.', favourite: 'favourited your post.', 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.', update: 'A post you interacted with has been edited.', 'favourite+reblog': 'boosted & favourited your post.', }; const NOTIFICATION_ICONS = { mention: 'comment', status: 'notification', reblog: 'rocket', follow: 'follow', follow_request: 'follow-add', favourite: 'heart', poll: 'poll', update: 'pencil', }; const LIMIT = 30; // 30 is the maximum limit :( function Notifications() { useTitle('Notifications', '/notifications'); const { masto, instance } = api(); 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({ scrollableRef, }); const hiddenUI = scrollDirection === 'end' && !nearReachStart; console.debug('RENDER Notifications'); const notificationsIterator = useRef(); async function fetchNotifications(firstLoad) { if (firstLoad || !notificationsIterator.current) { // Reset iterator notificationsIterator.current = masto.v1.notifications.list({ limit: LIMIT, }); } const allNotifications = await notificationsIterator.current.next(); const notifications = allNotifications.value; if (notifications?.length) { notifications.forEach((notification) => { saveStatus(notification.status, instance, { skipThreading: true, }); }); const groupedNotifications = groupNotifications(notifications); if (firstLoad) { states.notificationsLast = notifications[0]; states.notifications = groupedNotifications; } else { states.notifications.push(...groupedNotifications); } } states.notificationsShowNew = false; 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 (

Today

{showTodayEmpty && !!snapStates.notifications.length && (

{uiState === 'default' ? "You're all caught up." : <>…}

)} {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' : niceDateTime(currentDay, { hideTime: true, }); return ( <> {differentDay &&

{heading}

} ); })} ) : ( <> {uiState === 'loading' && ( <>
    {Array.from({ length: 5 }).map((_, i) => (
  • ███████████ ████

  • ))}
)} {uiState === 'error' && (

Unable to load notifications

)} )} {showMore && ( )}
); } function Notification({ notification, instance }) { 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; 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')) { 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 (
{type === 'favourite+reblog' ? ( <> ) : ( )}
{type !== 'mention' && ( <>

{!/poll|update/i.test(type) && ( <> {_accounts?.length > 1 ? ( <> {_accounts.length} people{' '} ) : ( <> {' '} )} )} {text} {type === 'mention' && ( {' '} •{' '} )}

{type === 'follow_request' && ( { loadNotifications(true); }} /> )} )} {_accounts?.length > 1 && (

{_accounts.map((account, i) => ( <>

{' '} ))}

)} {status && ( )}
); } function FollowRequestButtons({ accountID, onChange }) { const { masto } = api(); const [uiState, setUIState] = useState('default'); return (

{' '}

); } 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, createdAt } = notification; const date = new Date(createdAt).toLocaleDateString(); let virtualType = type; if (type === 'favourite' || type === 'reblog') { virtualType = 'favourite+reblog'; } const key = `${status?.id}-${virtualType}-${date}`; const mappedNotification = notificationsMap[key]; if (virtualType === 'follow_request') { cleanNotifications[j++] = notification; } else if (mappedNotification?.account) { 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 { account._types = [type]; let n = (notificationsMap[key] = { ...notification, type: virtualType, _accounts: [account], }); cleanNotifications[j++] = n; } } return cleanNotifications; } export default memo(Notifications);