import './notifications.css'; import { msg, Plural, t, Trans } from '@lingui/macro'; import { useLingui } from '@lingui/react'; import { Fragment } from 'preact'; import { memo } from 'preact/compat'; import { useCallback, useEffect, useMemo, useRef, useState, } from 'preact/hooks'; import { useHotkeys } from 'react-hotkeys-hook'; import { InView } from 'react-intersection-observer'; import { useSearchParams } from 'react-router-dom'; import { useSnapshot } from 'valtio'; import { subscribeKey } from 'valtio/utils'; import AccountBlock from '../components/account-block'; import FollowRequestButtons from '../components/follow-request-buttons'; import Icon from '../components/icon'; import Link from '../components/link'; import Loader from '../components/loader'; import Modal from '../components/modal'; import NavMenu from '../components/nav-menu'; import Notification from '../components/notification'; import Status from '../components/status'; import { api } from '../utils/api'; import enhanceContent from '../utils/enhance-content'; import groupNotifications, { groupNotifications2, massageNotifications2, } from '../utils/group-notifications'; import handleContentLinks from '../utils/handle-content-links'; import mem from '../utils/mem'; import niceDateTime from '../utils/nice-date-time'; import { getRegistration } from '../utils/push-notifications'; import shortenNumber from '../utils/shorten-number'; import showToast from '../utils/show-toast'; import states, { saveStatus } from '../utils/states'; import { getCurrentInstance } from '../utils/store-utils'; import supports from '../utils/supports'; import usePageVisibility from '../utils/usePageVisibility'; import useScroll from '../utils/useScroll'; import useTitle from '../utils/useTitle'; const NOTIFICATIONS_LIMIT = 80; const NOTIFICATIONS_GROUPED_LIMIT = 20; const emptySearchParams = new URLSearchParams(); const scrollIntoViewOptions = { block: 'center', inline: 'center', behavior: 'smooth', }; const memSupportsGroupedNotifications = mem( () => supports('@mastodon/grouped-notifications'), { maxAge: 1000 * 60 * 5, // 5 minutes }, ); export function mastoFetchNotifications(opts = {}) { const { masto } = api(); if ( states.settings.groupedNotificationsAlpha && memSupportsGroupedNotifications() ) { // https://github.com/mastodon/mastodon/pull/29889 return masto.v2.notifications.list({ limit: NOTIFICATIONS_GROUPED_LIMIT, ...opts, }); } else { return masto.v1.notifications.list({ limit: NOTIFICATIONS_LIMIT, ...opts, }); } } export function getGroupedNotifications(notifications) { if ( states.settings.groupedNotificationsAlpha && memSupportsGroupedNotifications() ) { return groupNotifications2(notifications); } else { return groupNotifications(notifications); } } const NOTIFICATIONS_POLICIES = [ 'forNotFollowing', 'forNotFollowers', 'forNewAccounts', 'forPrivateMentions', 'forLimitedAccounts', ]; const NOTIFICATIONS_POLICIES_TEXT = { forNotFollowing: msg`You don't follow`, forNotFollowers: msg`Who don't follow you`, forNewAccounts: msg`With a new account`, forPrivateMentions: msg`Who unsolicitedly private mention you`, forLimitedAccounts: msg`Who are limited by server moderators`, }; function Notifications({ columnMode }) { const { _ } = useLingui(); useTitle(t`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(); const { nearReachEnd, scrollDirection, reachStart, nearReachStart } = useScroll({ scrollableRef, }); const hiddenUI = scrollDirection === 'end' && !nearReachStart; const [followRequests, setFollowRequests] = useState([]); const [announcements, setAnnouncements] = useState([]); console.debug('RENDER Notifications'); const notificationsIterator = useRef(); async function fetchNotifications(firstLoad) { if (firstLoad || !notificationsIterator.current) { // Reset iterator notificationsIterator.current = mastoFetchNotifications({ excludeTypes: ['follow_request'], }); } if (/max_id=($|&)/i.test(notificationsIterator.current?.nextParams)) { // Pixelfed returns next paginationed link with empty max_id // I assume, it's done (end of list) return { done: true, }; } const allNotifications = await notificationsIterator.current.next(); const notifications = massageNotifications2(allNotifications.value); if (notifications?.length) { notifications.forEach((notification) => { saveStatus(notification.status, instance, { skipThreading: true, }); }); // TEST: Slot in a fake notification to test 'severed_relationships' // notifications.unshift({ // id: '123123', // type: 'severed_relationships', // createdAt: '2024-03-22T19:20:08.316Z', // event: { // type: 'account_suspension', // targetName: 'mastodon.dev', // followersCount: 0, // followingCount: 0, // }, // }); // TEST: Slot in a fake notification to test 'moderation_warning' // notifications.unshift({ // id: '123123', // type: 'moderation_warning', // createdAt: new Date().toISOString(), // moderation_warning: { // id: '1231234', // action: 'mark_statuses_as_sensitive', // }, // }); // console.log({ notifications }); const groupedNotifications = getGroupedNotifications(notifications); if (firstLoad) { states.notificationsLast = groupedNotifications[0]; states.notifications = groupedNotifications; // Update last read marker masto.v1.markers .create({ notifications: { lastReadId: groupedNotifications[0].id, }, }) .catch(() => {}); } else { states.notifications.push(...groupedNotifications); } } states.notificationsShowNew = false; states.notificationsLastFetchTime = Date.now(); return allNotifications; } async function fetchFollowRequests() { // Note: no pagination here yet because this better be on a separate page. Should be rare use-case??? try { return await masto.v1.followRequests.list({ limit: 80, }); } catch (e) { // Silently fail return []; } } const loadFollowRequests = () => { setUIState('loading'); (async () => { try { const requests = await fetchFollowRequests(); setFollowRequests(requests); setUIState('default'); } catch (e) { setUIState('error'); } })(); }; async function fetchAnnouncements() { try { return await masto.v1.announcements.list(); } catch (e) { // Silently fail return []; } } const supportsFilteredNotifications = supports( '@mastodon/filtered-notifications', ); const [showNotificationsSettings, setShowNotificationsSettings] = useState(false); const [notificationsPolicy, setNotificationsPolicy] = useState({}); function fetchNotificationsPolicy() { return masto.v2.notifications.policy.fetch().catch(() => {}); } function loadNotificationsPolicy() { fetchNotificationsPolicy() .then((policy) => { console.log('β¨ Notifications policy', policy); setNotificationsPolicy(policy); }) .catch(() => {}); } const [notificationsRequests, setNotificationsRequests] = useState(null); function fetchNotificationsRequest() { return masto.v1.notifications.requests.list(); } const loadNotifications = (firstLoad) => { setShowNew(false); setUIState('loading'); (async () => { try { const fetchNotificationsPromise = fetchNotifications(firstLoad); if (firstLoad) { fetchAnnouncements() .then((announcements) => { announcements.sort((a, b) => { // Sort by updatedAt first, then createdAt const aDate = new Date(a.updatedAt || a.createdAt); const bDate = new Date(b.updatedAt || b.createdAt); return bDate - aDate; }); setAnnouncements(announcements); }) .catch(() => {}); fetchFollowRequests() .then((requests) => { setFollowRequests(requests); }) .catch(() => {}); if (supportsFilteredNotifications) { loadNotificationsPolicy(); } } const { done } = await fetchNotificationsPromise; setShowMore(!done); setUIState('default'); } catch (e) { console.error(e); setUIState('error'); } })(); }; useEffect(() => { loadNotifications(true); }, []); useEffect(() => { if (reachStart) { loadNotifications(true); } }, [reachStart]); // useEffect(() => { // if (nearReachEnd && showMore) { // loadNotifications(); // } // }, [nearReachEnd, showMore]); const [showNew, setShowNew] = useState(false); const loadUpdates = useCallback( ({ disableIdleCheck = false } = {}) => { if (uiState === 'loading') { return; } console.log('β¨ Load updates', { autoRefresh: snapStates.settings.autoRefresh, scrollTop: scrollableRef.current?.scrollTop, inBackground: inBackground(), disableIdleCheck, }); if ( snapStates.settings.autoRefresh && scrollableRef.current?.scrollTop < 16 && (disableIdleCheck || window.__IDLE__) && !inBackground() ) { loadNotifications(true); } }, [snapStates.notificationsShowNew, snapStates.settings.autoRefresh, uiState], ); // useEffect(loadUpdates, [snapStates.notificationsShowNew]); const lastHiddenTime = useRef(); usePageVisibility((visible) => { if (visible) { const timeDiff = Date.now() - lastHiddenTime.current; if (!lastHiddenTime.current || timeDiff > 1000 * 3) { // 3 seconds loadUpdates({ disableIdleCheck: true, }); } else { lastHiddenTime.current = Date.now(); } } }); const firstLoad = useRef(true); useEffect(() => { let unsub = subscribeKey(states, 'notificationsShowNew', (v) => { if (firstLoad.current) { firstLoad.current = false; return; } if (uiState === 'loading') return; if (v) loadUpdates(); setShowNew(v); }); return () => unsub?.(); }, []); 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(), ); const announcementsListRef = useRef(); useEffect(() => { if (notificationID) { states.routeNotification = { id: notificationID, accessToken: atob(notificationAccessToken), }; } }, [notificationID, notificationAccessToken]); // useEffect(() => { // if (uiState === 'default') { // (async () => { // try { // const registration = await getRegistration(); // if (registration?.getNotifications) { // const notifications = await registration.getNotifications(); // console.log('π Push notifications', notifications); // // Close all notifications? // // notifications.forEach((notification) => { // // notification.close(); // // }); // } // } catch (e) {} // })(); // } // }, [uiState]); const itemsSelector = '.notification'; const jRef = useHotkeys('j', () => { const activeItem = document.activeElement.closest(itemsSelector); const activeItemRect = activeItem?.getBoundingClientRect(); const allItems = Array.from( scrollableRef.current.querySelectorAll(itemsSelector), ); if ( activeItem && activeItemRect.top < scrollableRef.current.clientHeight && activeItemRect.bottom > 0 ) { const activeItemIndex = allItems.indexOf(activeItem); let nextItem = allItems[activeItemIndex + 1]; if (nextItem) { nextItem.focus(); nextItem.scrollIntoView(scrollIntoViewOptions); } } else { const topmostItem = allItems.find((item) => { const itemRect = item.getBoundingClientRect(); return itemRect.top >= 44 && itemRect.left >= 0; }); if (topmostItem) { topmostItem.focus(); topmostItem.scrollIntoView(scrollIntoViewOptions); } } }); const kRef = useHotkeys('k', () => { // focus on previous status after active item const activeItem = document.activeElement.closest(itemsSelector); const activeItemRect = activeItem?.getBoundingClientRect(); const allItems = Array.from( scrollableRef.current.querySelectorAll(itemsSelector), ); if ( activeItem && activeItemRect.top < scrollableRef.current.clientHeight && activeItemRect.bottom > 0 ) { const activeItemIndex = allItems.indexOf(activeItem); let prevItem = allItems[activeItemIndex - 1]; if (prevItem) { prevItem.focus(); prevItem.scrollIntoView(scrollIntoViewOptions); } } else { const topmostItem = allItems.find((item) => { const itemRect = item.getBoundingClientRect(); return itemRect.top >= 44 && itemRect.left >= 0; }); if (topmostItem) { topmostItem.focus(); topmostItem.scrollIntoView(scrollIntoViewOptions); } } }); const oRef = useHotkeys(['enter', 'o'], () => { const activeItem = document.activeElement.closest(itemsSelector); const statusLink = activeItem?.querySelector('.status-link'); if (statusLink) { statusLink.click(); } }); const today = new Date(); const todaySubHeading = useMemo(() => { return niceDateTime(today, { forceOpts: { weekday: 'long', }, }); }, [today]); return (
{uiState === 'default' ? t`You're all caught up.` : <>…>}
)} {snapStates.notifications.length ? ( <> {snapStates.notifications // This is leaked from Notifications popover .filter((n) => n.type !== 'follow_request') .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() ? t`Yesterday` : niceDateTime(currentDay, { hideTime: true, }); const subHeading = niceDateTime(currentDay, { forceOpts: { weekday: 'long', }, }); return (βββββββββββ ββββ
{updatedAt && updatedAtText !== publishedDateText && (
<>
{' '}
•{' '}