diff --git a/src/app/hooks/useRoomMembers.ts b/src/app/hooks/useRoomMembers.ts index e7532dd5..6574016f 100644 --- a/src/app/hooks/useRoomMembers.ts +++ b/src/app/hooks/useRoomMembers.ts @@ -3,7 +3,7 @@ import { PendingObservableResult } from 'neurone/lib/utils/observable'; import { RoomMembership } from 'neurone/lib/api/types'; import { useClient } from './useClient'; import { useObservableValuePromise } from './useObservableValue'; -import { JoinRoomMemberObject } from '../../client/storage/collections'; +import { getJoinRoomMemberId, JoinRoomMemberObject } from '../../client/storage/collections'; import { getMxIdLocalPart } from '../utils/matrix'; import { roomMemberContentAvatarUrl, @@ -23,14 +23,9 @@ export type RoomMembersAPI = { memberEventsResult: PendingObservableResult; }; -export const useCreateRoomMembersAPI = (roomId: string): RoomMembersAPI => { - const ct = useClient(); - const memberEventsPromise = useMemo( - () => ct.storage.joinRoomMember.observableRead.queryIndex('roomId', roomId), - [ct, roomId] - ); - - const memberEventsResult = useObservableValuePromise(memberEventsPromise); +const useMemberEvents = ( + memberEventsResult: PendingObservableResult +): RoomMembersAPI => { const memberEvents = memberEventsResult.value; const userIdToMemberEvent = useMemo(() => { @@ -92,6 +87,18 @@ export const useCreateRoomMembersAPI = (roomId: string): RoomMembersAPI => { return api; }; +export const useCreateRoomMembersAPI = (roomId: string): RoomMembersAPI => { + const ct = useClient(); + const memberEventsPromise = useMemo( + () => ct.storage.joinRoomMember.observableRead.queryIndex('roomId', roomId), + [ct, roomId] + ); + + const memberEventsResult = useObservableValuePromise(memberEventsPromise); + + return useMemberEvents(memberEventsResult); +}; + const RoomMembersAPIContext = createContext(null); export const RoomMembersAPIProvider = RoomMembersAPIContext.Provider; @@ -103,3 +110,27 @@ export const useRoomMembersAPI = (): RoomMembersAPI => { return api; }; + +export const useRoomMembers = (roomId: string, users: string[]) => { + const ct = useClient(); + const memberEventsPromise = useMemo( + () => + ct.storage.joinRoomMember.observableRead.getMultiple( + users.map((userId) => getJoinRoomMemberId(roomId, userId)) + ), + [ct, roomId, users] + ); + + const memberEventsResult = useObservableValuePromise(memberEventsPromise); + + const memberEvents = useMemo(() => { + if (memberEventsResult.value) { + return { + value: memberEventsResult.value.filter((i) => i !== undefined), + }; + } + return memberEventsResult; + }, [memberEventsResult]); + + return useMemberEvents(memberEvents); +}; diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index c870bb89..ef4d19e6 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -1,5 +1,13 @@ /* eslint-disable react/destructuring-assignment */ -import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { + MouseEventHandler, + ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { Avatar, Box, @@ -14,75 +22,50 @@ import { 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 { useAtomValue } from 'jotai'; +import { + getNotifications, + GetNotificationsOK, +} from 'neurone/lib/api/matrix/client/v3/notifications'; +import { parseMatrixResp } from 'neurone/lib/matrix-request'; +import { NotificationChunk } from 'neurone/lib/api/types'; 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, UsernameBold, } from '../../../components/message'; -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 { 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 { useSettings } from '../../../state/hooks/settings'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; -import { markAsRead } from '../../../utils/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'; import { allRoomsAtom } from '../../../state/room-list/roomList'; -import { usePowerLevels } from '../../../hooks/usePowerLevels'; -import { usePowerLevelTags } from '../../../hooks/usePowerLevelTags'; +import { + PowerLevelsContextProvider, + usePowerLevels, + usePowerLevelsContext, + usePowerLevelsState, +} from '../../../hooks/usePowerLevels'; +import { + PowerLevelTagsContextProvider, + usePowerLevelTags, + usePowerLevelTagsContext, + usePowerLevelTagsState, +} from '../../../hooks/usePowerLevelTags'; import { useTheme } from '../../../hooks/useTheme'; import { PowerIcon } from '../../../components/power'; import colorMXID from '../../../../util/colorMXID'; @@ -94,10 +77,33 @@ import { } from '../../../hooks/useMemberPowerTag'; import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; +import { useClient } from '../../../hooks/useClient'; +import { + RoomMembersAPIProvider, + useRoomMembers, + useRoomMembersAPI, +} from '../../../hooks/useRoomMembers'; +import { useMessageParserOptions } from '../../../hooks/useMessageParserOptions'; +import { useRoomHeroes } from '../../../hooks/storage/useRoomHeros'; +import { GetRoomNameCallback, useGetRoomName } from '../../../hooks/storage/useGetRoomName'; +import { GetRoomAvatarCallback, useGetRoomAvatar } from '../../../hooks/storage/useGetRoomAvatar'; +import { mxcToHttp } from '../../../../client/mxcToHttp'; +import { useRoomUnread } from '../../../state/hooks/unread'; +import { unreadRoomsAtom } from '../../../state/room/roomToUnread'; +import { ErrorCode } from '../../../cs-errorcode'; +import { + useObservableValue, + useObservableValueOptionally, +} from '../../../hooks/useObservableValue'; +import { Timeline } from '../../../../client/room'; +import { MessageContent } from '../../../components/MessageContent'; +import { getLatestEditEvent } from '../../../utils/room'; +import { RenderMessageContent } from '../../../components/RenderMessageContent'; +import { rContent } from '../../../../client/reader'; type RoomNotificationsGroup = { roomId: string; - notifications: INotification[]; + notifications: NotificationChunk[]; }; type NotificationTimeline = { nextToken?: string; @@ -107,7 +113,7 @@ type LoadTimeline = (from?: string) => Promise; type SilentReloadTimeline = () => Promise; const groupNotifications = ( - notifications: INotification[], + notifications: NotificationChunk[], allowRooms: Set ): RoomNotificationsGroup[] => { const groups: RoomNotificationsGroup[] = []; @@ -132,7 +138,7 @@ const useNotificationTimeline = ( paginationLimit: number, onlyHighlight?: boolean ): [NotificationTimeline, LoadTimeline, SilentReloadTimeline] => { - const mx = useMatrixClient(); + const ct = useClient(); const allRooms = useAtomValue(allRoomsAtom); const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]); @@ -141,15 +147,21 @@ const useNotificationTimeline = ( }); const fetchNotifications = useCallback( - (from?: string, limit?: number, only?: 'highlight') => { - const queryParams = { from, limit, only }; - return mx.http.authedRequest( - Method.Get, - '/notifications', - queryParams + async (from?: string, limit?: number, only?: 'highlight') => { + const resp = await ct.requestAuthJSON( + getNotifications({ searchParams: { from, limit, only } }) ); + const [err, result] = await parseMatrixResp(resp); + if (err) { + if (err.data.errcode === ErrorCode.M_UNRECOGNIZED) { + throw new Error('This feature is not supported by your homeserver!'); + } + throw new Error(err.data.error ?? err.data.errcode); + } + + return result; }, - [mx] + [ct] ); const loadTimeline: LoadTimeline = useCallback( @@ -178,8 +190,12 @@ const useNotificationTimeline = ( ); /** - * Reload timeline silently i.e without setting to default - * before fetching notifications from start + * Reload timeline silently i.e without resetting to default. + * Fetches the first page and diffs it against current state: + * - if nothing new, keeps the existing state reference untouched + * - if there are new notifications, only prepends them to the top + * (merging into the first existing group when it's the same room) + * instead of re-fetching/rebuilding the whole timeline. */ const silentReloadTimeline: SilentReloadTimeline = useCallback(async () => { const data = await fetchNotifications( @@ -187,221 +203,256 @@ const useNotificationTimeline = ( paginationLimit, onlyHighlight ? 'highlight' : undefined ); - const groups = groupNotifications(data.notifications, allJoinedRooms); - setNotificationTimeline({ - nextToken: data.next_token, - groups, + + setNotificationTimeline((currentTimeline) => { + // Nothing loaded yet, just seed the state normally. + if (currentTimeline.groups.length === 0) { + return { + nextToken: data.next_token, + groups: groupNotifications(data.notifications, allJoinedRooms), + }; + } + + const existingIds = new Set(); + currentTimeline.groups.forEach((group) => { + group.notifications.forEach((n) => existingIds.add(n.event.event_id)); + }); + + const newNotifications = data.notifications.filter((n) => !existingIds.has(n.event.event_id)); + + // Nothing changed — return the same reference so consumers relying + // on identity (e.g. memoized lists) don't needlessly re-render. + if (newNotifications.length === 0) { + return currentTimeline; + } + + const newGroups = groupNotifications(newNotifications, allJoinedRooms); + + const lastNewGroup = newGroups[newGroups.length - 1]; + const firstExistingGroup = currentTimeline.groups[0]; + + // Merge into the existing first group if it's the same room, so we + // don't end up with two adjacent groups for the same room. + if (lastNewGroup && firstExistingGroup?.roomId === lastNewGroup.roomId) { + const mergedGroup: RoomNotificationsGroup = { + roomId: firstExistingGroup.roomId, + notifications: [...lastNewGroup.notifications, ...firstExistingGroup.notifications], + }; + return { + nextToken: currentTimeline.nextToken, + groups: [...newGroups.slice(0, -1), mergedGroup, ...currentTimeline.groups.slice(1)], + }; + } + + return { + nextToken: currentTimeline.nextToken, + groups: [...newGroups, ...currentTimeline.groups], + }; }); }, [paginationLimit, onlyHighlight, fetchNotifications, allJoinedRooms]); return [notificationTimeline, loadTimeline, silentReloadTimeline]; }; -type RoomNotificationsGroupProps = { - room: Room; - notifications: INotification[]; +type RoomNotificationsProps = { + roomId: string; + timeline?: Timeline; + notifications: NotificationChunk[]; mediaAutoLoad?: boolean; urlPreview?: boolean; - hideActivity: boolean; - onOpen: (roomId: string, eventId: string) => void; legacyUsernameColor?: boolean; hour24Clock: boolean; dateFormatString: string; + getRoomName: GetRoomNameCallback; + onOpen: (roomId: string, eventId: string) => void; }; -function RoomNotificationsGroupComp({ - room, +function RoomNotifications({ + roomId, + timeline, notifications, mediaAutoLoad, urlPreview, - hideActivity, - onOpen, legacyUsernameColor, hour24Clock, dateFormatString, -}: RoomNotificationsGroupProps) { - const mx = useMatrixClient(); + getRoomName, + onOpen, +}: RoomNotificationsProps) { + const ct = useClient(); + const membersAPI = useRoomMembersAPI(); const useAuthentication = useMediaAuthentication(); - // const unread = useRoomUnread(room.roomId, roomToUnreadAtom); - const powerLevels = usePowerLevels(room); - const creators = useRoomCreators(room); + const redacted = useObservableValueOptionally(timeline?.relations.redacted); + const edits = useObservableValueOptionally(timeline?.relations.edits.eventToRelations); + const creators = useRoomCreators(roomId); + const powerLevels = usePowerLevelsContext(); + const getMemberPowerTag = useGetMemberPowerTag(creators, powerLevels); const creatorsTag = useRoomCreatorsTag(); - const powerLevelTags = usePowerLevelTags(room, powerLevels); - const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels); + const powerLevelTags = usePowerLevelTagsContext(); const theme = useTheme(); const accessibleTagColors = useAccessiblePowerTagColors(theme.kind, creatorsTag, powerLevelTags); - 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 { linkifyOpts, htmlReactParserOptions } = useMessageParserOptions( + roomId, + getRoomName, + membersAPI.getName ); const handleOpenClick: MouseEventHandler = (evt) => { const eventId = evt.currentTarget.getAttribute('data-event-id'); if (!eventId) return; - onOpen(room.roomId, eventId); + onOpen(roomId, eventId); }; + + return ( + + {notifications.map((notification) => { + const { event } = notification; + + const displayName = membersAPI.getName(event.sender); + + const senderAvatarMxc = membersAPI.getAvatar(event.sender); + + const memberPowerTag = getMemberPowerTag(event.sender); + const tagColor = memberPowerTag?.color + ? accessibleTagColors?.get(memberPowerTag.color) + : undefined; + const tagIconSrc = memberPowerTag?.icon + ? getPowerTagIconSrc(ct, useAuthentication, memberPowerTag.icon) + : undefined; + + const usernameColor = legacyUsernameColor ? colorMXID(event.sender) : tagColor; + + const editEvents = edits?.get(event.event_id); + const editedEvent = editEvents && getLatestEditEvent(event.sender, editEvents, redacted); + + return ( + + + + } + /> + + + } + > + + + + + + {displayName} + + + {tagIconSrc && } + + + + + Open + + + + {redacted?.has(event.event_id) ? ( + + ) : ( + + {(contentEvent) => ( + + )} + + )} + + + ); + })} + + ); +} + +type RoomNotificationsGroupProps = { + roomId: string; + notifications: NotificationChunk[]; + getRoomName: GetRoomNameCallback; + getRoomAvatar: GetRoomAvatarCallback; + hideActivity: boolean; + children: ReactNode; +}; +function RoomNotificationsGroupComp({ + roomId, + notifications, + getRoomName, + getRoomAvatar, + hideActivity, + children, +}: RoomNotificationsGroupProps) { + const ct = useClient(); + const useAuthentication = useMediaAuthentication(); + const unread = useRoomUnread(roomId, unreadRoomsAtom); + + const users = useMemo( + () => Array.from(new Set(notifications.map((n) => n.event.sender))), + [notifications] + ); + + const avatarMxc = getRoomAvatar(roomId); + + const powerLevelStateResult = usePowerLevelsState(roomId); + const powerLevels = usePowerLevels(powerLevelStateResult.value); + + const powerLevelTagsStateResult = usePowerLevelTagsState(roomId); + const powerLevelTags = usePowerLevelTags(powerLevels, powerLevelTagsStateResult.value); + + const membersAPI = useRoomMembers(roomId, users); + const handleMarkAsRead = () => { - markAsRead(mx, room.roomId, hideActivity); + // markAsRead(mx, room.roomId, hideActivity); }; return ( @@ -410,21 +461,29 @@ function RoomNotificationsGroupComp({ ( )} /> - {room.name} + {getRoomName(roomId) ?? 'Unknown'} @@ -440,108 +499,14 @@ function RoomNotificationsGroupComp({ )} - - {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; - - const memberPowerTag = getMemberPowerTag(event.sender); - const tagColor = memberPowerTag?.color - ? accessibleTagColors?.get(memberPowerTag.color) - : undefined; - const tagIconSrc = memberPowerTag?.icon - ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon) - : undefined; - - const usernameColor = legacyUsernameColor ? colorMXID(event.sender) : tagColor; - - return ( - - - - } - /> - - - } - > - - - - - - {displayName} - - - {tagIconSrc && } - - - - - Open - - - - {replyEventId && ( - - )} - {renderMatrixEvent(event.type, false, event, displayName, getContent)} - - - ); - })} - + + + + {children} + + + ); } @@ -559,13 +524,16 @@ const useNotificationsSearchParams = ( const DEFAULT_REFRESH_MS = 7000; export function Notifications() { - const mx = useMatrixClient(); - const [hideActivity] = useSetting('hideActivity'); - const [mediaAutoLoad] = useSetting('mediaAutoLoad'); - const [urlPreview] = useSetting('urlPreview'); - const [legacyUsernameColor] = useSetting('legacyUsernameColor'); - const [hour24Clock] = useSetting('hour24Clock'); - const [dateFormatString] = useSetting('dateFormatString'); + const ct = useClient(); + + const { + hideActivity, + mediaAutoLoad, + urlPreview, + legacyUsernameColor, + hour24Clock, + dateFormatString, + } = useSettings(); const screenSize = useScreenSizeContext(); const mDirects = useAtomValue(mDirectAtom); @@ -579,11 +547,7 @@ export function Notifications() { const onlyHighlight = notificationsSearchParams.only === 'highlight'; const setOnlyHighlighted = (highlight: boolean) => { if (highlight) { - setSearchParams( - new URLSearchParams({ - only: 'highlight', - }) - ); + setSearchParams(new URLSearchParams({ only: 'highlight' })); return; } setSearchParams(); @@ -631,6 +595,16 @@ export function Notifications() { } }, [timelineState, notificationTimeline, lastVItemIndex, loadTimeline]); + const notificationRooms = useMemo( + () => Array.from(new Set(notificationTimeline.groups.map((g) => g.roomId))), + [notificationTimeline] + ); + + const roomToTimeline = useObservableValue(ct.roomToTimeline); + const [getRoomHeroes, roomToHeroMembersResult] = useRoomHeroes(notificationRooms); + const getRoomName = useGetRoomName(getRoomHeroes, roomToHeroMembersResult.value); + const getRoomAvatar = useGetRoomAvatar(getRoomHeroes, roomToHeroMembersResult.value); + return ( @@ -710,8 +684,6 @@ export function Notifications() { {vItems.map((vItem) => { const group = notificationTimeline.groups[vItem.index]; if (!group) return null; - const groupRoom = mx.getRoom(group.roomId); - if (!groupRoom) return null; return ( + > + + ); })} @@ -777,8 +756,11 @@ export function Notifications() { direction="Column" gap="200" > - {(timelineState.error as Error).name} - {(timelineState.error as Error).message} + + {timelineState.error instanceof Error + ? timelineState.error.message + : 'Unknown Error'} + )} diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 3a2ef6e1..158905dc 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -463,11 +463,11 @@ export const getReactionGroups = ( export const getLatestEditEvent = ( sender: string, events: MessageEventWithoutRoomId[], - redacted: Set + redacted: Set | undefined ): MessageEventWithoutRoomId | undefined => { let event: MessageEventWithoutRoomId | undefined; events.forEach((e) => { - if (redacted.has(e.event_id) || sender !== e.sender) return; + if (redacted?.has(e.event_id) || sender !== e.sender) return; if (!event) { event = e; return;