/* * Copyright 2024 New Vector Ltd. * Copyright 2024 The Matrix.org Foundation C.I.C. * * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only * Please see LICENSE files in the repository root for full details. */ import { useCallback, useEffect, useMemo, useState } from "react"; import { Room, RoomEvent, RoomStateEvent, MatrixEvent, EventType, RelationType, EventTimeline, MatrixClient, } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { useTypedEventEmitter } from "./useEventEmitter"; import { ReadPinsEventId } from "../components/views/right_panel/types"; import { useMatrixClientContext } from "../contexts/MatrixClientContext"; import { useAsyncMemo } from "./useAsyncMemo"; import PinningUtils from "../utils/PinningUtils"; import { batch } from "../utils/promise.ts"; /** * Get the pinned event IDs from a room. * The number of pinned events is limited to 100. * @param room */ function getPinnedEventIds(room?: Room): string[] { const eventIds: string[] = room ?.getLiveTimeline() .getState(EventTimeline.FORWARDS) ?.getStateEvents(EventType.RoomPinnedEvents, "") ?.getContent()?.pinned ?? []; // Limit the number of pinned events to 100 return eventIds.slice(0, 100); } /** * Get the pinned event IDs from a room. * @param room */ export const usePinnedEvents = (room?: Room): string[] => { const [pinnedEvents, setPinnedEvents] = useState(getPinnedEventIds(room)); // Update the pinned events when the room state changes // Filter out events that are not pinned events const update = useCallback( (ev?: MatrixEvent) => { if (ev && ev.getType() !== EventType.RoomPinnedEvents) return; setPinnedEvents(getPinnedEventIds(room)); }, [room], ); useTypedEventEmitter(room?.getLiveTimeline().getState(EventTimeline.FORWARDS), RoomStateEvent.Events, update); useEffect(() => { setPinnedEvents(getPinnedEventIds(room)); return () => { setPinnedEvents([]); }; }, [room]); return pinnedEvents; }; /** * Get the read pinned event IDs from a room. * @param room */ function getReadPinnedEventIds(room?: Room): Set { return new Set(room?.getAccountData(ReadPinsEventId)?.getContent()?.event_ids ?? []); } /** * Get the read pinned event IDs from a room. * @param room */ export const useReadPinnedEvents = (room?: Room): Set => { const [readPinnedEvents, setReadPinnedEvents] = useState>(new Set()); // Update the read pinned events when the room state changes // Filter out events that are not read pinned events const update = useCallback( (ev?: MatrixEvent) => { if (ev && ev.getType() !== ReadPinsEventId) return; setReadPinnedEvents(getReadPinnedEventIds(room)); }, [room], ); useTypedEventEmitter(room, RoomEvent.AccountData, update); useEffect(() => { setReadPinnedEvents(getReadPinnedEventIds(room)); return () => { setReadPinnedEvents(new Set()); }; }, [room]); return readPinnedEvents; }; /** * Fetch the pinned event * @param room * @param pinnedEventId * @param cli */ async function fetchPinnedEvent(room: Room, pinnedEventId: string, cli: MatrixClient): Promise { const timelineSet = room.getUnfilteredTimelineSet(); // Get the event from the local timeline const localEvent = timelineSet ?.getTimelineForEvent(pinnedEventId) ?.getEvents() .find((e) => e.getId() === pinnedEventId); // Decrypt the event if it's encrypted // Can happen when the tab is refreshed and the pinned events card is opened directly if (localEvent?.isEncrypted()) { await cli.decryptEventIfNeeded(localEvent, { emit: false }); } // If the event is available locally, return it if it's pinnable // or if it's redacted (to show the redacted event and to be able to unpin it) // Otherwise, return null if (localEvent) return PinningUtils.isUnpinnable(localEvent) ? localEvent : null; try { // The event is not available locally, so we fetch the event and latest edit in parallel const [ evJson, { events: [edit], }, ] = await Promise.all([ cli.fetchRoomEvent(room.roomId, pinnedEventId), cli.relations(room.roomId, pinnedEventId, RelationType.Replace, null, { limit: 1 }), ]); const event = new MatrixEvent(evJson); // Decrypt the event if it's encrypted if (event.isEncrypted()) { await cli.decryptEventIfNeeded(event, { emit: false }); } // Handle poll events await room.processPollEvents([event]); const senderUserId = event.getSender(); if (senderUserId && PinningUtils.isUnpinnable(event)) { // Inject sender information event.sender = room.getMember(senderUserId); // Also inject any edits we've found if (edit) event.makeReplaced(edit); return event; } } catch (err) { logger.error(`Error looking up pinned event ${pinnedEventId} in room ${room.roomId}`); logger.error(err); } return null; } /** * Fetch the pinned events * @param room * @param pinnedEventIds */ export function useFetchedPinnedEvents(room: Room, pinnedEventIds: string[]): Array | null { const cli = useMatrixClientContext(); return useAsyncMemo( () => { const fetchPromises = pinnedEventIds.map((eventId) => () => fetchPinnedEvent(room, eventId, cli)); // Fetch the pinned events in batches of 10 return batch(fetchPromises, 10); }, [cli, room, pinnedEventIds], null, ); } /** * Fetch the pinned events and sort them by from the oldest to the newest * The order is determined by the event timestamp * @param room * @param pinnedEventIds */ export function useSortedFetchedPinnedEvents(room: Room, pinnedEventIds: string[]): Array { const pinnedEvents = useFetchedPinnedEvents(room, pinnedEventIds); return useMemo(() => { if (!pinnedEvents) return []; return pinnedEvents.sort((a, b) => { if (!a) return -1; if (!b) return 1; return a.getTs() - b.getTs(); }); }, [pinnedEvents]); }