mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-20 10:33:41 -05:00
web/timeline: use tanstack virtual to virtualise timeline
This commit is contained in:
parent
cb08f43535
commit
15e92411d3
6 changed files with 164 additions and 69 deletions
28
web/package-lock.json
generated
28
web/package-lock.json
generated
|
@ -9,6 +9,7 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-virtual": "^3.11.2",
|
||||||
"@wailsio/runtime": "^3.0.0-alpha.29",
|
"@wailsio/runtime": "^3.0.0-alpha.29",
|
||||||
"blurhash": "^2.0.5",
|
"blurhash": "^2.0.5",
|
||||||
"katex": "^0.16.11",
|
"katex": "^0.16.11",
|
||||||
|
@ -1754,6 +1755,33 @@
|
||||||
"@swc/counter": "^0.1.3"
|
"@swc/counter": "^0.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/react-virtual": {
|
||||||
|
"version": "3.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.11.2.tgz",
|
||||||
|
"integrity": "sha512-OuFzMXPF4+xZgx8UzJha0AieuMihhhaWG0tCqpp6tDzlFwOmNBPYMuLOtMJ1Tr4pXLHmgjcWhG6RlknY2oNTdQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/virtual-core": "3.11.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/virtual-core": {
|
||||||
|
"version": "3.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.11.2.tgz",
|
||||||
|
"integrity": "sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-virtual": "^3.11.2",
|
||||||
"@wailsio/runtime": "^3.0.0-alpha.29",
|
"@wailsio/runtime": "^3.0.0-alpha.29",
|
||||||
"blurhash": "^2.0.5",
|
"blurhash": "^2.0.5",
|
||||||
"katex": "^0.16.11",
|
"katex": "^0.16.11",
|
||||||
|
|
|
@ -305,7 +305,7 @@ export default class Client {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadMoreHistory(roomID: RoomID): Promise<void> {
|
async loadMoreHistory(roomID: RoomID): Promise<number> {
|
||||||
const room = this.store.rooms.get(roomID)
|
const room = this.store.rooms.get(roomID)
|
||||||
if (!room) {
|
if (!room) {
|
||||||
throw new Error("Room not found")
|
throw new Error("Room not found")
|
||||||
|
@ -324,6 +324,7 @@ export default class Client {
|
||||||
}
|
}
|
||||||
room.hasMoreHistory = resp.has_more
|
room.hasMoreHistory = resp.has_more
|
||||||
room.applyPagination(resp.events, resp.related_events, resp.receipts)
|
room.applyPagination(resp.events, resp.related_events, resp.receipts)
|
||||||
|
return resp.events.length
|
||||||
} finally {
|
} finally {
|
||||||
room.paginating = false
|
room.paginating = false
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,7 +39,9 @@ export interface TimelineEventProps {
|
||||||
prevEvt: MemDBEvent | null
|
prevEvt: MemDBEvent | null
|
||||||
disableMenu?: boolean
|
disableMenu?: boolean
|
||||||
smallReplies?: boolean
|
smallReplies?: boolean
|
||||||
isFocused?: boolean
|
isFocused?: boolean,
|
||||||
|
virtualIndex?: number,
|
||||||
|
ref?: React.Ref<HTMLDivElement>
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullTimeFormatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "medium" })
|
const fullTimeFormatter = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "medium" })
|
||||||
|
@ -75,7 +77,7 @@ const EventSendStatus = ({ evt }: { evt: MemDBEvent }) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: TimelineEventProps) => {
|
const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused, virtualIndex, ref }: TimelineEventProps) => {
|
||||||
const roomCtx = useRoomContext()
|
const roomCtx = useRoomContext()
|
||||||
const client = use(ClientContext)!
|
const client = use(ClientContext)!
|
||||||
const mainScreen = use(MainScreenContext)
|
const mainScreen = use(MainScreenContext)
|
||||||
|
@ -145,9 +147,10 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
|
||||||
eventTS.getDate() !== prevEvtDate.getDate() ||
|
eventTS.getDate() !== prevEvtDate.getDate() ||
|
||||||
eventTS.getMonth() !== prevEvtDate.getMonth() ||
|
eventTS.getMonth() !== prevEvtDate.getMonth() ||
|
||||||
eventTS.getFullYear() !== prevEvtDate.getFullYear())) {
|
eventTS.getFullYear() !== prevEvtDate.getFullYear())) {
|
||||||
dateSeparator = <div className="date-separator">
|
const dateLabel = dateFormatter.format(eventTS)
|
||||||
|
dateSeparator = <div className="date-separator" role="separator" aria-label={dateLabel}>
|
||||||
<hr role="none"/>
|
<hr role="none"/>
|
||||||
{dateFormatter.format(eventTS)}
|
{dateLabel}
|
||||||
<hr role="none"/>
|
<hr role="none"/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@ -196,6 +199,8 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
|
||||||
className={wrapperClassNames.join(" ")}
|
className={wrapperClassNames.join(" ")}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
onClick={!disableMenu && isMobileDevice ? onClick : undefined}
|
onClick={!disableMenu && isMobileDevice ? onClick : undefined}
|
||||||
|
data-index={virtualIndex}
|
||||||
|
ref={ref}
|
||||||
>
|
>
|
||||||
{!disableMenu && !isMobileDevice && <div
|
{!disableMenu && !isMobileDevice && <div
|
||||||
className={`context-menu-container ${forceContextMenuOpen ? "force-open" : ""}`}
|
className={`context-menu-container ${forceContextMenuOpen ? "force-open" : ""}`}
|
||||||
|
@ -248,10 +253,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu, smallReplies, isFocused }: T
|
||||||
<ReadReceipts room={roomCtx.store} eventID={evt.event_id} />}
|
<ReadReceipts room={roomCtx.store} eventID={evt.event_id} />}
|
||||||
{evt.sender === client.userID && evt.transaction_id ? <EventSendStatus evt={evt}/> : null}
|
{evt.sender === client.userID && evt.transaction_id ? <EventSendStatus evt={evt}/> : null}
|
||||||
</div>
|
</div>
|
||||||
return <>
|
return mainEvent
|
||||||
{dateSeparator}
|
|
||||||
{mainEvent}
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default React.memo(TimelineEvent)
|
export default React.memo(TimelineEvent)
|
||||||
|
|
|
@ -1,9 +1,6 @@
|
||||||
div.timeline-view {
|
div.timeline-view {
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
|
contain: strict;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
> div.timeline-beginning {
|
> div.timeline-beginning {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
@ -13,7 +13,8 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import { use, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"
|
import { Virtualizer, useVirtualizer } from "@tanstack/react-virtual"
|
||||||
|
import { use, useEffect, useLayoutEffect, useRef, useState } from "react"
|
||||||
import { ScaleLoader } from "react-spinners"
|
import { ScaleLoader } from "react-spinners"
|
||||||
import { usePreference, useRoomTimeline } from "@/api/statestore"
|
import { usePreference, useRoomTimeline } from "@/api/statestore"
|
||||||
import { EventRowID, MemDBEvent } from "@/api/types"
|
import { EventRowID, MemDBEvent } from "@/api/types"
|
||||||
|
@ -21,8 +22,28 @@ import useFocus from "@/util/focus.ts"
|
||||||
import ClientContext from "../ClientContext.ts"
|
import ClientContext from "../ClientContext.ts"
|
||||||
import { useRoomContext } from "../roomview/roomcontext.ts"
|
import { useRoomContext } from "../roomview/roomcontext.ts"
|
||||||
import TimelineEvent from "./TimelineEvent.tsx"
|
import TimelineEvent from "./TimelineEvent.tsx"
|
||||||
|
import { getBodyType, isSmallEvent } from "./content/index.ts"
|
||||||
import "./TimelineView.css"
|
import "./TimelineView.css"
|
||||||
|
|
||||||
|
const measureElement = (element: Element, entry: ResizeObserverEntry | undefined, instance: Virtualizer<HTMLDivElement, Element>) => {
|
||||||
|
const horizontal = instance.options.horizontal
|
||||||
|
const style = window.getComputedStyle(element)
|
||||||
|
if (entry == null ? void 0 : entry.borderBoxSize) {
|
||||||
|
const box = entry?.borderBoxSize[0]
|
||||||
|
if (box) {
|
||||||
|
const size = Math.round(
|
||||||
|
box[horizontal ? "inlineSize" : "blockSize"],
|
||||||
|
)
|
||||||
|
return size + parseFloat(style[horizontal ? "marginInlineStart" : "marginBlockStart"]) + parseFloat(style[horizontal ? "marginInlineEnd" : "marginBlockEnd"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Math.round(
|
||||||
|
element.getBoundingClientRect()[instance.options.horizontal ? "width" : "height"] + parseFloat(style[horizontal ? "marginLeft" : "marginTop"]) + parseFloat(style[horizontal ? "marginRight" : "marginBottom"]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const estimateEventHeight = (event: MemDBEvent) => isSmallEvent(getBodyType(event)) ? (event?.reactions ? 26 : 0) + (event?.content.body ? (event?.local_content?.big_emoji ? 92 : 44) : 0) + (event?.content.info?.h || 0) : 26
|
||||||
|
|
||||||
const TimelineView = () => {
|
const TimelineView = () => {
|
||||||
const roomCtx = useRoomContext()
|
const roomCtx = useRoomContext()
|
||||||
const room = roomCtx.store
|
const room = roomCtx.store
|
||||||
|
@ -30,21 +51,51 @@ const TimelineView = () => {
|
||||||
const client = use(ClientContext)!
|
const client = use(ClientContext)!
|
||||||
const [isLoadingHistory, setLoadingHistory] = useState(false)
|
const [isLoadingHistory, setLoadingHistory] = useState(false)
|
||||||
const [focusedEventRowID, directSetFocusedEventRowID] = useState<EventRowID | null>(null)
|
const [focusedEventRowID, directSetFocusedEventRowID] = useState<EventRowID | null>(null)
|
||||||
const loadHistory = useCallback(() => {
|
const loadHistory = () => {
|
||||||
setLoadingHistory(true)
|
setLoadingHistory(true)
|
||||||
client.loadMoreHistory(room.roomID)
|
client.loadMoreHistory(room.roomID)
|
||||||
.catch(err => console.error("Failed to load history", err))
|
.catch(err => console.error("Failed to load history", err))
|
||||||
.finally(() => setLoadingHistory(false))
|
.then((loadedEventCount) => {
|
||||||
}, [client, room])
|
// Prevent scroll getting stuck loading more history
|
||||||
|
if (loadedEventCount && timelineViewRef.current && timelineViewRef.current.scrollTop <= virtualListOffsetRef.current) {
|
||||||
|
virtualizer.scrollToIndex(loadedEventCount, { align: "end" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setLoadingHistory(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
const bottomRef = roomCtx.timelineBottomRef
|
const bottomRef = roomCtx.timelineBottomRef
|
||||||
const topRef = useRef<HTMLDivElement>(null)
|
|
||||||
const timelineViewRef = useRef<HTMLDivElement>(null)
|
const timelineViewRef = useRef<HTMLDivElement>(null)
|
||||||
const prevOldestTimelineRow = useRef(0)
|
|
||||||
const oldestTimelineRow = timeline[0]?.timeline_rowid
|
|
||||||
const oldScrollHeight = useRef(0)
|
|
||||||
const focused = useFocus()
|
const focused = useFocus()
|
||||||
const smallReplies = usePreference(client.store, room, "small_replies")
|
const smallReplies = usePreference(client.store, room, "small_replies")
|
||||||
|
|
||||||
|
const virtualListRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const virtualListOffsetRef = useRef(0)
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
virtualListOffsetRef.current = virtualListRef.current?.offsetTop ?? 0
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: timeline.length,
|
||||||
|
getScrollElement: () => timelineViewRef.current,
|
||||||
|
estimateSize: (index) => timeline[index] ? estimateEventHeight(timeline[index]) : 0,
|
||||||
|
getItemKey: (index) => timeline[index]?.rowid || index,
|
||||||
|
overscan: 6,
|
||||||
|
measureElement,
|
||||||
|
})
|
||||||
|
|
||||||
|
const items = virtualizer.getVirtualItems()
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (roomCtx.scrolledToBottom) {
|
||||||
|
// timelineViewRef.current && (timelineViewRef.current.scrollTop = timelineViewRef.current.scrollHeight)
|
||||||
|
bottomRef.current?.scrollIntoView()
|
||||||
|
}
|
||||||
|
}, [roomCtx, timeline, virtualizer.getTotalSize()])
|
||||||
|
|
||||||
// When the user scrolls the timeline manually, remember if they were at the bottom,
|
// When the user scrolls the timeline manually, remember if they were at the bottom,
|
||||||
// so that we can keep them at the bottom when new events are added.
|
// so that we can keep them at the bottom when new events are added.
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
|
@ -54,24 +105,11 @@ const TimelineView = () => {
|
||||||
const timelineView = timelineViewRef.current
|
const timelineView = timelineViewRef.current
|
||||||
roomCtx.scrolledToBottom = timelineView.scrollTop + timelineView.clientHeight + 1 >= timelineView.scrollHeight
|
roomCtx.scrolledToBottom = timelineView.scrollTop + timelineView.clientHeight + 1 >= timelineView.scrollHeight
|
||||||
}
|
}
|
||||||
// Save the scroll height prior to updating the timeline, so that we can adjust the scroll position if needed.
|
|
||||||
if (timelineViewRef.current) {
|
|
||||||
oldScrollHeight.current = timelineViewRef.current.scrollHeight
|
|
||||||
}
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const bottomRef = roomCtx.timelineBottomRef
|
|
||||||
if (bottomRef.current && roomCtx.scrolledToBottom) {
|
|
||||||
// For any timeline changes, if we were at the bottom, scroll to the new bottom
|
|
||||||
bottomRef.current.scrollIntoView()
|
|
||||||
} else if (timelineViewRef.current && prevOldestTimelineRow.current > (timeline[0]?.timeline_rowid ?? 0)) {
|
|
||||||
// When new entries are added to the top of the timeline, scroll down to keep the same position
|
|
||||||
timelineViewRef.current.scrollTop += timelineViewRef.current.scrollHeight - oldScrollHeight.current
|
|
||||||
}
|
|
||||||
prevOldestTimelineRow.current = timeline[0]?.timeline_rowid ?? 0
|
|
||||||
}, [client.userID, roomCtx, timeline])
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
roomCtx.directSetFocusedEventRowID = directSetFocusedEventRowID
|
roomCtx.directSetFocusedEventRowID = directSetFocusedEventRowID
|
||||||
}, [roomCtx])
|
}, [roomCtx])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newestEvent = timeline[timeline.length - 1]
|
const newestEvent = timeline[timeline.length - 1]
|
||||||
if (
|
if (
|
||||||
|
@ -95,26 +133,31 @@ const TimelineView = () => {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}, [focused, client, roomCtx, room, timeline])
|
}, [focused, client, roomCtx, room, timeline])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const topElem = topRef.current
|
if (!room.hasMoreHistory || room.paginating) {
|
||||||
if (!topElem || !room.hasMoreHistory) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const observer = new IntersectionObserver(entries => {
|
|
||||||
if (entries[0]?.isIntersecting && room.paginationRequestedForRow !== prevOldestTimelineRow.current) {
|
|
||||||
room.paginationRequestedForRow = prevOldestTimelineRow.current
|
|
||||||
loadHistory()
|
|
||||||
}
|
|
||||||
}, {
|
|
||||||
root: topElem.parentElement!.parentElement,
|
|
||||||
rootMargin: "0px",
|
|
||||||
threshold: 1.0,
|
|
||||||
})
|
|
||||||
observer.observe(topElem)
|
|
||||||
return () => observer.unobserve(topElem)
|
|
||||||
}, [room, room.hasMoreHistory, loadHistory, oldestTimelineRow])
|
|
||||||
|
|
||||||
let prevEvt: MemDBEvent | null = null
|
const firstItem = virtualizer.getVirtualItems()[0]
|
||||||
|
|
||||||
|
// Load history if there is none
|
||||||
|
if (!firstItem) {
|
||||||
|
loadHistory()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load more history when the virtualiser loads the last item
|
||||||
|
if (firstItem.index == 0) {
|
||||||
|
console.log("Loading more history...")
|
||||||
|
loadHistory()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
room.hasMoreHistory, loadHistory,
|
||||||
|
virtualizer.getVirtualItems(),
|
||||||
|
])
|
||||||
|
|
||||||
return <div className="timeline-view" onScroll={handleScroll} ref={timelineViewRef}>
|
return <div className="timeline-view" onScroll={handleScroll} ref={timelineViewRef}>
|
||||||
<div className="timeline-beginning">
|
<div className="timeline-beginning">
|
||||||
{room.hasMoreHistory ? <button onClick={loadHistory} disabled={isLoadingHistory}>
|
{room.hasMoreHistory ? <button onClick={loadHistory} disabled={isLoadingHistory}>
|
||||||
|
@ -123,25 +166,48 @@ const TimelineView = () => {
|
||||||
: "Load more history"}
|
: "Load more history"}
|
||||||
</button> : "No more history available in this room"}
|
</button> : "No more history available in this room"}
|
||||||
</div>
|
</div>
|
||||||
<div className="timeline-list">
|
<div
|
||||||
<div className="timeline-top-ref" ref={topRef}/>
|
style={{
|
||||||
{timeline.map(entry => {
|
height: virtualizer.getTotalSize(),
|
||||||
|
width: "100%",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
className="timeline-list"
|
||||||
|
ref={virtualListRef}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
transform: `translateY(${items[0]?.start ?? 0}px)`,
|
||||||
|
}}
|
||||||
|
className="timeline-virtual-items"
|
||||||
|
>
|
||||||
|
|
||||||
|
{items.map((virtualRow) => {
|
||||||
|
const entry = timeline[virtualRow.index]
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const thisEvt = <TimelineEvent
|
const thisEvt = <TimelineEvent
|
||||||
key={entry.rowid}
|
|
||||||
evt={entry}
|
evt={entry}
|
||||||
prevEvt={prevEvt}
|
prevEvt={timeline[virtualRow.index - 1] ?? null}
|
||||||
smallReplies={smallReplies}
|
smallReplies={smallReplies}
|
||||||
isFocused={focusedEventRowID === entry.rowid}
|
isFocused={focusedEventRowID === entry.rowid}
|
||||||
|
|
||||||
|
key={virtualRow.key}
|
||||||
|
virtualIndex={virtualRow.index}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
/>
|
/>
|
||||||
prevEvt = entry
|
|
||||||
return thisEvt
|
return thisEvt
|
||||||
})}
|
})}
|
||||||
<div className="timeline-bottom-ref" ref={bottomRef}/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="timeline-bottom-ref" ref={bottomRef}/>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TimelineView
|
export default TimelineView
|
||||||
|
|
Loading…
Add table
Reference in a new issue