diff --git a/app/soapbox/components/__tests__/timeline_queue_button_header.test.js b/app/soapbox/components/__tests__/timeline_queue_button_header.test.js index 6874452ae..bc011d309 100644 --- a/app/soapbox/components/__tests__/timeline_queue_button_header.test.js +++ b/app/soapbox/components/__tests__/timeline_queue_button_header.test.js @@ -1,3 +1,4 @@ +import { fromJS } from 'immutable'; import React from 'react'; import { defineMessages } from 'react-intl'; @@ -14,9 +15,11 @@ describe('', () => { {}} // eslint-disable-line react/jsx-no-bind - count={0} + timelineId='home' message={messages.queue} />, + undefined, + { timelines: fromJS({ home: { totalQueuedItemsCount: 0 } }) }, ); expect(screen.queryAllByRole('link')).toHaveLength(0); @@ -24,20 +27,24 @@ describe('', () => { {}} // eslint-disable-line react/jsx-no-bind - count={1} + timelineId='home' message={messages.queue} />, + undefined, + { timelines: fromJS({ home: { totalQueuedItemsCount: 1 } }) }, ); - expect(screen.getByText('Click to see 1 new post', { hidden: true })).toBeInTheDocument(); + expect(screen.getByText(/Click to see\s+1\s+new post/, { hidden: true })).toBeInTheDocument(); render( {}} // eslint-disable-line react/jsx-no-bind - count={9999999} + timelineId='home' message={messages.queue} />, + undefined, + { timelines: fromJS({ home: { totalQueuedItemsCount: 9999999 } }) }, ); - expect(screen.getByText('Click to see 9999999 new posts', { hidden: true })).toBeInTheDocument(); + expect(screen.getByText(/10.*M/, { hidden: true })).toBeInTheDocument(); }); }); diff --git a/app/soapbox/components/scrollable_list.tsx b/app/soapbox/components/scrollable_list.tsx index 3cd3f5132..e546f2a45 100644 --- a/app/soapbox/components/scrollable_list.tsx +++ b/app/soapbox/components/scrollable_list.tsx @@ -1,5 +1,7 @@ -import React from 'react'; -import { Virtuoso, Components, VirtuosoProps, VirtuosoHandle } from 'react-virtuoso'; +import { debounce } from 'lodash'; +import React, { useEffect, useRef, useMemo, useCallback } from 'react'; +import { useHistory } from 'react-router-dom'; +import { Virtuoso, Components, VirtuosoProps, VirtuosoHandle, ListRange, IndexLocationWithAlign } from 'react-virtuoso'; import PullToRefresh from 'soapbox/components/pull-to-refresh'; import { useSettings } from 'soapbox/hooks'; @@ -12,6 +14,12 @@ type Context = { listClassName?: string, } +/** Scroll position saved in sessionStorage. */ +type SavedScrollPosition = { + index: number, + offset: number, +} + // NOTE: It's crucial to space lists with **padding** instead of margin! // Pass an `itemClassName` like `pb-3`, NOT a `space-y-3` className // https://virtuoso.dev/troubleshooting#list-does-not-scroll-to-the-bottom--items-jump-around @@ -31,13 +39,13 @@ interface IScrollableList extends VirtuosoProps { isLoading?: boolean, showLoading?: boolean, hasMore?: boolean, - prepend?: React.ReactElement, + prepend?: React.ReactNode, alwaysPrepend?: boolean, emptyMessage?: React.ReactNode, children: Iterable, onScrollToTop?: () => void, onScroll?: () => void, - placeholderComponent?: React.ComponentType, + placeholderComponent?: React.ComponentType | React.NamedExoticComponent, placeholderCount?: number, onRefresh?: () => Promise, className?: string, @@ -49,6 +57,7 @@ interface IScrollableList extends VirtuosoProps { /** Legacy ScrollableList with Virtuoso for backwards-compatibility */ const ScrollableList = React.forwardRef(({ + scrollKey, prepend = null, alwaysPrepend, children, @@ -66,13 +75,19 @@ const ScrollableList = React.forwardRef(({ placeholderComponent: Placeholder, placeholderCount = 0, initialTopMostItemIndex = 0, - scrollerRef, style = {}, useWindowScroll = true, }, ref) => { + const history = useHistory(); const settings = useSettings(); const autoloadMore = settings.get('autoloadMore'); + // Preserve scroll position + const scrollDataKey = `soapbox:scrollData:${scrollKey}`; + const scrollData: SavedScrollPosition | null = useMemo(() => JSON.parse(sessionStorage.getItem(scrollDataKey)!), [scrollDataKey]); + const topIndex = useRef(scrollData ? scrollData.index : 0); + const topOffset = useRef(scrollData ? scrollData.offset : 0); + /** Normalized children */ const elements = Array.from(children || []); @@ -91,6 +106,29 @@ const ScrollableList = React.forwardRef(({ data.push(); } + const handleScroll = useCallback(debounce(() => { + // HACK: Virtuoso has no better way to get this... + const node = document.querySelector(`[data-virtuoso-scroller] [data-item-index="${topIndex.current}"]`); + if (node) { + topOffset.current = node.getBoundingClientRect().top * -1; + } else { + topOffset.current = 0; + } + }, 150, { trailing: true }), []); + + useEffect(() => { + document.addEventListener('scroll', handleScroll); + sessionStorage.removeItem(scrollDataKey); + + return () => { + if (scrollKey) { + const data: SavedScrollPosition = { index: topIndex.current, offset: topOffset.current }; + sessionStorage.setItem(scrollDataKey, JSON.stringify(data)); + } + document.removeEventListener('scroll', handleScroll); + }; + }, []); + /* Render an empty state instead of the scrollable list */ const renderEmpty = (): JSX.Element => { return ( @@ -131,6 +169,29 @@ const ScrollableList = React.forwardRef(({ } }; + const handleRangeChange = (range: ListRange) => { + // HACK: using the first index can be buggy. + // Track the second item instead, unless the endIndex comes before it (eg one 1 item in view). + topIndex.current = Math.min(range.startIndex + 1, range.endIndex); + handleScroll(); + }; + + /** Figure out the initial index to scroll to. */ + const initialIndex = useMemo(() => { + if (showLoading) return 0; + if (initialTopMostItemIndex) return initialTopMostItemIndex; + + if (scrollData && history.action === 'POP') { + return { + align: 'start', + index: scrollData.index, + offset: scrollData.offset, + }; + } + + return 0; + }, [showLoading, initialTopMostItemIndex]); + /** Render the actual Virtuoso list */ const renderFeed = (): JSX.Element => ( (({ endReached={handleEndReached} isScrolling={isScrolling => isScrolling && onScroll && onScroll()} itemContent={renderItem} - initialTopMostItemIndex={showLoading ? 0 : initialTopMostItemIndex} + initialTopMostItemIndex={initialIndex} + rangeChanged={handleRangeChange} style={style} context={{ listClassName: className, itemClassName, }} components={{ - Header: () => prepend, + Header: () => <>{prepend}, ScrollSeekPlaceholder: Placeholder as any, EmptyPlaceholder: () => renderEmpty(), List, Item, Footer: loadMore, }} - scrollerRef={scrollerRef} /> ); @@ -178,3 +239,4 @@ const ScrollableList = React.forwardRef(({ }); export default ScrollableList; +export type { IScrollableList }; diff --git a/app/soapbox/components/status.tsx b/app/soapbox/components/status.tsx index 370deb6de..2b1325a60 100644 --- a/app/soapbox/components/status.tsx +++ b/app/soapbox/components/status.tsx @@ -61,6 +61,8 @@ export const defaultMediaVisibility = (status: StatusEntity, displayMedia: strin }; interface IStatus extends RouteComponentProps { + id?: string, + contextType?: string, intl: IntlShape, status: StatusEntity, account: AccountEntity, @@ -87,8 +89,8 @@ interface IStatus extends RouteComponentProps { muted: boolean, hidden: boolean, unread: boolean, - onMoveUp: (statusId: string, featured?: string) => void, - onMoveDown: (statusId: string, featured?: string) => void, + onMoveUp: (statusId: string, featured?: boolean) => void, + onMoveDown: (statusId: string, featured?: boolean) => void, getScrollPosition?: () => ScrollPosition | undefined, updateScrollBottom?: (bottom: number) => void, cacheMediaWidth: () => void, @@ -98,7 +100,8 @@ interface IStatus extends RouteComponentProps { allowedEmoji: ImmutableList, focusable: boolean, history: History, - featured?: string, + featured?: boolean, + withDismiss?: boolean, } interface IStatusState { diff --git a/app/soapbox/components/status_list.js b/app/soapbox/components/status_list.js deleted file mode 100644 index 5d08ab1a2..000000000 --- a/app/soapbox/components/status_list.js +++ /dev/null @@ -1,240 +0,0 @@ -import classNames from 'classnames'; -import { debounce } from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; -import { FormattedMessage, defineMessages } from 'react-intl'; - -import StatusContainer from 'soapbox/containers/status_container'; -import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status'; -import PendingStatus from 'soapbox/features/ui/components/pending_status'; - -import LoadGap from './load_gap'; -import ScrollableList from './scrollable_list'; -import TimelineQueueButtonHeader from './timeline_queue_button_header'; - -const messages = defineMessages({ - queue: { id: 'status_list.queue_label', defaultMessage: 'Click to see {count} new {count, plural, one {post} other {posts}}' }, -}); - -export default class StatusList extends ImmutablePureComponent { - - static propTypes = { - scrollKey: PropTypes.string.isRequired, - statusIds: ImmutablePropTypes.orderedSet.isRequired, - lastStatusId: PropTypes.string, - featuredStatusIds: ImmutablePropTypes.orderedSet, - onLoadMore: PropTypes.func, - isLoading: PropTypes.bool, - isPartial: PropTypes.bool, - hasMore: PropTypes.bool, - prepend: PropTypes.node, - emptyMessage: PropTypes.node, - alwaysPrepend: PropTypes.bool, - timelineId: PropTypes.string, - queuedItemSize: PropTypes.number, - onDequeueTimeline: PropTypes.func, - group: ImmutablePropTypes.map, - withGroupAdmin: PropTypes.bool, - onScrollToTop: PropTypes.func, - onScroll: PropTypes.func, - divideType: PropTypes.oneOf(['space', 'border']), - }; - - static defaultProps = { - divideType: 'border', - } - - componentDidMount() { - this.handleDequeueTimeline(); - } - - getFeaturedStatusCount = () => { - return this.props.featuredStatusIds ? this.props.featuredStatusIds.size : 0; - } - - getCurrentStatusIndex = (id, featured) => { - if (featured) { - return this.props.featuredStatusIds.keySeq().findIndex(key => key === id); - } else { - return this.props.statusIds.keySeq().findIndex(key => key === id) + this.getFeaturedStatusCount(); - } - } - - handleMoveUp = (id, featured) => { - const elementIndex = this.getCurrentStatusIndex(id, featured) - 1; - this._selectChild(elementIndex, true); - } - - handleMoveDown = (id, featured) => { - const elementIndex = this.getCurrentStatusIndex(id, featured) + 1; - this._selectChild(elementIndex, false); - } - - handleLoadOlder = debounce(() => { - const loadMoreID = this.props.lastStatusId ? this.props.lastStatusId : this.props.statusIds.last(); - this.props.onLoadMore(loadMoreID); - }, 300, { leading: true }) - - _selectChild(index) { - this.node.scrollIntoView({ - index, - behavior: 'smooth', - done: () => { - const element = document.querySelector(`#status-list [data-index="${index}"] .focusable`); - - if (element) { - element.focus(); - } - }, - }); - } - - handleDequeueTimeline = () => { - const { onDequeueTimeline, timelineId } = this.props; - if (!onDequeueTimeline || !timelineId) return; - onDequeueTimeline(timelineId); - } - - setRef = c => { - this.node = c; - } - - renderLoadGap(index) { - const { statusIds, onLoadMore, isLoading } = this.props; - - return ( - 0 ? statusIds.get(index - 1) : null} - onClick={onLoadMore} - /> - ); - } - - renderStatus(statusId) { - const { timelineId, withGroupAdmin, group } = this.props; - - return ( - - ); - } - - renderPendingStatus(statusId) { - const { timelineId, withGroupAdmin, group } = this.props; - const idempotencyKey = statusId.replace(/^末pending-/, ''); - - return ( - - ); - } - - renderFeaturedStatuses() { - const { featuredStatusIds, timelineId } = this.props; - if (!featuredStatusIds) return null; - - return featuredStatusIds.map(statusId => ( - - )); - } - - renderStatuses() { - const { statusIds, isLoading } = this.props; - - if (isLoading || statusIds.size > 0) { - return statusIds.map((statusId, index) => { - if (statusId === null) { - return this.renderLoadGap(index); - } else if (statusId.startsWith('末pending-')) { - return this.renderPendingStatus(statusId); - } else { - return this.renderStatus(statusId); - } - }); - } else { - return null; - } - } - - renderScrollableContent() { - const featuredStatuses = this.renderFeaturedStatuses(); - const statuses = this.renderStatuses(); - - if (featuredStatuses && statuses) { - return featuredStatuses.concat(statuses); - } else { - return statuses; - } - } - - render() { - const { statusIds, divideType, featuredStatusIds, onLoadMore, timelineId, totalQueuedItemsCount, isLoading, isPartial, withGroupAdmin, group, ...other } = this.props; - - if (isPartial) { - return ( -
-
-
- - -
-
-
- ); - } - - return [ - , - - {this.renderScrollableContent()} - , - ]; - } - -} diff --git a/app/soapbox/components/status_list.tsx b/app/soapbox/components/status_list.tsx new file mode 100644 index 000000000..a421f62a3 --- /dev/null +++ b/app/soapbox/components/status_list.tsx @@ -0,0 +1,209 @@ +import classNames from 'classnames'; +import { debounce } from 'lodash'; +import React, { useRef, useCallback } from 'react'; +import { FormattedMessage } from 'react-intl'; + +import LoadGap from 'soapbox/components/load_gap'; +import ScrollableList from 'soapbox/components/scrollable_list'; +import StatusContainer from 'soapbox/containers/status_container'; +import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder_status'; +import PendingStatus from 'soapbox/features/ui/components/pending_status'; + +import type { OrderedSet as ImmutableOrderedSet } from 'immutable'; +import type { VirtuosoHandle } from 'react-virtuoso'; +import type { IScrollableList } from 'soapbox/components/scrollable_list'; + +interface IStatusList extends Omit { + scrollKey: string, + statusIds: ImmutableOrderedSet, + lastStatusId?: string, + featuredStatusIds?: ImmutableOrderedSet, + onLoadMore?: (lastStatusId: string) => void, + isLoading: boolean, + isPartial?: boolean, + hasMore: boolean, + prepend?: React.ReactNode, + emptyMessage: React.ReactNode, + alwaysPrepend?: boolean, + timelineId?: string, + queuedItemSize?: number, + onScrollToTop?: () => void, + onScroll?: () => void, + divideType: 'space' | 'border', +} + +const StatusList: React.FC = ({ + statusIds, + lastStatusId, + featuredStatusIds, + divideType = 'border', + onLoadMore, + timelineId, + isLoading, + isPartial, + ...other +}) => { + const node = useRef(null); + + const getFeaturedStatusCount = () => { + return featuredStatusIds?.size || 0; + }; + + const getCurrentStatusIndex = (id: string, featured: boolean): number => { + if (featured) { + return featuredStatusIds?.keySeq().findIndex(key => key === id) || 0; + } else { + return statusIds.keySeq().findIndex(key => key === id) + getFeaturedStatusCount(); + } + }; + + const handleMoveUp = (id: string, featured: boolean = false) => { + const elementIndex = getCurrentStatusIndex(id, featured) - 1; + selectChild(elementIndex); + }; + + const handleMoveDown = (id: string, featured: boolean = false) => { + const elementIndex = getCurrentStatusIndex(id, featured) + 1; + selectChild(elementIndex); + }; + + const handleLoadOlder = useCallback(debounce(() => { + const loadMoreID = lastStatusId || statusIds.last(); + if (onLoadMore && loadMoreID) { + onLoadMore(loadMoreID); + } + }, 300, { leading: true }), []); + + const selectChild = (index: number) => { + node.current?.scrollIntoView({ + index, + behavior: 'smooth', + done: () => { + const element: HTMLElement | null = document.querySelector(`#status-list [data-index="${index}"] .focusable`); + element?.focus(); + }, + }); + }; + + const renderLoadGap = (index: number) => { + const ids = statusIds.toList(); + const nextId = ids.get(index + 1); + const prevId = ids.get(index - 1); + + if (index < 1 || !nextId || !prevId || !onLoadMore) return null; + + return ( + + ); + }; + + const renderStatus = (statusId: string) => { + return ( + // @ts-ignore + + ); + }; + + const renderPendingStatus = (statusId: string) => { + const idempotencyKey = statusId.replace(/^末pending-/, ''); + + return ( + + ); + }; + + const renderFeaturedStatuses = (): React.ReactNode[] => { + if (!featuredStatusIds) return []; + + return featuredStatusIds.toArray().map(statusId => ( + // @ts-ignore + + )); + }; + + const renderStatuses = (): React.ReactNode[] => { + if (isLoading || statusIds.size > 0) { + return statusIds.toArray().map((statusId, index) => { + if (statusId === null) { + return renderLoadGap(index); + } else if (statusId.startsWith('末pending-')) { + return renderPendingStatus(statusId); + } else { + return renderStatus(statusId); + } + }); + } else { + return []; + } + }; + + const renderScrollableContent = () => { + const featuredStatuses = renderFeaturedStatuses(); + const statuses = renderStatuses(); + + if (featuredStatuses && statuses) { + return featuredStatuses.concat(statuses); + } else { + return statuses; + } + }; + + if (isPartial) { + return ( +
+
+
+ + +
+
+
+ ); + } + + return ( + + {renderScrollableContent()} + + ); +}; + +export default StatusList; +export type { IStatusList }; diff --git a/app/soapbox/components/timeline_queue_button_header.js b/app/soapbox/components/timeline_queue_button_header.js deleted file mode 100644 index 29a8e787a..000000000 --- a/app/soapbox/components/timeline_queue_button_header.js +++ /dev/null @@ -1,119 +0,0 @@ -import classNames from 'classnames'; -import { throttle } from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { injectIntl } from 'react-intl'; -import { connect } from 'react-redux'; - -import { getSettings } from 'soapbox/actions/settings'; -import Icon from 'soapbox/components/icon'; -import { Text } from 'soapbox/components/ui'; - -const mapStateToProps = state => { - const settings = getSettings(state); - - return { - autoload: settings.get('autoloadTimelines'), - }; -}; - -export default @connect(mapStateToProps) -@injectIntl -class TimelineQueueButtonHeader extends React.PureComponent { - - static propTypes = { - onClick: PropTypes.func.isRequired, - count: PropTypes.number, - message: PropTypes.object.isRequired, - threshold: PropTypes.number, - intl: PropTypes.object.isRequired, - autoload: PropTypes.bool, - autoloadThreshold: PropTypes.number, - }; - - static defaultProps = { - count: 0, - threshold: 400, - autoload: true, - autoloadThreshold: 50, - }; - - state = { - scrolled: false, - } - - componentDidMount() { - this.attachScrollListener(); - } - - componentWillUnmount() { - this.detachScrollListener(); - } - - componentDidUpdate(prevProps, prevState) { - const { scrollTop } = (document.scrollingElement || document.documentElement); - const { count, onClick, autoload, autoloadThreshold } = this.props; - - if (autoload && scrollTop <= autoloadThreshold && count !== prevProps.count) { - onClick(); - } - } - - attachScrollListener() { - window.addEventListener('scroll', this.handleScroll); - } - - detachScrollListener() { - window.removeEventListener('scroll', this.handleScroll); - } - - handleScroll = throttle(() => { - const { scrollTop } = (document.scrollingElement || document.documentElement); - const { threshold, onClick, autoload, autoloadThreshold } = this.props; - - if (autoload && scrollTop <= autoloadThreshold) { - onClick(); - } - - if (scrollTop > threshold) { - this.setState({ scrolled: true }); - } else { - this.setState({ scrolled: false }); - } - }, 150, { trailing: true }); - - scrollUp = () => { - window.scrollTo({ top: 0, behavior: 'smooth' }); - }; - - handleClick = e => { - setTimeout(this.scrollUp, 10); - this.props.onClick(e); - } - - render() { - const { count, message, intl } = this.props; - const { scrolled } = this.state; - - const visible = count > 0 && scrolled; - - const classes = classNames('left-1/2 -translate-x-1/2 fixed top-20 z-50', { - 'hidden': !visible, - }); - - return ( - - ); - } - -} diff --git a/app/soapbox/components/timeline_queue_button_header.tsx b/app/soapbox/components/timeline_queue_button_header.tsx new file mode 100644 index 000000000..292368571 --- /dev/null +++ b/app/soapbox/components/timeline_queue_button_header.tsx @@ -0,0 +1,85 @@ +import classNames from 'classnames'; +import { throttle } from 'lodash'; +import React, { useState, useEffect, useCallback } from 'react'; +import { useIntl, MessageDescriptor } from 'react-intl'; + +import Icon from 'soapbox/components/icon'; +import { Text } from 'soapbox/components/ui'; +import { useAppSelector, useSettings } from 'soapbox/hooks'; +import { shortNumberFormat } from 'soapbox/utils/numbers'; + +interface ITimelineQueueButtonHeader { + onClick: () => void, + timelineId: string, + message: MessageDescriptor, + threshold?: number, + autoloadThreshold?: number, +} + +const TimelineQueueButtonHeader: React.FC = ({ + onClick, + timelineId, + message, + threshold = 400, + autoloadThreshold = 50, +}) => { + const intl = useIntl(); + const settings = useSettings(); + const count = useAppSelector(state => state.timelines.getIn([timelineId, 'totalQueuedItemsCount'])); + + const [scrolled, setScrolled] = useState(false); + const autoload = settings.get('autoloadTimelines') === true; + + const handleScroll = useCallback(throttle(() => { + const { scrollTop } = (document.scrollingElement || document.documentElement); + + if (autoload && scrollTop <= autoloadThreshold) { + onClick(); + } + + if (scrollTop > threshold) { + setScrolled(true); + } else { + setScrolled(false); + } + }, 150, { trailing: true }), []); + + const scrollUp = () => { + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + const handleClick: React.MouseEventHandler = () => { + setTimeout(scrollUp, 10); + onClick(); + }; + + useEffect(() => { + window.addEventListener('scroll', handleScroll); + + return () => { + window.removeEventListener('scroll', handleScroll); + }; + }, []); + + const visible = count > 0 && scrolled; + + const classes = classNames('left-1/2 -translate-x-1/2 fixed top-20 z-50', { + 'hidden': !visible, + }); + + return ( + + ); +}; + +export default TimelineQueueButtonHeader; diff --git a/app/soapbox/features/bookmarks/index.tsx b/app/soapbox/features/bookmarks/index.tsx index ebd4eb1cd..0b1255f32 100644 --- a/app/soapbox/features/bookmarks/index.tsx +++ b/app/soapbox/features/bookmarks/index.tsx @@ -1,13 +1,12 @@ import { debounce } from 'lodash'; import React from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; -import { useDispatch } from 'react-redux'; import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'soapbox/actions/bookmarks'; import StatusList from 'soapbox/components/status_list'; import SubNavigation from 'soapbox/components/sub_navigation'; import { Column } from 'soapbox/components/ui'; -import { useAppSelector } from 'soapbox/hooks'; +import { useAppSelector, useAppDispatch } from 'soapbox/hooks'; const messages = defineMessages({ heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' }, @@ -18,7 +17,7 @@ const handleLoadMore = debounce((dispatch) => { }, 300, { leading: true }); const Bookmarks: React.FC = () => { - const dispatch = useDispatch(); + const dispatch = useAppDispatch(); const intl = useIntl(); const statusIds = useAppSelector((state) => state.status_lists.getIn(['bookmarks', 'items'])); @@ -42,7 +41,7 @@ const Bookmarks: React.FC = () => { handleLoadMore(dispatch)} diff --git a/app/soapbox/features/conversations/components/conversation.tsx b/app/soapbox/features/conversations/components/conversation.tsx index 995af88b3..6f638f2d6 100644 --- a/app/soapbox/features/conversations/components/conversation.tsx +++ b/app/soapbox/features/conversations/components/conversation.tsx @@ -48,8 +48,8 @@ const Conversation: React.FC = ({ conversationId, onMoveUp, onMov } return ( - = (props) => { case 'poll': case 'pleroma:emoji_reaction': return status && typeof status === 'object' ? ( + // @ts-ignore