mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-05 06:50:28 +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
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,
|
||||
};
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue