mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-06 15:30:27 +03:00
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:
parent
fcd7723f73
commit
3a95d0da01
124 changed files with 9438 additions and 258 deletions
6
src/app/hooks/media/index.ts
Normal file
6
src/app/hooks/media/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export * from './useMediaPlay';
|
||||
export * from './useMediaPlayTimeCallback';
|
||||
export * from './useMediaPlaybackRate';
|
||||
export * from './useMediaSeek';
|
||||
export * from './useMediaVolume';
|
||||
export * from './useMediaLoading';
|
||||
51
src/app/hooks/media/useMediaLoading.ts
Normal file
51
src/app/hooks/media/useMediaLoading.ts
Normal 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;
|
||||
};
|
||||
46
src/app/hooks/media/useMediaPlay.ts
Normal file
46
src/app/hooks/media/useMediaPlay.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
24
src/app/hooks/media/useMediaPlayTimeCallback.ts
Normal file
24
src/app/hooks/media/useMediaPlayTimeCallback.ts
Normal 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]);
|
||||
};
|
||||
40
src/app/hooks/media/useMediaPlaybackRate.ts
Normal file
40
src/app/hooks/media/useMediaPlaybackRate.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
51
src/app/hooks/media/useMediaSeek.ts
Normal file
51
src/app/hooks/media/useMediaSeek.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
60
src/app/hooks/media/useMediaVolume.ts
Normal file
60
src/app/hooks/media/useMediaVolume.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
80
src/app/hooks/useMatrixEventRenderer.ts
Normal file
80
src/app/hooks/useMatrixEventRenderer.ts
Normal 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;
|
||||
};
|
||||
218
src/app/hooks/useMemberEventParser.tsx
Normal file
218
src/app/hooks/useMemberEventParser.tsx
Normal 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
62
src/app/hooks/usePan.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
25
src/app/hooks/useRelations.ts
Normal file
25
src/app/hooks/useRelations.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
35
src/app/hooks/useRoomEventReaders.ts
Normal file
35
src/app/hooks/useRoomEventReaders.ts
Normal 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;
|
||||
};
|
||||
29
src/app/hooks/useRoomLatestEvent.ts
Normal file
29
src/app/hooks/useRoomLatestEvent.ts
Normal 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;
|
||||
};
|
||||
68
src/app/hooks/useRoomMsgContentRenderer.ts
Normal file
68
src/app/hooks/useRoomMsgContentRenderer.ts
Normal 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;
|
||||
};
|
||||
405
src/app/hooks/useVirtualPaginator.ts
Normal file
405
src/app/hooks/useVirtualPaginator.ts
Normal 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
26
src/app/hooks/useZoom.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue