mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-04 14:30:29 +03:00
* 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
405 lines
12 KiB
TypeScript
405 lines
12 KiB
TypeScript
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,
|
|
};
|
|
};
|