diff --git a/.gitignore b/.gitignore index 87cc7c3055..473d44b7b3 100644 --- a/.gitignore +++ b/.gitignore @@ -160,7 +160,6 @@ docker-compose.override.yaml .aider* .env - **/CLAUDE.local.md -./CLAUDE.md - +CLAUDE.md +.cursorrules diff --git a/e2e-tests/playwright/specs/visual/common/login.spec.ts-snapshots/login-error-firefox-linux.png b/e2e-tests/playwright/specs/visual/common/login.spec.ts-snapshots/login-error-firefox-linux.png index caab795ccb..ac6a2881b6 100644 Binary files a/e2e-tests/playwright/specs/visual/common/login.spec.ts-snapshots/login-error-firefox-linux.png and b/e2e-tests/playwright/specs/visual/common/login.spec.ts-snapshots/login-error-firefox-linux.png differ diff --git a/e2e-tests/playwright/specs/visual/common/login.spec.ts-snapshots/login-error-ipad-linux.png b/e2e-tests/playwright/specs/visual/common/login.spec.ts-snapshots/login-error-ipad-linux.png index 784cab742c..2a84a8282c 100644 Binary files a/e2e-tests/playwright/specs/visual/common/login.spec.ts-snapshots/login-error-ipad-linux.png and b/e2e-tests/playwright/specs/visual/common/login.spec.ts-snapshots/login-error-ipad-linux.png differ diff --git a/e2e-tests/playwright/specs/visual/common/signup_email.spec.ts-snapshots/signup-email-chrome-linux.png b/e2e-tests/playwright/specs/visual/common/signup_email.spec.ts-snapshots/signup-email-chrome-linux.png index ab25465a81..1ca838edae 100644 Binary files a/e2e-tests/playwright/specs/visual/common/signup_email.spec.ts-snapshots/signup-email-chrome-linux.png and b/e2e-tests/playwright/specs/visual/common/signup_email.spec.ts-snapshots/signup-email-chrome-linux.png differ diff --git a/e2e-tests/playwright/specs/visual/common/signup_email.spec.ts-snapshots/signup-email-error-firefox-linux.png b/e2e-tests/playwright/specs/visual/common/signup_email.spec.ts-snapshots/signup-email-error-firefox-linux.png index 138a562785..25c1c7b130 100644 Binary files a/e2e-tests/playwright/specs/visual/common/signup_email.spec.ts-snapshots/signup-email-error-firefox-linux.png and b/e2e-tests/playwright/specs/visual/common/signup_email.spec.ts-snapshots/signup-email-error-firefox-linux.png differ diff --git a/e2e-tests/playwright/specs/visual/common/signup_email.spec.ts-snapshots/signup-email-error-ipad-linux.png b/e2e-tests/playwright/specs/visual/common/signup_email.spec.ts-snapshots/signup-email-error-ipad-linux.png index c39669b25b..4e9684537a 100644 Binary files a/e2e-tests/playwright/specs/visual/common/signup_email.spec.ts-snapshots/signup-email-error-ipad-linux.png and b/e2e-tests/playwright/specs/visual/common/signup_email.spec.ts-snapshots/signup-email-error-ipad-linux.png differ diff --git a/e2e-tests/playwright/specs/visual/common/signup_email.spec.ts-snapshots/signup-email-firefox-linux.png b/e2e-tests/playwright/specs/visual/common/signup_email.spec.ts-snapshots/signup-email-firefox-linux.png index 965b46f964..ef845a5961 100644 Binary files a/e2e-tests/playwright/specs/visual/common/signup_email.spec.ts-snapshots/signup-email-firefox-linux.png and b/e2e-tests/playwright/specs/visual/common/signup_email.spec.ts-snapshots/signup-email-firefox-linux.png differ diff --git a/e2e-tests/playwright/specs/visual/common/signup_email.spec.ts-snapshots/signup-email-ipad-linux.png b/e2e-tests/playwright/specs/visual/common/signup_email.spec.ts-snapshots/signup-email-ipad-linux.png index dd50811e75..bded51722b 100644 Binary files a/e2e-tests/playwright/specs/visual/common/signup_email.spec.ts-snapshots/signup-email-ipad-linux.png and b/e2e-tests/playwright/specs/visual/common/signup_email.spec.ts-snapshots/signup-email-ipad-linux.png differ diff --git a/webapp/channels/src/components/dynamic_virtualized_list/dynamic_virtualized_list.d.ts b/webapp/channels/src/components/dynamic_virtualized_list/dynamic_virtualized_list.d.ts new file mode 100644 index 0000000000..7b857acf3b --- /dev/null +++ b/webapp/channels/src/components/dynamic_virtualized_list/dynamic_virtualized_list.d.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +declare module 'components/dynamic_virtualized_list' { + export type OnScrollArgs = { + scrollDirection: 'backward' | 'forward'; + scrollOffset: number; + scrollUpdateWasRequested: boolean; + clientHeight: number; + scrollHeight: number; + } + + export type OnItemsRenderedArgs = { + overscanStartIndex: number; + overscanStopIndex: number; + visibleStartIndex: number; + visibleStopIndex: number; + } + + interface DynamicVirtualizedListProps { + canLoadMorePosts: (id: 'BEFORE_ID' | 'AFTER_ID' | undefined) => Promise; + children: ({data: any, itemId: any, style: any}) => JSX.Element; + height: number; + initRangeToRender: number[]; + initScrollToIndex: () => any; + initialScrollOffset?: number; + innerRef: React.Ref; + itemData: string[]; + onItemsRendered: (args: OnItemsRenderedArgs) => void; + onScroll: (scrollArgs: OnScrollArgs) => void; + overscanCountBackward: number; + overscanCountForward: number; + scrollToFailed: (index: number) => void; + style: CSSProperties; + width: number; + + id?: string; + className?: string; + correctScrollToBottom?: boolean; + innerListStyle?: CSSProperties; + loaderId?: string; + scrollToFailed?: (index: number) => void; + } + + export class DynamicVirtualizedList extends React.PureComponent { + scrollTo(scrollOffset: number, scrollByValue?: number, useAnimationFrame?: boolean): void; + scrollToItem(index: number, align: string, offset?: number): void; + _getRangeToRender(): number[]; + } +} diff --git a/webapp/channels/src/components/dynamic_virtualized_list/index.jsx b/webapp/channels/src/components/dynamic_virtualized_list/index.jsx new file mode 100644 index 0000000000..0b017c3213 --- /dev/null +++ b/webapp/channels/src/components/dynamic_virtualized_list/index.jsx @@ -0,0 +1,846 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable react/prop-types */ +/* eslint-disable no-underscore-dangle */ + +import memoizeOne from 'memoize-one'; +import {createElement, PureComponent} from 'react'; + +import ItemMeasurer from './item_measurer'; + +const atBottomMargin = 10; + +export class DynamicVirtualizedList extends PureComponent { + _listMetaData = { + itemOffsetMap: {}, + itemSizeMap: {}, + totalMeasuredSize: 0, + atBottom: true, + }; + + _itemStyleCache = {}; + _outerRef; + _scrollCorrectionInProgress = false; + _scrollByCorrection = null; + _keepScrollPosition = false; + _keepScrollToBottom = false; + _mountingCorrections = 0; + _correctedInstances = 0; + + static defaultProps = { + innerTagName: 'div', + itemData: undefined, + outerTagName: 'div', + overscanCountForward: 30, + overscanCountBackward: 10, + }; + + state = { + scrollDirection: 'backward', + scrollOffset: typeof this.props.initialScrollOffset === 'number' ? this.props.initialScrollOffset : 0, + scrollUpdateWasRequested: false, + scrollDelta: 0, + scrollHeight: 0, + localOlderPostsToRender: [], + }; + + // Always use explicit constructor for React components. + // It produces less code after transpilation. (#26) + // eslint-disable-next-line no-useless-constructor + constructor(props) { + super(props); + } + + scrollBy = (scrollOffset, scrollBy) => () => { + const element = this._outerRef; + if (typeof element.scrollBy === 'function' && scrollBy) { + element.scrollBy(0, scrollBy); + } else if (scrollOffset) { + element.scrollTop = scrollOffset; + } + + this._scrollCorrectionInProgress = false; + }; + + scrollTo(scrollOffset, scrollByValue, useAnimationFrame = false) { + this._scrollCorrectionInProgress = true; + this.setState( + (prevState) => ({ + scrollDirection: prevState.scrollOffset >= scrollOffset ? 'backward' : 'forward', + scrollOffset, + scrollUpdateWasRequested: true, + scrollByValue, + }), + () => { + if (useAnimationFrame) { + this._scrollByCorrection = window.requestAnimationFrame( + this.scrollBy( + this.state.scrollOffset, + this.state.scrollByValue, + ), + ); + } else { + this.scrollBy( + this.state.scrollOffset, + this.state.scrollByValue, + )(); + } + }, + ); + + this.forceUpdate(); + } + + scrollToItem(index, align = 'auto', offset = 0) { + const {scrollOffset} = this.state; + + //Ideally the below scrollTo works fine but firefox has 6px issue and stays 6px from bottom when corrected + //so manually keeping scroll position bottom for now + const element = this._outerRef; + if (index === 0 && align === 'end') { + this.scrollTo(element.scrollHeight - this.props.height); + return; + } + + const offsetOfItem = getOffsetForIndexAndAlignment( + this.props, + index, + align, + scrollOffset, + this._listMetaData, + ); + if (!offsetOfItem) { + const itemSize = getItemSize(this.props, index, this._listMetaData); + if (!itemSize && this.props.scrollToFailed) { + if (this.state.scrolledToInitIndex) { + this.props.scrollToFailed(index); + } + } + } + + this.scrollTo(offsetOfItem + offset); + } + + componentDidMount() { + const {initialScrollOffset} = this.props; + + if (typeof initialScrollOffset === 'number' && this._outerRef !== null) { + const element = this._outerRef; + element.scrollTop = initialScrollOffset; + } + + this._commitHook(); + } + + getSnapshotBeforeUpdate(_, prevState) { + if (prevState.localOlderPostsToRender[0] !== this.state.localOlderPostsToRender[0] || + prevState.localOlderPostsToRender[1] !== this.state.localOlderPostsToRender[1] + ) { + const element = this._outerRef; + const previousScrollTop = element.scrollTop; + const previousScrollHeight = element.scrollHeight; + return { + previousScrollTop, + previousScrollHeight, + }; + } + return null; + } + + componentDidUpdate(prevProps, prevState, snapshot) { + if (this.state.scrolledToInitIndex) { + const { + scrollDirection, + scrollOffset, + scrollUpdateWasRequested, + scrollHeight, + } = this.state; + + const { + scrollDirection: prevScrollDirection, + scrollOffset: prevScrollOffset, + scrollUpdateWasRequested: prevScrollUpdateWasRequested, + scrollHeight: previousScrollHeight, + } = prevState; + + if ( + scrollDirection !== prevScrollDirection || + scrollOffset !== prevScrollOffset || + scrollUpdateWasRequested !== prevScrollUpdateWasRequested || + scrollHeight !== previousScrollHeight + ) { + this._callPropsCallbacks(); + } + if (!prevState.scrolledToInitIndex) { + this._keepScrollPosition = false; + this._keepScrollToBottom = false; + } + } + + this._commitHook(); + if (prevProps.itemData !== this.props.itemData) { + this._dataChange(); + } + + if (prevProps.height !== this.props.height) { + this._heightChange(prevProps.height, prevState.scrollOffset); + } + + if (prevState.scrolledToInitIndex !== this.state.scrolledToInitIndex) { + this._dataChange(); // though this is not data change we are checking for first load change + } + + if (prevProps.width !== this.props.width) { + this.innerRefWidth = this.props.innerRef.current.clientWidth; + this._widthChange(prevProps.height, prevState.scrollOffset); + } + + if (prevState.localOlderPostsToRender[0] !== this.state.localOlderPostsToRender[0] || + prevState.localOlderPostsToRender[1] !== this.state.localOlderPostsToRender[1] + ) { + const postlistScrollHeight = this._outerRef.scrollHeight; + + const scrollValue = snapshot.previousScrollTop + (postlistScrollHeight - snapshot.previousScrollHeight); + + this.scrollTo( + scrollValue, + scrollValue - snapshot.previousScrollTop, + true, + ); + } + } + + componentWillUnmount() { + if (this._scrollByCorrection) { + window.cancelAnimationFrame(this._scrollByCorrection); + } + } + + _callOnItemsRendered = memoizeOne((overscanStartIndex, overscanStopIndex, visibleStartIndex, visibleStopIndex) => + this.props.onItemsRendered({ + overscanStartIndex, + overscanStopIndex, + visibleStartIndex, + visibleStopIndex, + }), + ); + + _callOnScroll = memoizeOne((scrollDirection, scrollOffset, scrollUpdateWasRequested, scrollHeight, clientHeight) => + this.props.onScroll({ + scrollDirection, + scrollOffset, + scrollUpdateWasRequested, + scrollHeight, + clientHeight, + }), + ); + + _callPropsCallbacks() { + const {itemData, height} = this.props; + const { + scrollDirection, + scrollOffset, + scrollUpdateWasRequested, + scrollHeight, + } = this.state; + const itemCount = itemData.length; + + if (typeof this.props.onItemsRendered === 'function') { + if (itemCount > 0) { + const [ + overscanStartIndex, + overscanStopIndex, + visibleStartIndex, + visibleStopIndex, + ] = this._getRangeToRender(); + + this._callOnItemsRendered( + overscanStartIndex, + overscanStopIndex, + visibleStartIndex, + visibleStopIndex, + ); + + if ( + scrollDirection === 'backward' && + scrollOffset < 1000 && + overscanStopIndex !== itemCount - 1 + ) { + const sizeOfNextElement = getItemSize( + this.props, + overscanStopIndex + 1, + this._listMetaData, + ).size; + if (!sizeOfNextElement && this.state.scrolledToInitIndex) { + this.setState((prevState) => { + if ( + prevState.localOlderPostsToRender[0] !== + overscanStopIndex + 1 + ) { + return { + localOlderPostsToRender: [ + overscanStopIndex + 1, + overscanStopIndex + 50, + ], + }; + } + return null; + }); + } + } + } + } + + if (typeof this.props.onScroll === 'function') { + this._callOnScroll( + scrollDirection, + scrollOffset, + scrollUpdateWasRequested, + scrollHeight, + height, + ); + } + } + + // This method is called after mount and update. + // List implementations can override this method to be notified. + _commitHook = () => { + if ( + !this.state.scrolledToInitIndex && + Object.keys(this._listMetaData.itemOffsetMap).length + ) { + const {index, position, offset} = this.props.initScrollToIndex(); + this.scrollToItem(index, position, offset); + this.setState({ + scrolledToInitIndex: true, + }); + + if (index === 0) { + this._keepScrollToBottom = true; + } else { + this._keepScrollPosition = true; + } + } + }; + + // This method is called when data changes + // List implementations can override this method to be notified. + _dataChange = () => { + if (this._listMetaData.totalMeasuredSize < this.props.height) { + this.props.canLoadMorePosts(); + } + }; + + _heightChange = (prevHeight, prevOffset) => { + const wasAtBottom = + prevOffset + prevHeight >= + this._listMetaData.totalMeasuredSize - atBottomMargin; + + if (wasAtBottom) { + this.scrollToItem(0, 'end'); + } + }; + + _widthChange = (prevHeight, prevOffset) => { + const wasAtBottom = + prevOffset + prevHeight >= + this._listMetaData.totalMeasuredSize - atBottomMargin; + + if (wasAtBottom) { + this.scrollToItem(0, 'end'); + } + }; + + // Lazily create and cache item styles while scrolling, + // So that pure component sCU will prevent re-renders. + // We maintain this cache, and pass a style prop rather than index, + // So that List can clear cached styles and force item re-render if necessary. + _getItemStyle = (index) => { + const {itemData} = this.props; + + const itemStyleCache = this._itemStyleCache; + + let style; + if (Object.prototype.hasOwnProperty.call(itemStyleCache, itemData[index])) { + style = itemStyleCache[itemData[index]]; + } else { + style = { + left: 0, + top: getItemOffset(this.props, index, this._listMetaData), + height: getItemSize(this.props, index, this._listMetaData), + width: '100%', + }; + itemStyleCache[itemData[index]] = style; + } + + return style; + }; + + _getRangeToRender(scrollTop) { + const {itemData, overscanCountForward, overscanCountBackward} = + this.props; + const {scrollDirection, scrollOffset} = this.state; + const itemCount = itemData.length; + + if (itemCount === 0) { + return [0, 0, 0, 0]; + } + const scrollOffsetValue = scrollTop >= 0 ? scrollTop : scrollOffset; + const startIndex = getStartIndexForOffset( + this.props, + scrollOffsetValue, + this._listMetaData, + ); + const stopIndex = getStopIndexForStartIndex( + this.props, + startIndex, + scrollOffsetValue, + this._listMetaData, + ); + + // Overscan by one item in each direction so that tab/focus works. + // If there isn't at least one extra item, tab loops back around. + const overscanBackward = scrollDirection === 'backward' ? overscanCountBackward : Math.max(1, overscanCountForward); + + const overscanForward = scrollDirection === 'forward' ? overscanCountBackward : Math.max(1, overscanCountForward); + + const minValue = Math.max(0, stopIndex - overscanBackward); + let maxValue = Math.max(0, Math.min(itemCount - 1, startIndex + overscanForward)); + + while ( + !getItemSize(this.props, maxValue, this._listMetaData) && + maxValue > 0 && + this._listMetaData.totalMeasuredSize > this.props.height + ) { + maxValue--; + } + + if ( + !this.state.scrolledToInitIndex && + this.props.initRangeToRender.length + ) { + return this.props.initRangeToRender; + } + + return [minValue, maxValue, startIndex, stopIndex]; + } + + _correctScroll = () => { + const {scrollOffset} = this.state; + const element = this._outerRef; + if (element) { + element.scrollTop = scrollOffset; + this._scrollCorrectionInProgress = false; + this._correctedInstances = 0; + this._mountingCorrections = 0; + } + }; + + _generateOffsetMeasurements = () => { + const {itemOffsetMap, itemSizeMap} = this._listMetaData; + const {itemData} = this.props; + this._listMetaData.totalMeasuredSize = 0; + + for (let i = itemData.length - 1; i >= 0; i--) { + const prevOffset = itemOffsetMap[itemData[i + 1]] || 0; + + // In some browsers (e.g. Firefox) fast scrolling may skip rows. + // In this case, our assumptions about last measured indices may be incorrect. + // Handle this edge case to prevent NaN values from breaking styles. + // Slow scrolling back over these skipped rows will adjust their sizes. + const prevSize = itemSizeMap[itemData[i + 1]] || 0; + + itemOffsetMap[itemData[i]] = prevOffset + prevSize; + this._listMetaData.totalMeasuredSize += itemSizeMap[itemData[i]] || 0; + + // Reset cached style to clear stale position. + delete this._itemStyleCache[itemData[i]]; + } + }; + + _handleNewMeasurements = (key, newSize, forceScrollCorrection) => { + const {itemSizeMap} = this._listMetaData; + const {itemData} = this.props; + const index = itemData.findIndex((item) => item === key); + + // In some browsers (e.g. Firefox) fast scrolling may skip rows. + // In this case, our assumptions about last measured indices may be incorrect. + // Handle this edge case to prevent NaN values from breaking styles. + // Slow scrolling back over these skipped rows will adjust their sizes. + const oldSize = itemSizeMap[key] || 0; + if (oldSize === newSize) { + return; + } + + itemSizeMap[key] = newSize; + + if (!this.state.scrolledToInitIndex) { + this._generateOffsetMeasurements(); + return; + } + + const element = this._outerRef; + const wasAtBottom = + this.props.height + element.scrollTop >= + this._listMetaData.totalMeasuredSize - atBottomMargin; + + if ( + (wasAtBottom || this._keepScrollToBottom) && + this.props.correctScrollToBottom + ) { + this._generateOffsetMeasurements(); + this.scrollToItem(0, 'end'); + this.forceUpdate(); + return; + } + + if (forceScrollCorrection || this._keepScrollPosition) { + const delta = newSize - oldSize; + const [, , visibleStartIndex] = this._getRangeToRender(this.state.scrollOffset); + this._generateOffsetMeasurements(); + if (index < visibleStartIndex + 1) { + return; + } + + this._scrollCorrectionInProgress = true; + + this.setState( + (prevState) => { + let deltaValue; + if (this._mountingCorrections === 0) { + deltaValue = delta; + } else { + deltaValue = prevState.scrollDelta + delta; + } + this._mountingCorrections++; + const newOffset = prevState.scrollOffset + delta; + return { + scrollOffset: newOffset, + scrollDelta: deltaValue, + }; + }, + () => { + this._correctedInstances++; + if ( + this._mountingCorrections === this._correctedInstances + ) { + this._correctScroll(); + } + }, + ); + return; + } + + this._generateOffsetMeasurements(); + }; + + _onItemRowUnmount = (itemId, index) => { + const {props} = this; + if (props.itemData[index] === itemId) { + return; + } + const doesItemExist = props.itemData.includes(itemId); + if (!doesItemExist) { + delete this._listMetaData.itemSizeMap[itemId]; + delete this._listMetaData.itemOffsetMap[itemId]; + const element = this._outerRef; + + const atBottom = + element.offsetHeight + element.scrollTop >= + this._listMetaData.totalMeasuredSize - atBottomMargin; + + this._generateOffsetMeasurements(); + + if (atBottom) { + this.scrollToItem(0, 'end'); + } + + this.forceUpdate(); + } + }; + + _onScrollVertical = (event) => { + if (!this.state.scrolledToInitIndex) { + return; + } + const {scrollTop, scrollHeight} = event.currentTarget; + if (this._scrollCorrectionInProgress) { + if (this.state.scrollUpdateWasRequested) { + this.setState(() => ({ + scrollUpdateWasRequested: false, + })); + } + return; + } + + if (scrollHeight !== this.state.scrollHeight) { + this.setState({ + scrollHeight, + }); + } + + this.setState((prevState) => { + if (prevState.scrollOffset === scrollTop) { + // Scroll position may have been updated by cDM/cDU, + // In which case we don't need to trigger another render, + return null; + } + + return { + scrollDirection: + prevState.scrollOffset < scrollTop ? 'forward' : 'backward', + scrollOffset: scrollTop, + scrollUpdateWasRequested: false, + scrollHeight, + scrollTop, + scrollDelta: 0, + }; + }); + }; + + _outerRefSetter = (ref) => { + const {outerRef} = this.props; + this.innerRefWidth = this.props.innerRef.current.clientWidth; + this._outerRef = ref; + + if (typeof outerRef === 'function') { + outerRef(ref); + } else if (outerRef != null && typeof outerRef === 'object' && Object.prototype.hasOwnProperty.call(outerRef, 'current')) { + outerRef.current = ref; + } + }; + + _renderItems = () => { + const {children, direction, itemData, loaderId, visibleId} = + this.props; + const width = this.innerRefWidth; + const [startIndex, stopIndex] = this._getRangeToRender(); + const itemCount = itemData.length; + const items = []; + if (itemCount > 0) { + for (let index = itemCount - 1; index >= 0; index--) { + const {size} = getItemMetadata( + this.props, + index, + this._listMetaData, + ); + + const [ + localOlderPostsToRenderStartIndex, + localOlderPostsToRenderStopIndex, + ] = this.state.localOlderPostsToRender; + + const isItemInLocalPosts = + index >= localOlderPostsToRenderStartIndex && + index < localOlderPostsToRenderStopIndex + 1 && + localOlderPostsToRenderStartIndex === stopIndex + 1; + + const isLoader = itemData[index] === loaderId; + const isVisible = itemData[index] === visibleId; + const itemId = itemData[index]; + + // It's important to read style after fetching item metadata. + // getItemMetadata() will clear stale styles. + const style = this._getItemStyle(index); + if ( + (index >= startIndex && index < stopIndex + 1) || + isItemInLocalPosts || + isLoader || + isVisible + ) { + const item = createElement(children, { + data: itemData, + itemId, + }); + + // Always wrap children in a ItemMeasurer to detect changes in size. + items.push( + createElement(ItemMeasurer, { + direction, + handleNewMeasurements: this._handleNewMeasurements, + index, + item, + key: itemId, + size, + itemId, + width, + onUnmount: this._onItemRowUnmount, + itemCount, + }), + ); + } else { + items.push( + createElement('div', { + key: itemId, + style, + }), + ); + } + } + } + return items; + }; + + render() { + const { + className, + id, + innerRef, + innerTagName, + outerTagName, + style, + innerListStyle, + } = this.props; + + const onScroll = this._onScrollVertical; + + const items = this._renderItems(); + + return createElement( + outerTagName, + { + className, + id, + onScroll, + ref: this._outerRefSetter, + style: { + WebkitOverflowScrolling: 'touch', + overflowY: 'auto', + overflowAnchor: 'none', + willChange: 'transform', + width: '100%', + ...style, + }, + }, + createElement( + innerTagName, + { + ref: innerRef, + role: 'list', + style: innerListStyle, + }, + items, + ), + ); + } +} + +const getItemMetadata = (props, index, listMetaData) => { + const {itemOffsetMap, itemSizeMap} = listMetaData; + const {itemData} = props; + + // If the specified item has not yet been measured, + // Just return an estimated size for now. + if (!itemSizeMap[itemData[index]]) { + return { + offset: 0, + size: 0, + }; + } + + const offset = itemOffsetMap[itemData[index]] || 0; + const size = itemSizeMap[itemData[index]] || 0; + + return {offset, size}; +}; + +const getItemOffset = (props, index, listMetaData) => getItemMetadata(props, index, listMetaData).offset; + +const getOffsetForIndexAndAlignment = ( + props, + index, + align, + scrollOffset, + listMetaData, +) => { + const {height} = props; + const itemMetadata = getItemMetadata(props, index, listMetaData); + + // Get estimated total size after ItemMetadata is computed, + // To ensure it reflects actual measurements instead of just estimates. + const estimatedTotalSize = listMetaData.totalMeasuredSize; + + const maxOffset = Math.max(0, (itemMetadata.offset + itemMetadata.size) - height); + const minOffset = Math.max(0, itemMetadata.offset); + + switch (align) { + case 'start': + return minOffset; + case 'end': + return maxOffset; + case 'center': + return Math.round(((minOffset - (height / 2)) + (itemMetadata.size / 2))); + case 'auto': + default: + if (scrollOffset >= minOffset && scrollOffset <= maxOffset) { + return estimatedTotalSize - (scrollOffset + height); + } else if (scrollOffset - minOffset < maxOffset - scrollOffset) { + return minOffset; + } + return maxOffset; + } +}; + +const findNearestItem = (props, listMetaData, high, low, scrollOffset) => { + let index = low; + while (low <= high) { + var currentOffset = getItemMetadata(props, low, listMetaData).offset; + if (scrollOffset - currentOffset <= 0) { + index = low; + } + + // eslint-disable-next-line no-param-reassign + low++; + } + return index; +}; + +const getStartIndexForOffset = (props, offset, listMetaData) => { + const {totalMeasuredSize} = listMetaData; + const {itemData} = props; + + // If we've already positioned and measured past this point, + // Use a binary search to find the closets cell. + if (offset <= totalMeasuredSize) { + return findNearestItem(props, listMetaData, itemData.length, 0, offset); + } + + // Otherwise render a new batch of items starting from where 0. + return 0; +}; + +const getStopIndexForStartIndex = ( + props, + startIndex, + scrollOffset, + listMetaData, +) => { + const {itemData} = props; + + let stopIndex = startIndex; + const maxOffset = scrollOffset + props.height; + const itemMetadata = getItemMetadata(props, stopIndex, listMetaData); + let offset = itemMetadata.offset + (itemMetadata.size || 0); + + while (stopIndex > 0 && offset <= maxOffset) { + const itemMetadata = getItemMetadata(props, stopIndex, listMetaData); + offset = itemMetadata.offset + itemMetadata.size; + stopIndex--; + } + + if (stopIndex >= itemData.length) { + return 0; + } + + return stopIndex; +}; + +const getItemSize = (props, index, listMetaData) => { + // Do not hard-code item dimensions. + // We don't know them initially. + // Even once we do, changes in item content or list size should reflow. + return getItemMetadata(props, index, listMetaData).size; +}; + +export default DynamicVirtualizedList; diff --git a/webapp/channels/src/components/dynamic_virtualized_list/item_measurer.jsx b/webapp/channels/src/components/dynamic_virtualized_list/item_measurer.jsx new file mode 100644 index 0000000000..1d4d9b9596 --- /dev/null +++ b/webapp/channels/src/components/dynamic_virtualized_list/item_measurer.jsx @@ -0,0 +1,202 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable react/prop-types */ +/* eslint-disable no-underscore-dangle */ + +import React, {PureComponent} from 'react'; +import {findDOMNode} from 'react-dom'; + +import {isSafari} from 'utils/user_agent'; + +const scrollBarWidth = 8; +const scrollableContainerStyles = { + display: 'inline', + width: '0px', + height: '0px', + zIndex: '-1', + overflow: 'hidden', + margin: '0px', + padding: '0px', +}; + +const scrollableWrapperStyle = { + position: 'absolute', + flex: '0 0 auto', + overflow: 'hidden', + visibility: 'hidden', + zIndex: '-1', + width: '100%', + height: '100%', + left: '0px', + top: '0px', +}; + +const expandShrinkContainerStyles = { + flex: '0 0 auto', + overflow: 'hidden', + zIndex: '-1', + visibility: 'hidden', + left: `-${scrollBarWidth + 1}px`, //8px(scrollbar width) + 1px + bottom: `-${scrollBarWidth}px`, //8px because of scrollbar width + right: `-${scrollBarWidth}px`, //8px because of scrollbar width + top: `-${scrollBarWidth + 1}px`, //8px(scrollbar width) + 1px +}; + +const expandShrinkStyles = { + position: 'absolute', + flex: '0 0 auto', + visibility: 'hidden', + overflow: 'scroll', + zIndex: '-1', + width: '100%', + height: '100%', +}; + +const shrinkChildStyle = { + position: 'absolute', + height: '200%', + width: '200%', +}; + +//values below need to be changed when scrollbar width changes +const shrinkScrollDelta = (2 * scrollBarWidth) + 1; // 17 = 2* scrollbar width(8px) + 1px as buffer + +// 27 = 2* scrollbar width(8px) + 1px as buffer + 10px(this value is based of off lib(Link below). Probably not needed but doesnt hurt to leave) +//https://github.com/wnr/element-resize-detector/blob/27983e59dce9d8f1296d8f555dc2340840fb0804/src/detection-strategy/scroll.js#L246 +const expandScrollDelta = shrinkScrollDelta + 10; + +export default class ItemMeasurer extends PureComponent { + _node = null; + _resizeSensorExpand = React.createRef(); + _resizeSensorShrink = React.createRef(); + _positionScrollbarsRef = null; + _measureItemAnimFrame = null; + + componentDidMount() { + // eslint-disable-next-line react/no-find-dom-node + this._node = findDOMNode(this); + + // Force sync measure for the initial mount. + // This is necessary to support the DynamicSizeList layout logic. + if (isSafari() && this.props.size) { + this._measureItemAnimFrame = requestAnimationFrame(() => { + this._measureItem(false); + }); + } else { + this._measureItem(false); + } + + if (this.props.size) { + // Don't wait for positioning scrollbars when we have size + // This is needed triggering an event for remounting a post + this.positionScrollBars(); + } + } + + componentDidUpdate(prevProps) { + if ((prevProps.size === 0 && this.props.size !== 0) || prevProps.size !== this.props.size) { + this.positionScrollBars(); + } + } + + _measureItem = (forceScrollCorrection) => { + const {handleNewMeasurements, size: oldSize, itemId} = this.props; + + const node = this._node; + + if (node && node.ownerDocument && node.ownerDocument.defaultView && node instanceof node.ownerDocument.defaultView.HTMLElement) { + const newSize = Math.ceil(node.offsetHeight); + + if (oldSize !== newSize) { + handleNewMeasurements(itemId, newSize, forceScrollCorrection); + } + } + }; + + positionScrollBars = (height = this.props.size) => { + //we are position these hidden div scroll bars to the end so they can emit + //scroll event when height in the div changes + //Heavily inspired from https://github.com/marcj/css-element-queries/blob/master/src/ResizeSensor.js + //and https://github.com/wnr/element-resize-detector/blob/master/src/detection-strategy/scroll.js + //For more info http://www.backalleycoder.com/2013/03/18/cross-browser-event-based-element-resize-detection/#comment-244 + if (this._positionScrollbarsRef) { + window.cancelAnimationFrame(this._positionScrollbarsRef); + } + + this._positionScrollbarsRef = window.requestAnimationFrame(() => { + this._resizeSensorExpand.current.scrollTop = height + expandScrollDelta; + this._resizeSensorShrink.current.scrollTop = (2 * height) + shrinkScrollDelta; + }); + }; + + scrollingDiv = (event) => { + if (event.target.offsetHeight !== this.props.size) { + this._measureItem(event.target.offsetWidth !== this.props.width); + } + }; + + renderItems = () => { + const item = this.props.item; + + const expandChildStyle = { + position: 'absolute', + left: '0', + top: '0', + height: `${this.props.size + expandScrollDelta}px`, + width: '100%', + }; + + const renderItem = ( +
+ {item} +
+
+
+
+
+
+
+
+
+
+
+
+
+ ); + return renderItem; + }; + + componentWillUnmount() { + if (this._positionScrollbarsRef) { + window.cancelAnimationFrame(this._positionScrollbarsRef); + } + + if (this._measureItemAnimFrame) { + window.cancelAnimationFrame(this._measureItemAnimFrame); + } + + const {onUnmount, itemId, index} = this.props; + if (onUnmount) { + onUnmount(itemId, index); + } + } + + render() { + return this.renderItems(); + } +} diff --git a/webapp/channels/src/components/post_view/post_list_virtualized/post_list_virtualized.test.tsx b/webapp/channels/src/components/post_view/post_list_virtualized/post_list_virtualized.test.tsx index 5ee9ac5454..6878c10559 100644 --- a/webapp/channels/src/components/post_view/post_list_virtualized/post_list_virtualized.test.tsx +++ b/webapp/channels/src/components/post_view/post_list_virtualized/post_list_virtualized.test.tsx @@ -5,10 +5,9 @@ import {shallow} from 'enzyme'; import React from 'react'; import type {ComponentProps} from 'react'; -import type {DynamicSizeList} from '@mattermost/dynamic-virtualized-list'; - import {DATE_LINE} from 'mattermost-redux/utils/post_list'; +import type {DynamicVirtualizedList} from 'components/dynamic_virtualized_list'; import PostListRow from 'components/post_view/post_list_row'; import {PostListRowListIds, PostRequestTypes} from 'utils/constants'; @@ -133,7 +132,7 @@ describe('PostList', () => { const scrollHeight = 1000; const clientHeight = 500; - instance.listRef = {current: {_getRangeToRender: () => [0, 70, 12, 1]} as unknown as DynamicSizeList}; + instance.listRef = {current: {_getRangeToRender: () => [0, 70, 12, 1]} as unknown as DynamicVirtualizedList}; instance.onScroll({ scrollDirection: 'forward', scrollOffset, @@ -153,7 +152,7 @@ describe('PostList', () => { const scrollHeight = 1000; const clientHeight = 500; - instance.listRef = {current: {_getRangeToRender: () => [0, 70, 12, 2]} as unknown as DynamicSizeList}; + instance.listRef = {current: {_getRangeToRender: () => [0, 70, 12, 2]} as unknown as DynamicVirtualizedList}; instance.onScroll({ scrollDirection: 'forward', scrollOffset, diff --git a/webapp/channels/src/components/post_view/post_list_virtualized/post_list_virtualized.tsx b/webapp/channels/src/components/post_view/post_list_virtualized/post_list_virtualized.tsx index c0a9c1562c..d512557823 100644 --- a/webapp/channels/src/components/post_view/post_list_virtualized/post_list_virtualized.tsx +++ b/webapp/channels/src/components/post_view/post_list_virtualized/post_list_virtualized.tsx @@ -1,20 +1,17 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -/* eslint-disable max-lines */ - import React from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; -import {DynamicSizeList} from '@mattermost/dynamic-virtualized-list'; -import type {OnItemsRenderedArgs} from '@mattermost/dynamic-virtualized-list'; - import EventEmitter from 'mattermost-redux/utils/event_emitter'; import {getNewMessagesIndex, isDateLine, isStartOfNewMessages} from 'mattermost-redux/utils/post_list'; import type {updateNewMessagesAtInChannel} from 'actions/global_actions'; import type {CanLoadMorePosts} from 'actions/views/channel'; +import {DynamicVirtualizedList} from 'components/dynamic_virtualized_list'; +import type {OnItemsRenderedArgs} from 'components/dynamic_virtualized_list'; import FloatingTimestamp from 'components/post_view/floating_timestamp'; import PostListRow from 'components/post_view/post_list_row'; import ScrollToBottomArrows from 'components/post_view/scroll_to_bottom_arrows'; @@ -147,7 +144,7 @@ type State = { } export default class PostList extends React.PureComponent { - listRef: React.RefObject; + listRef: React.RefObject; postListRef: React.RefObject; scrollStopAction: DelayedAction | null = null; initRangeToRender: number[]; @@ -717,12 +714,12 @@ export default class PostList extends React.PureComponent { {this.renderToasts(width)}
- { scrollToFailed={this.scrollToFailed} > {this.renderRow} - + )}