/* eslint-disable react/destructuring-assignment */ import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Avatar, Box, Chip, Header, Icon, IconButton, Icons, Scroll, Text, config, toRem, } from 'folds'; import { useSearchParams } from 'react-router-dom'; import { INotification, INotificationsResponse, IRoomEvent, JoinRule, Method, RelationType, Room, } from 'matrix-js-sdk'; import { useVirtualizer } from '@tanstack/react-virtual'; import { HTMLReactParserOptions } from 'html-react-parser'; import { Opts as LinkifyOpts } from 'linkifyjs'; import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; import { InboxNotificationsPathSearchParams } from '../../paths'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { SequenceCard } from '../../../components/sequence-card'; import { RoomAvatar, RoomIcon } from '../../../components/room-avatar'; import { getEditedEvent, getMemberAvatarMxc, getMemberDisplayName, getRoomAvatarUrl, } from '../../../utils/room'; import { ScrollTopContainer } from '../../../components/scroll-top-container'; import { useInterval } from '../../../hooks/useInterval'; import { AvatarBase, ImageContent, MSticker, MessageNotDecryptedContent, MessageUnsupportedContent, ModernLayout, RedactedContent, Reply, Time, Username, } from '../../../components/message'; import colorMXID from '../../../../util/colorMXID'; import { factoryRenderLinkifyWithMention, getReactCustomHtmlParser, LINKIFY_OPTS, makeMentionCustomProps, renderMatrixMention, } from '../../../plugins/react-custom-html-parser'; import { RenderMessageContent } from '../../../components/RenderMessageContent'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; import { Image } from '../../../components/media'; import { ImageViewer } from '../../../components/image-viewer'; import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room'; import { useMatrixEventRenderer } from '../../../hooks/useMatrixEventRenderer'; import * as customHtmlCss from '../../../styles/CustomHtml.css'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; import { useRoomUnread } from '../../../state/hooks/unread'; import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; import { markAsRead } from '../../../../client/action/notifications'; import { ContainerColor } from '../../../styles/ContainerColor.css'; import { VirtualTile } from '../../../components/virtualizer'; import { UserAvatar } from '../../../components/user-avatar'; import { EncryptedContent } from '../../../features/room/message'; import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler'; import { useSpoilerClickHandler } from '../../../hooks/useSpoilerClickHandler'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; type RoomNotificationsGroup = { roomId: string; notifications: INotification[]; }; type NotificationTimeline = { nextToken?: string; groups: RoomNotificationsGroup[]; }; type LoadTimeline = (from?: string) => Promise; type SilentReloadTimeline = () => Promise; const groupNotifications = (notifications: INotification[]): RoomNotificationsGroup[] => { const groups: RoomNotificationsGroup[] = []; notifications.forEach((notification) => { const groupIndex = groups.length - 1; const lastAddedGroup: RoomNotificationsGroup | undefined = groups[groupIndex]; if (lastAddedGroup && notification.room_id === lastAddedGroup.roomId) { lastAddedGroup.notifications.push(notification); return; } groups.push({ roomId: notification.room_id, notifications: [notification], }); }); return groups; }; const useNotificationTimeline = ( paginationLimit: number, onlyHighlight?: boolean ): [NotificationTimeline, LoadTimeline, SilentReloadTimeline] => { const mx = useMatrixClient(); const [notificationTimeline, setNotificationTimeline] = useState({ groups: [], }); const fetchNotifications = useCallback( (from?: string, limit?: number, only?: 'highlight') => { const queryParams = { from, limit, only }; return mx.http.authedRequest( Method.Get, '/notifications', queryParams ); }, [mx] ); const loadTimeline: LoadTimeline = useCallback( async (from) => { if (!from) { setNotificationTimeline({ groups: [] }); } const data = await fetchNotifications( from, paginationLimit, onlyHighlight ? 'highlight' : undefined ); const groups = groupNotifications(data.notifications); setNotificationTimeline((currentTimeline) => { if (currentTimeline.nextToken === from) { return { nextToken: data.next_token, groups: from ? currentTimeline.groups.concat(groups) : groups, }; } return currentTimeline; }); }, [paginationLimit, onlyHighlight, fetchNotifications] ); /** * Reload timeline silently i.e without setting to default * before fetching notifications from start */ const silentReloadTimeline: SilentReloadTimeline = useCallback(async () => { const data = await fetchNotifications( undefined, paginationLimit, onlyHighlight ? 'highlight' : undefined ); const groups = groupNotifications(data.notifications); setNotificationTimeline({ nextToken: data.next_token, groups, }); }, [paginationLimit, onlyHighlight, fetchNotifications]); return [notificationTimeline, loadTimeline, silentReloadTimeline]; }; type RoomNotificationsGroupProps = { room: Room; notifications: INotification[]; mediaAutoLoad?: boolean; urlPreview?: boolean; hideActivity: boolean; onOpen: (roomId: string, eventId: string) => void; }; function RoomNotificationsGroupComp({ room, notifications, mediaAutoLoad, urlPreview, hideActivity, onOpen, }: RoomNotificationsGroupProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const mentionClickHandler = useMentionClickHandler(room.roomId); const spoilerClickHandler = useSpoilerClickHandler(); const linkifyOpts = useMemo( () => ({ ...LINKIFY_OPTS, render: factoryRenderLinkifyWithMention((href) => renderMatrixMention(mx, room.roomId, href, makeMentionCustomProps(mentionClickHandler)) ), }), [mx, room, mentionClickHandler] ); const htmlReactParserOptions = useMemo( () => getReactCustomHtmlParser(mx, room.roomId, { linkifyOpts, useAuthentication, handleSpoilerClick: spoilerClickHandler, handleMentionClick: mentionClickHandler, }), [mx, room, linkifyOpts, mentionClickHandler, spoilerClickHandler, useAuthentication] ); const renderMatrixEvent = useMatrixEventRenderer<[IRoomEvent, string, GetContentCallback]>( { [MessageEvent.RoomMessage]: (event, displayName, getContent) => { if (event.unsigned?.redacted_because) { return ; } return ( ); }, [MessageEvent.RoomMessageEncrypted]: (evt, displayName) => { const evtTimeline = room.getTimelineForEvent(evt.event_id); const mEvent = evtTimeline?.getEvents().find((e) => e.getId() === evt.event_id); if (!mEvent || !evtTimeline) { return ( {evt.type} {' event'} ); } return ( {() => { if (mEvent.isRedacted()) return ; if (mEvent.getType() === MessageEvent.Sticker) return ( ( } renderViewer={(p) => } /> )} /> ); if (mEvent.getType() === MessageEvent.RoomMessage) { const editedEvent = getEditedEvent( evt.event_id, mEvent, evtTimeline.getTimelineSet() ); const getContent = (() => editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback; return ( ); } if (mEvent.getType() === MessageEvent.RoomMessageEncrypted) return ( ); return ( ); }} ); }, [MessageEvent.Sticker]: (event, displayName, getContent) => { if (event.unsigned?.redacted_because) { return ; } return ( ( } renderViewer={(p) => } /> )} /> ); }, [StateEvent.RoomTombstone]: (event) => { const { content } = event; return ( Room Tombstone. {content.body} ); }, }, undefined, (event) => { if (event.unsigned?.redacted_because) { return ; } return ( {event.type} {' event'} ); } ); const handleOpenClick: MouseEventHandler = (evt) => { const eventId = evt.currentTarget.getAttribute('data-event-id'); if (!eventId) return; onOpen(room.roomId, eventId); }; const handleMarkAsRead = () => { markAsRead(mx, room.roomId, hideActivity); }; return (
( )} /> {room.name} {unread && ( } > Mark as Read )}
{notifications.map((notification) => { const { event } = notification; const displayName = getMemberDisplayName(room, event.sender) ?? getMxIdLocalPart(event.sender) ?? event.sender; const senderAvatarMxc = getMemberAvatarMxc(room, event.sender); const getContent = (() => event.content) as GetContentCallback; const relation = event.content['m.relates_to']; const replyEventId = relation?.['m.in_reply_to']?.event_id; const threadRootId = relation?.rel_type === RelationType.Thread ? relation.event_id : undefined; return ( } /> } > {displayName} Open {replyEventId && ( )} {renderMatrixEvent(event.type, false, event, displayName, getContent)} ); })}
); } const useNotificationsSearchParams = ( searchParams: URLSearchParams ): InboxNotificationsPathSearchParams => useMemo( () => ({ only: searchParams.get('only') ?? undefined, }), [searchParams] ); const DEFAULT_REFRESH_MS = 7000; export function Notifications() { const mx = useMatrixClient(); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); const screenSize = useScreenSizeContext(); const { navigateRoom } = useRoomNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const notificationsSearchParams = useNotificationsSearchParams(searchParams); const scrollRef = useRef(null); const scrollTopAnchorRef = useRef(null); const [refreshIntervalTime, setRefreshIntervalTime] = useState(DEFAULT_REFRESH_MS); const onlyHighlight = notificationsSearchParams.only === 'highlight'; const setOnlyHighlighted = (highlight: boolean) => { if (highlight) { setSearchParams( new URLSearchParams({ only: 'highlight', }) ); return; } setSearchParams(); }; const [notificationTimeline, _loadTimeline, silentReloadTimeline] = useNotificationTimeline( 24, onlyHighlight ); const [timelineState, loadTimeline] = useAsyncCallback(_loadTimeline); const virtualizer = useVirtualizer({ count: notificationTimeline.groups.length, getScrollElement: () => scrollRef.current, estimateSize: () => 40, overscan: 4, }); const vItems = virtualizer.getVirtualItems(); useInterval( useCallback(() => { silentReloadTimeline(); }, [silentReloadTimeline]), refreshIntervalTime ); const handleScrollTopVisibility = useCallback( (onTop: boolean) => setRefreshIntervalTime(onTop ? DEFAULT_REFRESH_MS : -1), [] ); useEffect(() => { loadTimeline(); }, [loadTimeline]); const lastVItem = vItems[vItems.length - 1]; const lastVItemIndex: number | undefined = lastVItem?.index; useEffect(() => { if ( timelineState.status === AsyncStatus.Success && notificationTimeline.groups.length - 1 === lastVItemIndex && notificationTimeline.nextToken ) { loadTimeline(notificationTimeline.nextToken); } }, [timelineState, notificationTimeline, lastVItemIndex, loadTimeline]); return ( {screenSize === ScreenSize.Mobile && ( {(onBack) => ( )} )} {screenSize !== ScreenSize.Mobile && } Notification Messages Filter setOnlyHighlighted(false)} variant={!onlyHighlight ? 'Success' : 'Surface'} aria-pressed={!onlyHighlight} before={!onlyHighlight && } outlined > All Notifications setOnlyHighlighted(true)} variant={onlyHighlight ? 'Success' : 'Surface'} aria-pressed={onlyHighlight} before={onlyHighlight && } outlined > Highlighted virtualizer.scrollToOffset(0)} variant="SurfaceVariant" radii="Pill" outlined size="300" aria-label="Scroll to Top" >
{vItems.map((vItem) => { const group = notificationTimeline.groups[vItem.index]; if (!group) return null; const groupRoom = mx.getRoom(group.roomId); if (!groupRoom) return null; return ( ); })}
{timelineState.status === AsyncStatus.Success && notificationTimeline.groups.length === 0 && ( No Notifications You don't have any new notifications to display yet. )} {timelineState.status === AsyncStatus.Loading && ( {[...Array(8).keys()].map((key) => ( ))} )} {timelineState.status === AsyncStatus.Error && ( {(timelineState.error as Error).name} {(timelineState.error as Error).message} )}
); }