Refactor timeline (#1346)

* fix intersection & resize observer

* add binary search util

* add scroll info util

* add virtual paginator hook - WIP

* render timeline using paginator hook

* add continuous pagination to fill timeline

* add doc comments in virtual paginator hook

* add scroll to element func in virtual paginator

* extract timeline pagination login into hook

* add sliding name for timeline messages - testing

* scroll with live event

* change message rending style

* make message timestamp smaller

* remove unused imports

* add random number between util

* add compact message component

* add sanitize html types

* fix sending alias in room mention

* get room member display name util

* add get room with canonical alias util

* add sanitize html util

* render custom html with new styles

* fix linkifying link text

* add reaction component

* display message reactions in timeline

* Change mention color

* show edited message

* add event sent by function factory

* add functions to get emoji shortcode

* add component for reaction msg

* add tooltip for who has reacted

* add message layouts & placeholder

* fix reaction size

* fix dark theme colors

* add code highlight with prismjs

* add options to configure spacing in msgs

* render message reply

* fix trim reply from body regex

* fix crash when loading reply

* fix reply hover style

* decrypt event on timeline paginate

* update custom html code style

* remove console logs

* fix virtual paginator scroll to func

* fix virtual paginator scroll to types

* add stop scroll for in view item options

* fix virtual paginator out of range scroll to index

* scroll to and highlight reply on click

* fix reply hover style

* make message avatar clickable

* fix scrollTo issue in virtual paginator

* load reply from fetch

* import virtual paginator restore scroll

* load timeline for specific event

* Fix back pagination recalibration

* fix reply min height

* revert code block colors to secondary

* stop sanitizing text in code block

* add decrypt file util

* add image media component

* update folds

* fix code block font style

* add msg event type

* add scale dimension util

* strict msg layout type

* add image renderer component

* add message content fallback components

* add message matrix event renderer components

* render matrix event using hooks

* add attachment component

* add attachment content types

* handle error when rendering image in timeline

* add video component

* render video

* include blurhash in thumbnails

* generate thumbnails for image message

* fix reactToDom spoiler opts

* add hooks for HTMLMediaElement

* render audio file in timeline

* add msg image content component

* fix image content props

* add video content component

* render new image/video component in timeline

* remove console.log

* convert seconds to milliseconds in video info

* add load thumbnail prop to video content component

* add file saver types

* add file header component

* add file content component

* render file in timeline

* add media control component

* render audio message in room timeline

* remove moved components

* safely load message reply

* add media loading hook

* update media control layout

* add loading indication in audio component

* fill audio play icon when playing audio

* fix media expanding

* add image viewer - WIP

* add pan and zoom control to image viewer

* add text based file viewer

* add pdf viewer

* add error handling in pdf viewer

* add download btn to pdf viewer

* fix file button spinner fill

* fix file opens on re-render

* add range slider in audio content player

* render location in timeline

* update folds

* display membership event in timeline

* make reactions toggle

* render sticker messages in timeline

* render room name, topic, avatar change and event

* fix typos

* update render state event type style

* add  room intro in start of timeline

* add power levels context

* fix wrong param passing in RoomView

* fix sending typing notification in wrong room

Slate onChange callback was not updating with react re-renders.

* send typing status on key up

* add typing indicator component

* add typing member atom

* display typing status in member drawer

* add room view typing member component

* display typing members in room view

* remove old roomTimeline uses

* add event readers hook

* add latest event hook

* display following members in room view

* fetch event instead of event context for reply

* fix typo in virtual paginator hook

* add scroll to latest btn in timeline

* change scroll to latest chip variant

* destructure paginator object to improve perf

* restore forward dir scroll in virtual paginator

* run scroll to bottom in layout effect

* display unread message indicator in timeline

* make component for room timeline float

* add timeline divider component

* add day divider and format message time

* apply message spacing to dividers

* format date in room intro

* send read receipt on message arrive

* add event readers component

* add reply, read receipt, source delete opt

* bug fixes

* update timeline on delete & show reason

* fix empty reaction container style

* show msg selection effect on msg option open

* add report message options

* add options to send quick reactions

* add emoji board in message options

* add reaction viewer

* fix styles

* show view reaction in msg options menu

* fix spacing between two msg by same person

* add option menu in other rendered event

* handle m.room.encrypted messages

* fix italic reply text overflow cut

* handle encrypted sticker messages

* remove console log

* prevent message context menu with alt key pressed

* make mentions clickable in messages

* add options to show and hidden events in timeline

* add option to disable media autoload

* remove old emojiboard opener

* add options to use system emoji

* refresh timeline on reset

* fix stuck typing member in member drawer
This commit is contained in:
Ajay Bura 2023-10-06 13:44:06 +11:00 committed by GitHub
parent fcd7723f73
commit 3a95d0da01
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
124 changed files with 9438 additions and 258 deletions

View file

@ -0,0 +1,6 @@
export * from './useMediaPlay';
export * from './useMediaPlayTimeCallback';
export * from './useMediaPlaybackRate';
export * from './useMediaSeek';
export * from './useMediaVolume';
export * from './useMediaLoading';

View file

@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
export type MediaLoadingData = {
loading: boolean;
error: boolean;
};
export const useMediaLoading = (
getTargetElement: () => HTMLMediaElement | null
): MediaLoadingData => {
const [loadingData, setLoadingData] = useState<MediaLoadingData>({
loading: false,
error: false,
});
useEffect(() => {
const targetEl = getTargetElement();
const handleStart = () => {
setLoadingData({
loading: true,
error: false,
});
};
const handleStop = () => {
setLoadingData({
loading: false,
error: false,
});
};
const handleError = () => {
setLoadingData({
loading: false,
error: true,
});
};
targetEl?.addEventListener('loadstart', handleStart);
targetEl?.addEventListener('loadeddata', handleStop);
targetEl?.addEventListener('stalled', handleStop);
targetEl?.addEventListener('suspend', handleStop);
targetEl?.addEventListener('error', handleError);
return () => {
targetEl?.removeEventListener('loadstart', handleStart);
targetEl?.removeEventListener('loadeddata', handleStop);
targetEl?.removeEventListener('stalled', handleStop);
targetEl?.removeEventListener('suspend', handleStop);
targetEl?.removeEventListener('error', handleError);
};
}, [getTargetElement]);
return loadingData;
};

View file

@ -0,0 +1,46 @@
import { useCallback, useEffect, useState } from 'react';
export type MediaPlayData = {
playing: boolean;
};
export type MediaPlayControl = {
setPlaying: (play: boolean) => void;
};
export const useMediaPlay = (
getTargetElement: () => HTMLMediaElement | null
): MediaPlayData & MediaPlayControl => {
const [playing, setPlay] = useState(false);
const setPlaying = useCallback(
(play: boolean) => {
const targetEl = getTargetElement();
if (!targetEl) return;
if (play) targetEl.play();
else targetEl.pause();
},
[getTargetElement]
);
useEffect(() => {
const targetEl = getTargetElement();
const handleChange = () => {
if (!targetEl) return;
setPlay(targetEl.paused === false);
};
targetEl?.addEventListener('playing', handleChange);
targetEl?.addEventListener('play', handleChange);
targetEl?.addEventListener('pause', handleChange);
return () => {
targetEl?.removeEventListener('playing', handleChange);
targetEl?.removeEventListener('play', handleChange);
targetEl?.removeEventListener('pause', handleChange);
};
}, [getTargetElement]);
return {
playing,
setPlaying,
};
};

View file

@ -0,0 +1,24 @@
import { useEffect } from 'react';
export type PlayTimeCallback = (duration: number, currentTime: number) => void;
export const useMediaPlayTimeCallback = (
getTargetElement: () => HTMLMediaElement | null,
onPlayTimeCallback: PlayTimeCallback
): void => {
useEffect(() => {
const targetEl = getTargetElement();
const handleChange = () => {
if (!targetEl) return;
onPlayTimeCallback(targetEl.duration, targetEl.currentTime);
};
targetEl?.addEventListener('timeupdate', handleChange);
targetEl?.addEventListener('loadedmetadata', handleChange);
targetEl?.addEventListener('ended', handleChange);
return () => {
targetEl?.removeEventListener('timeupdate', handleChange);
targetEl?.removeEventListener('loadedmetadata', handleChange);
targetEl?.removeEventListener('ended', handleChange);
};
}, [getTargetElement, onPlayTimeCallback]);
};

View file

@ -0,0 +1,40 @@
import { useCallback, useEffect, useState } from 'react';
export type MediaPlaybackRateData = {
playbackRate: number;
};
export type MediaPlaybackRateControl = {
setPlaybackRate: (rate: number) => void;
};
export const useMediaPlaybackRate = (
getTargetElement: () => HTMLMediaElement | null
): MediaPlaybackRateData & MediaPlaybackRateControl => {
const [rate, setRate] = useState(1.0);
const setPlaybackRate = useCallback(
(playbackRate: number) => {
const targetEl = getTargetElement();
if (!targetEl) return;
targetEl.playbackRate = playbackRate;
},
[getTargetElement]
);
useEffect(() => {
const targetEl = getTargetElement();
const handleChange = () => {
if (!targetEl) return;
setRate(targetEl.playbackRate);
};
targetEl?.addEventListener('ratechange', handleChange);
return () => {
targetEl?.removeEventListener('ratechange', handleChange);
};
}, [getTargetElement]);
return {
playbackRate: rate,
setPlaybackRate,
};
};

View file

@ -0,0 +1,51 @@
import { useCallback, useEffect, useState } from 'react';
export type MediaSeekData = {
seeking: boolean;
seekable?: TimeRanges;
};
export type MediaSeekControl = {
seek: (time: number) => void;
};
export const useMediaSeek = (
getTargetElement: () => HTMLMediaElement | null
): MediaSeekData & MediaSeekControl => {
const [seekData, setSeekData] = useState<MediaSeekData>({
seeking: false,
seekable: undefined,
});
const seek = useCallback(
(time: number) => {
const targetEl = getTargetElement();
if (!targetEl) return;
targetEl.currentTime = time;
},
[getTargetElement]
);
useEffect(() => {
const targetEl = getTargetElement();
const handleChange = () => {
if (!targetEl) return;
setSeekData({
seeking: targetEl.seeking,
seekable: targetEl.seekable,
});
};
targetEl?.addEventListener('loadedmetadata', handleChange);
targetEl?.addEventListener('seeked', handleChange);
targetEl?.addEventListener('seeking', handleChange);
return () => {
targetEl?.removeEventListener('loadedmetadata', handleChange);
targetEl?.removeEventListener('seeked', handleChange);
targetEl?.removeEventListener('seeking', handleChange);
};
}, [getTargetElement]);
return {
...seekData,
seek,
};
};

View file

@ -0,0 +1,60 @@
import { useCallback, useEffect, useState } from 'react';
export type MediaVolumeData = {
volume: number;
mute: boolean;
};
export type MediaVolumeControl = {
setMute: (mute: boolean) => void;
setVolume: (volume: number) => void;
};
export const useMediaVolume = (
getTargetElement: () => HTMLMediaElement | null
): MediaVolumeData & MediaVolumeControl => {
const [volumeData, setVolumeData] = useState<MediaVolumeData>({
volume: 1,
mute: false,
});
const setMute = useCallback(
(mute: boolean) => {
const targetEl = getTargetElement();
if (!targetEl) return;
targetEl.muted = mute;
},
[getTargetElement]
);
const setVolume = useCallback(
(volume: number) => {
const targetEl = getTargetElement();
if (!targetEl) return;
targetEl.volume = volume;
},
[getTargetElement]
);
useEffect(() => {
const targetEl = getTargetElement();
const handleChange = () => {
if (!targetEl) return;
setVolumeData({
mute: targetEl.muted,
volume: Math.max(0, Math.min(targetEl.volume, 1)),
});
};
targetEl?.addEventListener('volumechange', handleChange);
return () => {
targetEl?.removeEventListener('volumechange', handleChange);
};
}, [getTargetElement]);
return {
...volumeData,
setMute,
setVolume,
};
};

View file

@ -25,6 +25,8 @@ export const useIntersectionObserver = (
setIntersectionObserver(new IntersectionObserver(onIntersectionCallback, initOpts));
}, [onIntersectionCallback, opts]);
useEffect(() => () => intersectionObserver?.disconnect(), [intersectionObserver]);
useEffect(() => {
const element = typeof observeElement === 'function' ? observeElement() : observeElement;
if (element) intersectionObserver?.observe(element);

View file

@ -0,0 +1,80 @@
import { ReactNode } from 'react';
import { MatrixEvent } from 'matrix-js-sdk';
import { MessageEvent, StateEvent } from '../../types/matrix/room';
export type EventRenderer<T extends unknown[]> = (
eventId: string,
mEvent: MatrixEvent,
...args: T
) => ReactNode;
export type EventRendererOpts<T extends unknown[]> = {
renderRoomMessage?: EventRenderer<T>;
renderRoomEncrypted?: EventRenderer<T>;
renderSticker?: EventRenderer<T>;
renderRoomMember?: EventRenderer<T>;
renderRoomName?: EventRenderer<T>;
renderRoomTopic?: EventRenderer<T>;
renderRoomAvatar?: EventRenderer<T>;
renderStateEvent?: EventRenderer<T>;
renderEvent?: EventRenderer<T>;
};
export type RenderMatrixEvent<T extends unknown[]> = (
eventId: string,
mEvent: MatrixEvent,
...args: T
) => ReactNode;
export const useMatrixEventRenderer =
<T extends unknown[]>({
renderRoomMessage,
renderRoomEncrypted,
renderSticker,
renderRoomMember,
renderRoomName,
renderRoomTopic,
renderRoomAvatar,
renderStateEvent,
renderEvent,
}: EventRendererOpts<T>): RenderMatrixEvent<T> =>
(eventId, mEvent, ...args) => {
const eventType = mEvent.getWireType();
if (eventType === MessageEvent.RoomMessage && renderRoomMessage) {
return renderRoomMessage(eventId, mEvent, ...args);
}
if (eventType === MessageEvent.RoomMessageEncrypted && renderRoomEncrypted) {
return renderRoomEncrypted(eventId, mEvent, ...args);
}
if (eventType === MessageEvent.Sticker && renderSticker) {
return renderSticker(eventId, mEvent, ...args);
}
if (eventType === StateEvent.RoomMember && renderRoomMember) {
return renderRoomMember(eventId, mEvent, ...args);
}
if (eventType === StateEvent.RoomName && renderRoomName) {
return renderRoomName(eventId, mEvent, ...args);
}
if (eventType === StateEvent.RoomTopic && renderRoomTopic) {
return renderRoomTopic(eventId, mEvent, ...args);
}
if (eventType === StateEvent.RoomAvatar && renderRoomAvatar) {
return renderRoomAvatar(eventId, mEvent, ...args);
}
if (typeof mEvent.getStateKey() === 'string' && renderStateEvent) {
return renderStateEvent(eventId, mEvent, ...args);
}
if (typeof mEvent.getStateKey() !== 'string' && renderEvent) {
return renderEvent(eventId, mEvent, ...args);
}
return null;
};

View file

@ -0,0 +1,218 @@
import React, { ReactNode } from 'react';
import { IconSrc, Icons } from 'folds';
import { MatrixEvent } from 'matrix-js-sdk';
import { IMemberContent, Membership } from '../../types/matrix/room';
import { getMxIdLocalPart } from '../utils/matrix';
export type ParsedResult = {
icon: IconSrc;
body: ReactNode;
};
export type MemberEventParser = (mEvent: MatrixEvent) => ParsedResult;
export const useMemberEventParser = (): MemberEventParser => {
const parseMemberEvent: MemberEventParser = (mEvent) => {
const content = mEvent.getContent<IMemberContent>();
const prevContent = mEvent.getPrevContent() as IMemberContent;
const senderId = mEvent.getSender();
const userId = mEvent.getStateKey();
if (!senderId || !userId)
return {
icon: Icons.User,
body: 'Broken membership event',
};
const senderName = getMxIdLocalPart(senderId);
const userName = content.displayname || getMxIdLocalPart(userId);
if (content.membership !== prevContent.membership) {
if (content.membership === Membership.Invite) {
if (prevContent.membership === Membership.Knock) {
return {
icon: Icons.ArrowGoRightPlus,
body: (
<>
<b>{senderName}</b>
{' accepted '}
<b>{userName}</b>
{`'s join request `}
{content.reason}
</>
),
};
}
return {
icon: Icons.ArrowGoRightPlus,
body: (
<>
<b>{senderName}</b>
{' invited '}
<b>{userName}</b> {content.reason}
</>
),
};
}
if (content.membership === Membership.Knock) {
return {
icon: Icons.ArrowGoRightPlus,
body: (
<>
<b>{userName}</b>
{' request to join room '}
{content.reason}
</>
),
};
}
if (content.membership === Membership.Join) {
return {
icon: Icons.ArrowGoRight,
body: (
<>
<b>{userName}</b>
{' joined the room'}
</>
),
};
}
if (content.membership === Membership.Leave) {
if (prevContent.membership === Membership.Invite) {
return {
icon: Icons.ArrowGoRightCross,
body:
senderId === userId ? (
<>
<b>{userName}</b>
{' reject the invitation '}
{content.reason}
</>
) : (
<>
<b>{senderName}</b>
{' reject '}
<b>{userName}</b>
{`'s join request `}
{content.reason}
</>
),
};
}
if (prevContent.membership === Membership.Knock) {
return {
icon: Icons.ArrowGoRightCross,
body:
senderId === userId ? (
<>
<b>{userName}</b>
{' revoked joined request '}
{content.reason}
</>
) : (
<>
<b>{senderName}</b>
{' revoked '}
<b>{userName}</b>
{`'s invite `}
{content.reason}
</>
),
};
}
if (prevContent.membership === Membership.Ban) {
return {
icon: Icons.ArrowGoLeft,
body: (
<>
<b>{senderName}</b>
{' unbanned '}
<b>{userName}</b> {content.reason}
</>
),
};
}
return {
icon: Icons.ArrowGoLeft,
body:
senderId === userId ? (
<>
<b>{userName}</b>
{' left the room '}
{content.reason}
</>
) : (
<>
<b>{senderName}</b>
{' kicked '}
<b>{userName}</b> {content.reason}
</>
),
};
}
if (content.membership === Membership.Ban) {
return {
icon: Icons.ArrowGoLeft,
body: (
<>
<b>{senderName}</b>
{' banned '}
<b>{userName}</b> {content.reason}
</>
),
};
}
}
if (content.displayname !== prevContent.displayname) {
const prevUserName = prevContent.displayname || userId;
return {
icon: Icons.Mention,
body: content.displayname ? (
<>
<b>{prevUserName}</b>
{' changed display name to '}
<b>{userName}</b>
</>
) : (
<>
<b>{prevUserName}</b>
{' removed their display name '}
</>
),
};
}
if (content.avatar_url !== prevContent.avatar_url) {
return {
icon: Icons.User,
body: content.displayname ? (
<>
<b>{userName}</b>
{' changed their avatar'}
</>
) : (
<>
<b>{userName}</b>
{' removed their avatar '}
</>
),
};
}
return {
icon: Icons.User,
body: 'Broken membership event',
};
};
return parseMemberEvent;
};

62
src/app/hooks/usePan.ts Normal file
View file

@ -0,0 +1,62 @@
import { MouseEventHandler, useEffect, useState } from 'react';
export type Pan = {
translateX: number;
translateY: number;
};
const INITIAL_PAN = {
translateX: 0,
translateY: 0,
};
export const usePan = (active: boolean) => {
const [pan, setPan] = useState<Pan>(INITIAL_PAN);
const [cursor, setCursor] = useState<'grab' | 'grabbing' | 'initial'>(
active ? 'grab' : 'initial'
);
useEffect(() => {
setCursor(active ? 'grab' : 'initial');
}, [active]);
const handleMouseMove = (evt: MouseEvent) => {
evt.preventDefault();
evt.stopPropagation();
setPan((p) => {
const { translateX, translateY } = p;
const mX = translateX + evt.movementX;
const mY = translateY + evt.movementY;
return { translateX: mX, translateY: mY };
});
};
const handleMouseUp = (evt: MouseEvent) => {
evt.preventDefault();
setCursor('grab');
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
const handleMouseDown: MouseEventHandler<HTMLElement> = (evt) => {
if (!active) return;
evt.preventDefault();
setCursor('grabbing');
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
useEffect(() => {
if (!active) setPan(INITIAL_PAN);
}, [active]);
return {
pan,
cursor,
onMouseDown: handleMouseDown,
};
};

View file

@ -1,8 +1,10 @@
import { Room } from 'matrix-js-sdk';
import { useCallback } from 'react';
import { createContext, useCallback, useContext } from 'react';
import { useStateEvent } from './useStateEvent';
import { StateEvent } from '../../types/matrix/room';
export type PowerLevelActions = 'invite' | 'redact' | 'kick' | 'ban' | 'historical';
enum DefaultPowerLevels {
usersDefault = 0,
stateDefault = 50,
@ -29,12 +31,23 @@ interface IPowerLevels {
notifications?: Record<string, number>;
}
export function usePowerLevels(room: Room) {
export type GetPowerLevel = (userId: string) => number;
export type CanSend = (eventType: string | undefined, powerLevel: number) => boolean;
export type CanDoAction = (action: PowerLevelActions, powerLevel: number) => boolean;
export type PowerLevelsAPI = {
getPowerLevel: GetPowerLevel;
canSendEvent: CanSend;
canSendStateEvent: CanSend;
canDoAction: CanDoAction;
};
export function usePowerLevels(room: Room): PowerLevelsAPI {
const powerLevelsEvent = useStateEvent(room, StateEvent.RoomPowerLevels);
const powerLevels: IPowerLevels = powerLevelsEvent?.getContent() ?? DefaultPowerLevels;
const getPowerLevel = useCallback(
(userId: string) => {
const getPowerLevel: GetPowerLevel = useCallback(
(userId) => {
const { users_default: usersDefault, users } = powerLevels;
if (users && typeof users[userId] === 'number') {
return users[userId];
@ -44,8 +57,8 @@ export function usePowerLevels(room: Room) {
[powerLevels]
);
const canSendEvent = useCallback(
(eventType: string | undefined, powerLevel: number) => {
const canSendEvent: CanSend = useCallback(
(eventType, powerLevel) => {
const { events, events_default: eventsDefault } = powerLevels;
if (events && eventType && typeof events[eventType] === 'number') {
return powerLevel >= events[eventType];
@ -55,8 +68,8 @@ export function usePowerLevels(room: Room) {
[powerLevels]
);
const canSendStateEvent = useCallback(
(eventType: string | undefined, powerLevel: number) => {
const canSendStateEvent: CanSend = useCallback(
(eventType, powerLevel) => {
const { events, state_default: stateDefault } = powerLevels;
if (events && eventType && typeof events[eventType] === 'number') {
return powerLevel >= events[eventType];
@ -66,8 +79,8 @@ export function usePowerLevels(room: Room) {
[powerLevels]
);
const canDoAction = useCallback(
(action: 'invite' | 'redact' | 'kick' | 'ban' | 'historical', powerLevel: number) => {
const canDoAction: CanDoAction = useCallback(
(action, powerLevel) => {
const requiredPL = powerLevels[action];
if (typeof requiredPL === 'number') {
return powerLevel >= requiredPL;
@ -84,3 +97,13 @@ export function usePowerLevels(room: Room) {
canDoAction,
};
}
export const PowerLevelsContext = createContext<PowerLevelsAPI | null>(null);
export const PowerLevelsContextProvider = PowerLevelsContext.Provider;
export const usePowerLevelsAPI = (): PowerLevelsAPI => {
const api = useContext(PowerLevelsContext);
if (!api) throw new Error('PowerLevelContext is not initialized!');
return api;
};

View file

@ -0,0 +1,25 @@
import { useEffect, useState } from 'react';
import { RelationsEvent, type Relations } from 'matrix-js-sdk/lib/models/relations';
export const useRelations = <T>(
relations: Relations,
getRelations: (relations: Relations) => T
) => {
const [data, setData] = useState(() => getRelations(relations));
useEffect(() => {
const handleUpdate = () => {
setData(getRelations(relations));
};
relations.on(RelationsEvent.Add, handleUpdate);
relations.on(RelationsEvent.Redaction, handleUpdate);
relations.on(RelationsEvent.Remove, handleUpdate);
return () => {
relations.removeListener(RelationsEvent.Add, handleUpdate);
relations.removeListener(RelationsEvent.Redaction, handleUpdate);
relations.removeListener(RelationsEvent.Remove, handleUpdate);
};
}, [relations, getRelations]);
return data;
};

View file

@ -13,6 +13,8 @@ export const useResizeObserver = (
): ResizeObserver => {
const resizeObserver = useMemo(() => new ResizeObserver(onResizeCallback), [onResizeCallback]);
useEffect(() => () => resizeObserver?.disconnect(), [resizeObserver]);
useEffect(() => {
const element = typeof observeElement === 'function' ? observeElement() : observeElement;
if (element) resizeObserver.observe(element);

View file

@ -0,0 +1,35 @@
import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { useEffect, useState } from 'react';
const getEventReaders = (room: Room, evtId?: string) => {
if (!evtId) return [];
const liveEvents = room.getLiveTimeline().getEvents();
const userIds: string[] = [];
for (let i = liveEvents.length - 1; i >= 0; i -= 1) {
userIds.splice(userIds.length, 0, ...room.getUsersReadUpTo(liveEvents[i]));
if (liveEvents[i].getId() === evtId) break;
}
return [...new Set(userIds)];
};
export const useRoomEventReaders = (room: Room, eventId?: string): string[] => {
const [readers, setReaders] = useState<string[]>(() => getEventReaders(room, eventId));
useEffect(() => {
setReaders(getEventReaders(room, eventId));
const handleReceipt: RoomEventHandlerMap[RoomEvent.Receipt] = (event, r) => {
if (r.roomId !== room.roomId) return;
setReaders(getEventReaders(room, eventId));
};
room.on(RoomEvent.Receipt, handleReceipt);
return () => {
room.removeListener(RoomEvent.Receipt, handleReceipt);
};
}, [room, eventId]);
return readers;
};

View file

@ -0,0 +1,29 @@
import { MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { useEffect, useState } from 'react';
export const useRoomLatestEvent = (room: Room) => {
const [latestEvent, setLatestEvent] = useState<MatrixEvent>();
useEffect(() => {
const getLatestEvent = (): MatrixEvent | undefined => {
const liveEvents = room.getLiveTimeline().getEvents();
for (let i = liveEvents.length - 1; i >= 0; i -= 1) {
const evt = liveEvents[i];
if (evt) return evt;
}
return undefined;
};
const handleTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = () => {
setLatestEvent(getLatestEvent());
};
setLatestEvent(getLatestEvent());
room.on(RoomEvent.Timeline, handleTimelineEvent);
return () => {
room.removeListener(RoomEvent.Timeline, handleTimelineEvent);
};
}, [room]);
return latestEvent;
};

View file

@ -0,0 +1,68 @@
import { ReactNode } from 'react';
import { MatrixEvent, MsgType } from 'matrix-js-sdk';
export type MsgContentRenderer<T extends unknown[]> = (
eventId: string,
mEvent: MatrixEvent,
...args: T
) => ReactNode;
export type RoomMsgContentRendererOpts<T extends unknown[]> = {
renderText?: MsgContentRenderer<T>;
renderEmote?: MsgContentRenderer<T>;
renderNotice?: MsgContentRenderer<T>;
renderImage?: MsgContentRenderer<T>;
renderVideo?: MsgContentRenderer<T>;
renderAudio?: MsgContentRenderer<T>;
renderFile?: MsgContentRenderer<T>;
renderLocation?: MsgContentRenderer<T>;
renderBadEncrypted?: MsgContentRenderer<T>;
renderUnsupported?: MsgContentRenderer<T>;
renderBrokenFallback?: MsgContentRenderer<T>;
};
export type RenderRoomMsgContent<T extends unknown[]> = (
eventId: string,
mEvent: MatrixEvent,
...args: T
) => ReactNode;
export const useRoomMsgContentRenderer =
<T extends unknown[]>({
renderText,
renderEmote,
renderNotice,
renderImage,
renderVideo,
renderAudio,
renderFile,
renderLocation,
renderBadEncrypted,
renderUnsupported,
renderBrokenFallback,
}: RoomMsgContentRendererOpts<T>): RenderRoomMsgContent<T> =>
(eventId, mEvent, ...args) => {
const msgType = mEvent.getContent().msgtype;
let node: ReactNode = null;
if (msgType === MsgType.Text && renderText) node = renderText(eventId, mEvent, ...args);
else if (msgType === MsgType.Emote && renderEmote) node = renderEmote(eventId, mEvent, ...args);
else if (msgType === MsgType.Notice && renderNotice)
node = renderNotice(eventId, mEvent, ...args);
else if (msgType === MsgType.Image && renderImage) node = renderImage(eventId, mEvent, ...args);
else if (msgType === MsgType.Video && renderVideo) node = renderVideo(eventId, mEvent, ...args);
else if (msgType === MsgType.Audio && renderAudio) node = renderAudio(eventId, mEvent, ...args);
else if (msgType === MsgType.File && renderFile) node = renderFile(eventId, mEvent, ...args);
else if (msgType === MsgType.Location && renderLocation)
node = renderLocation(eventId, mEvent, ...args);
else if (msgType === 'm.bad.encrypted' && renderBadEncrypted)
node = renderBadEncrypted(eventId, mEvent, ...args);
else if (renderUnsupported) {
node = renderUnsupported(eventId, mEvent, ...args);
}
if (!node && renderBrokenFallback) node = renderBrokenFallback(eventId, mEvent, ...args);
return node;
};

View file

@ -0,0 +1,405 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import { OnIntersectionCallback, useIntersectionObserver } from './useIntersectionObserver';
import {
canFitInScrollView,
getScrollInfo,
isInScrollView,
isIntersectingScrollView,
} from '../utils/dom';
const PAGINATOR_ANCHOR_ATTR = 'data-paginator-anchor';
export enum Direction {
Backward = 'B',
Forward = 'F',
}
export type ItemRange = {
start: number;
end: number;
};
export type ScrollToOptions = {
offset?: number;
align?: 'start' | 'center' | 'end';
behavior?: 'auto' | 'instant' | 'smooth';
stopInView?: boolean;
};
export type ScrollToElement = (element: HTMLElement, opts?: ScrollToOptions) => void;
export type ScrollToItem = (index: number, opts?: ScrollToOptions) => void;
type HandleObserveAnchor = (element: HTMLElement | null) => void;
type VirtualPaginatorOptions<TScrollElement extends HTMLElement> = {
count: number;
limit: number;
range: ItemRange;
onRangeChange: (range: ItemRange) => void;
getScrollElement: () => TScrollElement | null;
getItemElement: (index: number) => HTMLElement | undefined;
onEnd?: (back: boolean) => void;
};
type VirtualPaginator = {
getItems: () => number[];
scrollToElement: ScrollToElement;
scrollToItem: ScrollToItem;
observeBackAnchor: HandleObserveAnchor;
observeFrontAnchor: HandleObserveAnchor;
};
const generateItems = (range: ItemRange) => {
const items: number[] = [];
for (let i = range.start; i < range.end; i += 1) {
items.push(i);
}
return items;
};
const getDropIndex = (
scrollEl: HTMLElement,
range: ItemRange,
dropDirection: Direction,
getItemElement: (index: number) => HTMLElement | undefined,
pageThreshold = 1
): number | undefined => {
const fromBackward = dropDirection === Direction.Backward;
const items = fromBackward ? generateItems(range) : generateItems(range).reverse();
const { viewHeight, top, height } = getScrollInfo(scrollEl);
const { offsetTop: sOffsetTop } = scrollEl;
const bottom = top + viewHeight;
const dropEdgePx = fromBackward
? Math.max(top - viewHeight * pageThreshold, 0)
: Math.min(bottom + viewHeight * pageThreshold, height);
if (dropEdgePx === 0 || dropEdgePx === height) return undefined;
let dropIndex: number | undefined;
items.find((item) => {
const el = getItemElement(item);
if (!el) {
dropIndex = item;
return false;
}
const { clientHeight } = el;
const offsetTop = el.offsetTop - sOffsetTop;
const offsetBottom = offsetTop + clientHeight;
const isInView = fromBackward ? offsetBottom > dropEdgePx : offsetTop < dropEdgePx;
if (isInView) return true;
dropIndex = item;
return false;
});
return dropIndex;
};
type RestoreAnchorData = [number | undefined, HTMLElement | undefined];
const getRestoreAnchor = (
range: ItemRange,
getItemElement: (index: number) => HTMLElement | undefined,
direction: Direction
): RestoreAnchorData => {
let scrollAnchorEl: HTMLElement | undefined;
const scrollAnchorItem = (
direction === Direction.Backward ? generateItems(range) : generateItems(range).reverse()
).find((i) => {
const el = getItemElement(i);
if (el) {
scrollAnchorEl = el;
return true;
}
return false;
});
return [scrollAnchorItem, scrollAnchorEl];
};
const getRestoreScrollData = (scrollTop: number, restoreAnchorData: RestoreAnchorData) => {
const [anchorItem, anchorElement] = restoreAnchorData;
if (!anchorItem || !anchorElement) {
return undefined;
}
return {
scrollTop,
anchorItem,
anchorOffsetTop: anchorElement.offsetTop,
};
};
const useObserveAnchorHandle = (
intersectionObserver: ReturnType<typeof useIntersectionObserver>,
anchorType: Direction
): HandleObserveAnchor =>
useMemo<HandleObserveAnchor>(() => {
let anchor: HTMLElement | null = null;
return (element) => {
if (element === anchor) return;
if (anchor) intersectionObserver?.unobserve(anchor);
if (!element) return;
anchor = element;
element.setAttribute(PAGINATOR_ANCHOR_ATTR, anchorType);
intersectionObserver?.observe(element);
};
}, [intersectionObserver, anchorType]);
export const useVirtualPaginator = <TScrollElement extends HTMLElement>(
options: VirtualPaginatorOptions<TScrollElement>
): VirtualPaginator => {
const { count, limit, range, onRangeChange, getScrollElement, getItemElement, onEnd } = options;
const initialRenderRef = useRef(true);
const restoreScrollRef = useRef<{
scrollTop: number;
anchorOffsetTop: number;
anchorItem: number;
}>();
const scrollToItemRef = useRef<{
index: number;
opts?: ScrollToOptions;
}>();
const propRef = useRef({
range,
limit,
count,
});
if (propRef.current.count !== count) {
// Clear restoreScrollRef on count change
// As restoreScrollRef.current.anchorItem might changes
restoreScrollRef.current = undefined;
}
propRef.current = {
range,
count,
limit,
};
const getItems = useMemo(() => {
const items = generateItems(range);
return () => items;
}, [range]);
const scrollToElement = useCallback<ScrollToElement>(
(element, opts) => {
const scrollElement = getScrollElement();
if (!scrollElement) return;
if (opts?.stopInView && isInScrollView(scrollElement, element)) {
return;
}
let scrollTo = element.offsetTop;
if (opts?.align === 'center' && canFitInScrollView(scrollElement, element)) {
const scrollInfo = getScrollInfo(scrollElement);
scrollTo =
element.offsetTop -
Math.round(scrollInfo.viewHeight / 2) +
Math.round(element.clientHeight / 2);
} else if (opts?.align === 'end' && canFitInScrollView(scrollElement, element)) {
const scrollInfo = getScrollInfo(scrollElement);
scrollTo = element.offsetTop - Math.round(scrollInfo.viewHeight) + element.clientHeight;
}
scrollElement.scrollTo({
top: scrollTo - (opts?.offset ?? 0),
behavior: opts?.behavior,
});
},
[getScrollElement]
);
const scrollToItem = useCallback<ScrollToItem>(
(index, opts) => {
const { range: currentRange, limit: currentLimit, count: currentCount } = propRef.current;
if (index < 0 || index >= currentCount) return;
// index is not in range change range
// and trigger scrollToItem in layoutEffect hook
if (index < currentRange.start || index >= currentRange.end) {
onRangeChange({
start: Math.max(index - currentLimit, 0),
end: Math.min(index + currentLimit, currentCount),
});
scrollToItemRef.current = {
index,
opts,
};
return;
}
// find target or it's previous rendered element to scroll to
const targetItems = generateItems({ start: currentRange.start, end: index + 1 });
const targetItem = targetItems.reverse().find((i) => getItemElement(i) !== undefined);
const itemElement = targetItem && getItemElement(targetItem);
if (!itemElement) {
const scrollElement = getScrollElement();
scrollElement?.scrollTo({
top: opts?.offset ?? 0,
behavior: opts?.behavior,
});
return;
}
scrollToElement(itemElement, opts);
},
[getScrollElement, scrollToElement, getItemElement, onRangeChange]
);
const paginate = useCallback(
(direction: Direction) => {
const scrollEl = getScrollElement();
const { range: currentRange, limit: currentLimit, count: currentCount } = propRef.current;
let { start, end } = currentRange;
if (direction === Direction.Backward) {
restoreScrollRef.current = undefined;
if (start === 0) {
onEnd?.(true);
return;
}
if (scrollEl) {
restoreScrollRef.current = getRestoreScrollData(
scrollEl.scrollTop,
getRestoreAnchor({ start, end }, getItemElement, Direction.Backward)
);
}
if (scrollEl) {
end = getDropIndex(scrollEl, currentRange, Direction.Forward, getItemElement, 2) ?? end;
}
start = Math.max(start - currentLimit, 0);
}
if (direction === Direction.Forward) {
restoreScrollRef.current = undefined;
if (end === currentCount) {
onEnd?.(false);
return;
}
if (scrollEl) {
restoreScrollRef.current = getRestoreScrollData(
scrollEl.scrollTop,
getRestoreAnchor({ start, end }, getItemElement, Direction.Forward)
);
}
end = Math.min(end + currentLimit, currentCount);
if (scrollEl) {
start =
getDropIndex(scrollEl, currentRange, Direction.Backward, getItemElement, 2) ?? start;
}
}
onRangeChange({
start,
end,
});
},
[getScrollElement, getItemElement, onEnd, onRangeChange]
);
const handlePaginatorElIntersection: OnIntersectionCallback = useCallback(
(entries) => {
const anchorB = entries.find(
(entry) => entry.target.getAttribute(PAGINATOR_ANCHOR_ATTR) === Direction.Backward
);
if (anchorB?.isIntersecting) {
paginate(Direction.Backward);
}
const anchorF = entries.find(
(entry) => entry.target.getAttribute(PAGINATOR_ANCHOR_ATTR) === Direction.Forward
);
if (anchorF?.isIntersecting) {
paginate(Direction.Forward);
}
},
[paginate]
);
const intersectionObserver = useIntersectionObserver(
handlePaginatorElIntersection,
useMemo(
() => ({
root: getScrollElement(),
}),
[getScrollElement]
)
);
const observeBackAnchor = useObserveAnchorHandle(intersectionObserver, Direction.Backward);
const observeFrontAnchor = useObserveAnchorHandle(intersectionObserver, Direction.Forward);
// Restore scroll when local pagination.
// restoreScrollRef.current only gets set
// when paginate() changes range itself
useLayoutEffect(() => {
const scrollEl = getScrollElement();
if (!restoreScrollRef.current || !scrollEl) return;
const {
anchorOffsetTop: oldOffsetTop,
anchorItem,
scrollTop: oldScrollTop,
} = restoreScrollRef.current;
const anchorEl = getItemElement(anchorItem);
if (!anchorEl) return;
const { offsetTop } = anchorEl;
const offsetAddition = offsetTop - oldOffsetTop;
const restoreTop = oldScrollTop + offsetAddition;
scrollEl.scrollTo({
top: restoreTop,
behavior: 'instant',
});
restoreScrollRef.current = undefined;
}, [range, getScrollElement, getItemElement]);
// When scrollToItem index was not in range.
// Scroll to item after range changes.
useLayoutEffect(() => {
if (scrollToItemRef.current === undefined) return;
const { index, opts } = scrollToItemRef.current;
scrollToItem(index, {
...opts,
behavior: 'instant',
});
scrollToItemRef.current = undefined;
}, [range, scrollToItem]);
// Continue pagination to fill view height with scroll items
// check if pagination anchor are in visible view height
// and trigger pagination
useEffect(() => {
if (initialRenderRef.current) {
// Do not trigger pagination on initial render
// anchor intersection observable will trigger pagination on mount
initialRenderRef.current = false;
return;
}
const scrollElement = getScrollElement();
if (!scrollElement) return;
const backAnchor = scrollElement.querySelector(
`[${PAGINATOR_ANCHOR_ATTR}="${Direction.Backward}"]`
) as HTMLElement | null;
const frontAnchor = scrollElement.querySelector(
`[${PAGINATOR_ANCHOR_ATTR}="${Direction.Forward}"]`
) as HTMLElement | null;
if (backAnchor && isIntersectingScrollView(scrollElement, backAnchor)) {
paginate(Direction.Backward);
return;
}
if (frontAnchor && isIntersectingScrollView(scrollElement, frontAnchor)) {
paginate(Direction.Forward);
}
}, [range, getScrollElement, paginate]);
return {
getItems,
scrollToItem,
scrollToElement,
observeBackAnchor,
observeFrontAnchor,
};
};

26
src/app/hooks/useZoom.ts Normal file
View file

@ -0,0 +1,26 @@
import { useState } from 'react';
export const useZoom = (step: number, min = 0.1, max = 5) => {
const [zoom, setZoom] = useState<number>(1);
const zoomIn = () => {
setZoom((z) => {
const newZ = z + step;
return newZ > max ? z : newZ;
});
};
const zoomOut = () => {
setZoom((z) => {
const newZ = z - step;
return newZ < min ? z : newZ;
});
};
return {
zoom,
setZoom,
zoomIn,
zoomOut,
};
};