Do not use immutable for timelines

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak 2024-10-29 13:32:12 +01:00
parent 533c83c1b2
commit 632905f591
12 changed files with 143 additions and 154 deletions

View file

@ -70,7 +70,7 @@ const fetchHomeTimeline = (expand = false, done = noOp) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
const state = getState(); const state = getState();
const fn = (expand && state.timelines.get('home')?.next?.()) || client.timelines.homeTimeline(); const fn = (expand && state.timelines.home?.next?.()) || client.timelines.homeTimeline();
return dispatch(handleTimelineExpand('home', fn, false, done)); return dispatch(handleTimelineExpand('home', fn, false, done));
}; };
@ -82,7 +82,7 @@ const fetchPublicTimeline = ({ onlyMedia, local, instance }: Record<string, any>
const params: PublicTimelineParams = { only_media: onlyMedia, local: instance ? false : local, instance }; const params: PublicTimelineParams = { only_media: onlyMedia, local: instance ? false : local, instance };
const fn = (expand && state.timelines.get(timelineId)?.next?.()) || client.timelines.publicTimeline(params); const fn = (expand && state.timelines[timelineId]?.next?.()) || client.timelines.publicTimeline(params);
return dispatch(handleTimelineExpand(timelineId, fn, false, done)); return dispatch(handleTimelineExpand(timelineId, fn, false, done));
}; };
@ -95,7 +95,7 @@ const fetchAccountTimeline = (accountId: string, { exclude_replies, pinned, only
const params: GetAccountStatusesParams = { exclude_replies, pinned, only_media, limit, tagged }; const params: GetAccountStatusesParams = { exclude_replies, pinned, only_media, limit, tagged };
if (pinned || only_media) params.with_muted = true; if (pinned || only_media) params.with_muted = true;
const fn = (expand && state.timelines.get(timelineId)?.next?.()) || client.accounts.getAccountStatuses(accountId, params); const fn = (expand && state.timelines[timelineId]?.next?.()) || client.accounts.getAccountStatuses(accountId, params);
return dispatch(handleTimelineExpand(timelineId, fn, false, done)); return dispatch(handleTimelineExpand(timelineId, fn, false, done));
}; };
@ -108,7 +108,7 @@ const fetchGroupTimeline = (groupId: string, { only_media, limit }: Record<strin
const params: GroupTimelineParams = { only_media, limit }; const params: GroupTimelineParams = { only_media, limit };
if (only_media) params.with_muted = true; if (only_media) params.with_muted = true;
const fn = (expand && state.timelines.get(timelineId)?.next?.()) || client.timelines.groupTimeline(groupId, params); const fn = (expand && state.timelines[timelineId]?.next?.()) || client.timelines.groupTimeline(groupId, params);
return dispatch(handleTimelineExpand(timelineId, fn, false, done)); return dispatch(handleTimelineExpand(timelineId, fn, false, done));
}; };
@ -118,7 +118,7 @@ const fetchHashtagTimeline = (hashtag: string, { tags }: Record<string, any> = {
const state = getState(); const state = getState();
const timelineId = `hashtag:${hashtag}`; const timelineId = `hashtag:${hashtag}`;
const fn = (expand && state.timelines.get(timelineId)?.next?.()) || client.timelines.hashtagTimeline(hashtag); const fn = (expand && state.timelines[timelineId]?.next?.()) || client.timelines.hashtagTimeline(hashtag);
return dispatch(handleTimelineExpand(timelineId, fn, false, done)); return dispatch(handleTimelineExpand(timelineId, fn, false, done));
}; };

View file

@ -8,15 +8,14 @@ import StatusContainer from 'bigbuffet/containers/status-container';
import PlaceholderStatus from 'bigbuffet/features/placeholder/components/placeholder-status'; import PlaceholderStatus from 'bigbuffet/features/placeholder/components/placeholder-status';
import type { IScrollableList } from 'bigbuffet/components/scrollable-list'; import type { IScrollableList } from 'bigbuffet/components/scrollable-list';
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> { interface IStatusList extends Omit<IScrollableList, 'onLoadMore' | 'children'> {
/** List of status IDs to display. */ /** List of status IDs to display. */
statusIds: ImmutableOrderedSet<string>; statusIds: Array<string>;
/** Last _unfiltered_ status ID (maxId) for pagination. */ /** Last _unfiltered_ status ID (maxId) for pagination. */
lastStatusId?: string; lastStatusId?: string;
/** Pinned statuses to show at the top of the feed. */ /** Pinned statuses to show at the top of the feed. */
featuredStatusIds?: ImmutableOrderedSet<string>; featuredStatusIds?: Array<string>;
/** Pagination callback when the end of the list is reached. */ /** Pagination callback when the end of the list is reached. */
onLoadMore?: (lastStatusId: string) => void; onLoadMore?: (lastStatusId: string) => void;
/** Whether the data is currently being fetched. */ /** Whether the data is currently being fetched. */
@ -38,13 +37,13 @@ const StatusList: React.FC<IStatusList> = ({
isLoading, isLoading,
...other ...other
}) => { }) => {
const getFeaturedStatusCount = () => featuredStatusIds?.size || 0; const getFeaturedStatusCount = () => featuredStatusIds?.length || 0;
const getCurrentStatusIndex = (id: string, featured: boolean): number => { const getCurrentStatusIndex = (id: string, featured: boolean): number => {
if (featured) { if (featured) {
return featuredStatusIds?.keySeq().findIndex(key => key === id) || 0; return featuredStatusIds?.findIndex(key => key === id) || 0;
} else { } else {
return statusIds.keySeq().findIndex(key => key === id) + getFeaturedStatusCount(); return statusIds.findIndex(key => key === id) + getFeaturedStatusCount();
} }
}; };
@ -59,11 +58,11 @@ const StatusList: React.FC<IStatusList> = ({
}; };
const handleLoadOlder = useCallback(debounce(() => { const handleLoadOlder = useCallback(debounce(() => {
const maxId = lastStatusId || statusIds.last(); const maxId = statusIds.at(-1);
if (onLoadMore && maxId) { if (onLoadMore && maxId) {
onLoadMore(maxId.replace('末suggestions-', '')); onLoadMore(maxId.replace('末suggestions-', ''));
} }
}, 300, { leading: true }), [onLoadMore, lastStatusId, statusIds.last()]); }, 300, { leading: true }), [onLoadMore, lastStatusId, statusIds]);
const selectChild = (index: number) => { const selectChild = (index: number) => {
const selector = `#status-list [data-index="${index}"] .focusable`; const selector = `#status-list [data-index="${index}"] .focusable`;
@ -73,9 +72,9 @@ const StatusList: React.FC<IStatusList> = ({
}; };
const renderLoadGap = (index: number) => { const renderLoadGap = (index: number) => {
const ids = statusIds.toList(); const ids = statusIds;
const nextId = ids.get(index + 1); const nextId = ids[index + 1];
const prevId = ids.get(index - 1); const prevId = ids[index - 1];
if (index < 1 || !nextId || !prevId || !onLoadMore) return null; if (index < 1 || !nextId || !prevId || !onLoadMore) return null;
@ -101,7 +100,7 @@ const StatusList: React.FC<IStatusList> = ({
const renderFeaturedStatuses = (): React.ReactNode[] => { const renderFeaturedStatuses = (): React.ReactNode[] => {
if (!featuredStatusIds) return []; if (!featuredStatusIds) return [];
return featuredStatusIds.toArray().map(statusId => ( return featuredStatusIds.map(statusId => (
<StatusContainer <StatusContainer
key={`f-${statusId}`} key={`f-${statusId}`}
id={statusId} id={statusId}
@ -113,8 +112,8 @@ const StatusList: React.FC<IStatusList> = ({
}; };
const renderStatuses = (): React.ReactNode[] => { const renderStatuses = (): React.ReactNode[] => {
if (isLoading || statusIds.size > 0) { if (isLoading || statusIds.length > 0) {
return statusIds.toList().reduce((acc, statusId, index) => { return statusIds.reduce((acc, statusId, index) => {
if (statusId === null) { if (statusId === null) {
const gap = renderLoadGap(index); const gap = renderLoadGap(index);
if (gap) { if (gap) {
@ -147,7 +146,7 @@ const StatusList: React.FC<IStatusList> = ({
id='status-list' id='status-list'
key='scrollable-list' key='scrollable-list'
isLoading={isLoading} isLoading={isLoading}
showLoading={isLoading && statusIds.size === 0} showLoading={isLoading && statusIds.length === 0}
onLoadMore={handleLoadOlder} onLoadMore={handleLoadOlder}
placeholderComponent={PlaceholderStatus} placeholderComponent={PlaceholderStatus}
placeholderCount={20} placeholderCount={20}

View file

@ -39,11 +39,11 @@ const LoadMoreMedia: React.FC<ILoadMoreMedia> = ({ maxId, onLoadMore }) => {
}; };
const useAccountGallery = (accountId?: string) => { const useAccountGallery = (accountId?: string) => {
const statusIds = useAppSelector((state) => state.timelines.get(`account:${accountId}:with_replies:media`)?.items); const statusIds = useAppSelector((state) => state.timelines[`account:${accountId}:with_replies:media`]?.items);
const queryClient = usePlHooksQueryClient(); const queryClient = usePlHooksQueryClient();
return useQueries<UseQueryOptions<NormalizedStatus>[]>({ return useQueries<UseQueryOptions<NormalizedStatus>[]>({
queries: (statusIds?.toArray() || []).map(statusId => ({ queries: (statusIds || []).map(statusId => ({
queryKey: ['statuses', 'entities', statusId], queryKey: ['statuses', 'entities', statusId],
})), })),
}, queryClient).reduce<Array<MediaAttachment & { }, queryClient).reduce<Array<MediaAttachment & {
@ -69,8 +69,8 @@ const AccountGallery = () => {
} = useAccountLookup(username); } = useAccountLookup(username);
const attachments = useAccountGallery(account?.id); const attachments = useAccountGallery(account?.id);
const isLoading = useAppSelector((state) => state.timelines.get(`account:${account?.id}:with_replies:media`)?.isLoading); const isLoading = useAppSelector((state) => state.timelines[`account:${account?.id}:with_replies:media`]?.isLoading);
const hasMore = useAppSelector((state) => state.timelines.get(`account:${account?.id}:with_replies:media`)?.hasMore); const hasMore = useAppSelector((state) => state.timelines[`account:${account?.id}:with_replies:media`]?.hasMore);
const node = useRef<HTMLDivElement>(null); const node = useRef<HTMLDivElement>(null);

View file

@ -1,4 +1,3 @@
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import { useAccountLookup } from 'pl-hooks'; import { useAccountLookup } from 'pl-hooks';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
@ -17,8 +16,6 @@ interface IHashtagTimeline {
}; };
} }
const emptyTimeline = ImmutableOrderedSet<string>();
export const AccountHashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => { export const AccountHashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) => {
const id = params?.id || ''; const id = params?.id || '';
@ -28,10 +25,10 @@ export const AccountHashtagTimeline: React.FC<IHashtagTimeline> = ({ params }) =
const path = `account:${account?.id}:hashtag:${id}`; const path = `account:${account?.id}:hashtag:${id}`;
const statusIds = useAppSelector(state => state.timelines.get(path)?.items) || emptyTimeline; const statusIds = useAppSelector(state => state.timelines[path]?.items) || [];
const isLoading = useAppSelector(state => state.timelines.get(path)?.isLoading === true); const isLoading = useAppSelector(state => state.timelines[path]?.isLoading === true);
const hasMore = useAppSelector(state => state.timelines.get(path)?.hasMore === true); const hasMore = useAppSelector(state => state.timelines[path]?.hasMore === true);
useEffect(() => { useEffect(() => {
if (account) { if (account) {

View file

@ -1,4 +1,3 @@
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import { useAccountLookup } from 'pl-hooks'; import { useAccountLookup } from 'pl-hooks';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
@ -24,11 +23,11 @@ const AccountTimeline: React.FC<IAccountTimeline> = ({ params, withReplies = fal
const path = withReplies ? `${account?.id}:with_replies` : account?.id; const path = withReplies ? `${account?.id}:with_replies` : account?.id;
const showPins = !withReplies; const showPins = !withReplies;
const statusIds = useAppSelector(state => state.timelines.get(`account:${path}`)?.items || ImmutableOrderedSet<string>()); const statusIds = useAppSelector(state => state.timelines[`account:${path}`]?.items || []);
const featuredStatusIds = useAppSelector(state => state.timelines.get(`account:${account?.id}:pinned`)?.items || ImmutableOrderedSet<string>()); const featuredStatusIds = useAppSelector(state => state.timelines[`account:${account?.id}:pinned`]?.items || []);
const isLoading = useAppSelector(state => state.timelines.get(`account:${path}`)?.isLoading === true); const isLoading = useAppSelector(state => state.timelines[`account:${path}`]?.isLoading === true);
const hasMore = useAppSelector(state => state.timelines.get(`account:${path}`)?.hasMore === true); const hasMore = useAppSelector(state => state.timelines[`account:${path}`]?.hasMore === true);
useEffect(() => { useEffect(() => {
if (account && !withReplies) { if (account && !withReplies) {

View file

@ -1,4 +1,3 @@
import { List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable';
import { useStatus } from 'pl-hooks'; import { useStatus } from 'pl-hooks';
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
@ -30,15 +29,15 @@ const EventDiscussion: React.FC<IEventDiscussion> = (props) => {
const { data: status } = useStatus(props.params.statusId); const { data: status } = useStatus(props.params.statusId);
const descendantsIds = useAppSelector(state => { const descendantsIds = useAppSelector(state => {
let descendantsIds = ImmutableOrderedSet<string>(); let descendantsIds = new Set<string>();
if (status) { if (status) {
const statusId = status.id; const statusId = status.id;
descendantsIds = getDescendantsIds(state, statusId); descendantsIds = getDescendantsIds(state, statusId);
descendantsIds = descendantsIds.delete(statusId); descendantsIds.delete(statusId);
} }
return descendantsIds; return Array.from(descendantsIds);
}); });
const [isLoaded, setIsLoaded] = useState<boolean>(!!status); const [isLoaded, setIsLoaded] = useState<boolean>(!!status);
@ -60,12 +59,12 @@ const EventDiscussion: React.FC<IEventDiscussion> = (props) => {
}, [props.params.statusId]); }, [props.params.statusId]);
const handleMoveUp = (id: string) => { const handleMoveUp = (id: string) => {
const index = ImmutableList(descendantsIds).indexOf(id); const index = descendantsIds.indexOf(id);
_selectChild(index - 1); _selectChild(index - 1);
}; };
const handleMoveDown = (id: string) => { const handleMoveDown = (id: string) => {
const index = ImmutableList(descendantsIds).indexOf(id); const index = descendantsIds.indexOf(id);
_selectChild(index + 1); _selectChild(index + 1);
}; };
@ -97,7 +96,7 @@ const EventDiscussion: React.FC<IEventDiscussion> = (props) => {
/> />
); );
const renderChildren = (list: ImmutableOrderedSet<string>) => list.map(id => { const renderChildren = (list: Array<string>) => list.map(id => {
if (id.endsWith('-tombstone')) { if (id.endsWith('-tombstone')) {
return renderTombstone(id); return renderTombstone(id);
} else { } else {
@ -105,7 +104,7 @@ const EventDiscussion: React.FC<IEventDiscussion> = (props) => {
} }
}); });
const hasDescendants = descendantsIds.size > 0; const hasDescendants = descendantsIds.length > 0;
if (!status && isLoaded) { if (!status && isLoaded) {
return ( return (
@ -120,7 +119,7 @@ const EventDiscussion: React.FC<IEventDiscussion> = (props) => {
const children: JSX.Element[] = []; const children: JSX.Element[] = [];
if (hasDescendants) { if (hasDescendants) {
children.push(...renderChildren(descendantsIds).toArray()); children.push(...renderChildren(descendantsIds));
} }
return ( return (

View file

@ -1,4 +1,3 @@
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import { useStatusQuotes } from 'pl-hooks'; import { useStatusQuotes } from 'pl-hooks';
import React from 'react'; import React from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
@ -15,9 +14,7 @@ const Quotes: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const { statusId } = useParams<{ statusId: string }>(); const { statusId } = useParams<{ statusId: string }>();
const { data, isLoading, hasNextPage, fetchNextPage } = useStatusQuotes(statusId); const { data: statusIds = [], isLoading, hasNextPage, fetchNextPage } = useStatusQuotes(statusId);
const statusIds = ImmutableOrderedSet(data);
const emptyMessage = <FormattedMessage id='empty_column.quotes' defaultMessage='This post has not been quoted yet.' />; const emptyMessage = <FormattedMessage id='empty_column.quotes' defaultMessage='This post has not been quoted yet.' />;

View file

@ -1,6 +1,5 @@
import { createSelector } from '@reduxjs/toolkit'; import { createSelector } from '@reduxjs/toolkit';
import clsx from 'clsx'; import clsx from 'clsx';
import { List as ImmutableList, OrderedSet as ImmutableOrderedSet } from 'immutable';
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { FormattedMessage, useIntl } from 'react-intl'; import { FormattedMessage, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
@ -26,11 +25,11 @@ export const getAncestorsIds = createSelector([
(_: RootState, statusId: string | undefined) => statusId, (_: RootState, statusId: string | undefined) => statusId,
(state: RootState) => state.contexts.inReplyTos, (state: RootState) => state.contexts.inReplyTos,
], (statusId, inReplyTos) => { ], (statusId, inReplyTos) => {
let ancestorsIds = ImmutableOrderedSet<string>(); let ancestorsIds = new Set<string>();
let id: string | undefined = statusId; let id: string | undefined = statusId;
while (id && !ancestorsIds.includes(id)) { while (id && !ancestorsIds.has(id)) {
ancestorsIds = ImmutableOrderedSet([id]).union(ancestorsIds); ancestorsIds = new Set([id]).union(ancestorsIds);
id = inReplyTos.get(id); id = inReplyTos.get(id);
} }
@ -41,7 +40,7 @@ export const getDescendantsIds = createSelector([
(_: RootState, statusId: string) => statusId, (_: RootState, statusId: string) => statusId,
(state: RootState) => state.contexts.replies, (state: RootState) => state.contexts.replies,
], (statusId, contextReplies) => { ], (statusId, contextReplies) => {
let descendantsIds = ImmutableOrderedSet<string>(); let descendantsIds = new Set<string>();
const ids = [statusId]; const ids = [statusId];
while (ids.length > 0) { while (ids.length > 0) {
@ -50,12 +49,12 @@ export const getDescendantsIds = createSelector([
const replies = contextReplies.get(id); const replies = contextReplies.get(id);
if (descendantsIds.includes(id)) { if (descendantsIds.has(id)) {
break; break;
} }
if (statusId !== id) { if (statusId !== id) {
descendantsIds = descendantsIds.union([id]); descendantsIds = descendantsIds.union(new Set(id));
} }
if (replies) { if (replies) {
@ -91,26 +90,29 @@ const Thread: React.FC<IThread> = ({
const showMedia = statusesMeta[status.id]?.visible ?? !status.sensitive; const showMedia = statusesMeta[status.id]?.visible ?? !status.sensitive;
const { ancestorsIds, descendantsIds } = useAppSelector((state) => { const { ancestorsIds, descendantsIds } = useAppSelector((state) => {
let ancestorsIds = ImmutableOrderedSet<string>(); let ancestorsIds = new Set<string>();
let descendantsIds = ImmutableOrderedSet<string>(); let descendantsIds = new Set<string>();
if (status) { if (status) {
const statusId = status.id; const statusId = status.id;
ancestorsIds = getAncestorsIds(state, state.contexts.inReplyTos.get(statusId)); ancestorsIds = getAncestorsIds(state, state.contexts.inReplyTos.get(statusId));
descendantsIds = getDescendantsIds(state, statusId); descendantsIds = getDescendantsIds(state, statusId);
ancestorsIds = ancestorsIds.delete(statusId).subtract(descendantsIds);
descendantsIds = descendantsIds.delete(statusId).subtract(ancestorsIds); ancestorsIds.delete(statusId);
descendantsIds.forEach(ancestorsIds.delete);
descendantsIds.delete(statusId);
ancestorsIds.forEach(descendantsIds.delete);
} }
return { return {
status, status,
ancestorsIds, ancestorsIds: Array.from(ancestorsIds),
descendantsIds, descendantsIds: Array.from(descendantsIds),
}; };
}); });
let initialTopMostItemIndex = ancestorsIds.size; let initialTopMostItemIndex = ancestorsIds.length;
if (!useWindowScroll && initialTopMostItemIndex !== 0) initialTopMostItemIndex = ancestorsIds.size + 1; if (!useWindowScroll && initialTopMostItemIndex !== 0) initialTopMostItemIndex = ancestorsIds.length + 1;
const node = useRef<HTMLDivElement>(null); const node = useRef<HTMLDivElement>(null);
const statusRef = useRef<HTMLDivElement>(null); const statusRef = useRef<HTMLDivElement>(null);
@ -161,13 +163,13 @@ const Thread: React.FC<IThread> = ({
const handleMoveUp = (id: string) => { const handleMoveUp = (id: string) => {
if (id === status?.id) { if (id === status?.id) {
_selectChild(ancestorsIds.size - 1); _selectChild(ancestorsIds.length - 1);
} else { } else {
let index = ImmutableList(ancestorsIds).indexOf(id); let index = ancestorsIds.indexOf(id);
if (index === -1) { if (index === -1) {
index = ImmutableList(descendantsIds).indexOf(id); index = descendantsIds.indexOf(id);
_selectChild(ancestorsIds.size + index); _selectChild(ancestorsIds.length + index);
} else { } else {
_selectChild(index - 1); _selectChild(index - 1);
} }
@ -176,13 +178,13 @@ const Thread: React.FC<IThread> = ({
const handleMoveDown = (id: string) => { const handleMoveDown = (id: string) => {
if (id === status?.id) { if (id === status?.id) {
_selectChild(ancestorsIds.size + 1); _selectChild(ancestorsIds.length + 1);
} else { } else {
let index = ImmutableList(ancestorsIds).indexOf(id); let index = ancestorsIds.indexOf(id);
if (index === -1) { if (index === -1) {
index = ImmutableList(descendantsIds).indexOf(id); index = descendantsIds.indexOf(id);
_selectChild(ancestorsIds.size + index + 2); _selectChild(ancestorsIds.length + index + 2);
} else { } else {
_selectChild(index + 1); _selectChild(index + 1);
} }
@ -225,7 +227,7 @@ const Thread: React.FC<IThread> = ({
/> />
); );
const renderChildren = (list: ImmutableOrderedSet<string>) => list.map(id => { const renderChildren = (list: Array<string>) => list.map(id => {
if (id.endsWith('-tombstone')) { if (id.endsWith('-tombstone')) {
return renderTombstone(id); return renderTombstone(id);
} else { } else {
@ -235,15 +237,15 @@ const Thread: React.FC<IThread> = ({
// Scroll focused status into view when thread updates. // Scroll focused status into view when thread updates.
useEffect(() => { useEffect(() => {
virtualizer.current?.scrollToIndex(ancestorsIds.size); virtualizer.current?.scrollToIndex(ancestorsIds.length);
}, [status.id, ancestorsIds.size]); }, [status.id, ancestorsIds.length]);
const handleOpenCompareHistoryModal = (status: Pick<Status, 'id'>) => { const handleOpenCompareHistoryModal = (status: Pick<Status, 'id'>) => {
openModal('COMPARE_HISTORY', { statusId: status.id }); openModal('COMPARE_HISTORY', { statusId: status.id });
}; };
const hasAncestors = ancestorsIds.size > 0; const hasAncestors = ancestorsIds.length > 0;
const hasDescendants = descendantsIds.size > 0; const hasDescendants = descendantsIds.length > 0;
type HotkeyHandlers = { [key: string]: (keyEvent?: KeyboardEvent) => void }; type HotkeyHandlers = { [key: string]: (keyEvent?: KeyboardEvent) => void };
@ -291,7 +293,7 @@ const Thread: React.FC<IThread> = ({
} }
if (hasAncestors) { if (hasAncestors) {
children.push(...renderChildren(ancestorsIds).toArray()); children.push(...renderChildren(ancestorsIds));
} }
children.push(focusedStatus); children.push(focusedStatus);
@ -302,11 +304,11 @@ const Thread: React.FC<IThread> = ({
<FormattedMessage id='status.replies' defaultMessage='Replies' /> <FormattedMessage id='status.replies' defaultMessage='Replies' />
</p>, </p>,
); );
children.push(...renderChildren(descendantsIds).toArray()); children.push(...renderChildren(descendantsIds));
} }
if (hasDescendants) { if (hasDescendants) {
children.push(...renderChildren(descendantsIds).toArray()); children.push(...renderChildren(descendantsIds));
} }
return ( return (

View file

@ -1,4 +1,3 @@
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import { useStatus } from 'pl-hooks'; import { useStatus } from 'pl-hooks';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
@ -35,17 +34,18 @@ const StatusDetails: React.FC<IStatusDetails> = (props) => {
const isLoaded = statusQuery.isSuccess; const isLoaded = statusQuery.isSuccess;
const ancestorsIds = useAppSelector(state => { const ancestorsIds = useAppSelector(state => {
let ancestorsIds = ImmutableOrderedSet<string>(); let ancestorsIds = new Set<string>();
let descendantsIds = ImmutableOrderedSet<string>(); let descendantsIds = new Set<string>();
if (status) { if (status) {
const statusId = status.id; const statusId = status.id;
ancestorsIds = getAncestorsIds(state, state.contexts.inReplyTos.get(statusId)); ancestorsIds = getAncestorsIds(state, state.contexts.inReplyTos.get(statusId));
descendantsIds = getDescendantsIds(state, statusId); descendantsIds = getDescendantsIds(state, statusId);
ancestorsIds = ancestorsIds.delete(statusId).subtract(descendantsIds); ancestorsIds.delete(statusId);
descendantsIds.forEach(ancestorsIds.delete);
} }
return ancestorsIds; return Array.from(ancestorsIds);
}); });
/** Fetch the status (and context) from the API. */ /** Fetch the status (and context) from the API. */
@ -60,9 +60,9 @@ const StatusDetails: React.FC<IStatusDetails> = (props) => {
fetchData(); fetchData();
}, [props.params.statusId]); }, [props.params.statusId]);
if (ancestorsIds.size) { if (ancestorsIds.length) {
return ( return (
<Redirect to={`/@${status?.account.acct || '@'}/posts/${ancestorsIds.first()}`} /> <Redirect to={`/@${status?.account.acct || '@'}/posts/${ancestorsIds[0]}`} />
); );
} }

View file

@ -1,4 +1,3 @@
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import React from 'react'; import React from 'react';
import StatusList, { IStatusList } from 'bigbuffet/components/status-list'; import StatusList, { IStatusList } from 'bigbuffet/components/status-list';
@ -15,15 +14,13 @@ const Timeline: React.FC<ITimeline> = ({
onLoadMore, onLoadMore,
...rest ...rest
}) => { }) => {
const statusIds = useAppSelector(state => state.timelines.get(timelineId)?.items || ImmutableOrderedSet<string>()); const statusIds = useAppSelector(state => state.timelines[timelineId]?.items || []);
const lastStatusId = statusIds.last(); const isLoading = useAppSelector(state => (state.timelines[timelineId] || { isLoading: true }).isLoading === true);
const isLoading = useAppSelector(state => (state.timelines.get(timelineId) || { isLoading: true }).isLoading === true); const hasMore = useAppSelector(state => state.timelines[timelineId]?.hasMore === true);
const hasMore = useAppSelector(state => state.timelines.get(timelineId)?.hasMore === true);
return ( return (
<StatusList <StatusList
timelineId={timelineId} timelineId={timelineId}
lastStatusId={lastStatusId}
statusIds={statusIds} statusIds={statusIds}
isLoading={isLoading} isLoading={isLoading}
hasMore={hasMore} hasMore={hasMore}

View file

@ -21,7 +21,7 @@ type ContextStatus = {
} }
/** Import a single status into the reducer, setting replies and replyTos. */ /** Import a single status into the reducer, setting replies and replyTos. */
const importStatus = (state: State, status: ContextStatus, idempotencyKey?: string): State => { const importStatus = (state: State, status: ContextStatus): State => {
const { id, in_reply_to_id: inReplyToId } = status; const { id, in_reply_to_id: inReplyToId } = status;
if (!inReplyToId) return state; if (!inReplyToId) return state;

View file

@ -1,8 +1,4 @@
import { import { produce } from 'immer';
Map as ImmutableMap,
OrderedSet as ImmutableOrderedSet,
Record as ImmutableRecord,
} from 'immutable';
import { import {
TIMELINE_CLEAR, TIMELINE_CLEAR,
@ -14,36 +10,38 @@ import {
import type { PaginatedResponse, Status as BaseStatus } from 'pl-api'; import type { PaginatedResponse, Status as BaseStatus } from 'pl-api';
const TimelineRecord = ImmutableRecord({ interface Timeline {
isLoading: boolean;
hasMore: boolean;
next: (() => Promise<PaginatedResponse<BaseStatus>>) | null;
prev: (() => Promise<PaginatedResponse<BaseStatus>>) | null;
items: Array<string>;
}
const emptyTimeline: Timeline = {
isLoading: false, isLoading: false,
hasMore: true, hasMore: true,
next: null as (() => Promise<PaginatedResponse<BaseStatus>>) | null, next: null,
prev: null as (() => Promise<PaginatedResponse<BaseStatus>>) | null, prev: null,
items: ImmutableOrderedSet<string>(), items: [],
queuedItems: ImmutableOrderedSet<string>(), //max= MAX_QUEUED_ITEMS };
loadingFailed: false,
isPartial: false,
});
const initialState = ImmutableMap<string, Timeline>(); const initialState: State = {};
type State = ImmutableMap<string, Timeline>; type State = Record<string, Timeline>;
type Timeline = ReturnType<typeof TimelineRecord>;
const getStatusIds = (statuses: Array<Pick<BaseStatus, 'id'>> = []) => ( const getStatusIds = (statuses: Array<Pick<BaseStatus, 'id'>> = []) => (
ImmutableOrderedSet(statuses.map(status => status.id)) Array.from(new Set(statuses.map(status => status.id)))
); );
const mergeStatusIds = (oldIds = ImmutableOrderedSet<string>(), newIds = ImmutableOrderedSet<string>()) => ( const mergeStatusIds = (oldIds = new Array<string>(), newIds = new Array<string>()) => (
newIds.union(oldIds) Array.from(new Set(newIds).union(new Set(oldIds)))
); );
const setLoading = (state: State, timelineId: string, loading: boolean) => const setLoading = (state: State, timelineId: string, loading: boolean) => {
state.update(timelineId, TimelineRecord(), timeline => timeline.set('isLoading', loading)); if (!state[timelineId]) state[timelineId] = emptyTimeline;
state[timelineId].isLoading = loading;
// Keep track of when a timeline failed to load };
const setFailed = (state: State, timelineId: string, failed: boolean) =>
state.update(timelineId, TimelineRecord(), timeline => timeline.set('loadingFailed', failed));
const expandNormalizedTimeline = ( const expandNormalizedTimeline = (
state: State, state: State,
@ -51,37 +49,37 @@ const expandNormalizedTimeline = (
statuses: Array<BaseStatus>, statuses: Array<BaseStatus>,
next: (() => Promise<PaginatedResponse<BaseStatus>>) | null, next: (() => Promise<PaginatedResponse<BaseStatus>>) | null,
prev: (() => Promise<PaginatedResponse<BaseStatus>>) | null, prev: (() => Promise<PaginatedResponse<BaseStatus>>) | null,
isPartial: boolean,
isLoadingRecent: boolean, isLoadingRecent: boolean,
pos: 'start' | 'end' = 'end', pos: 'start' | 'end' = 'end',
) => { ) => {
const newIds = getStatusIds(statuses); const newIds = getStatusIds(statuses);
return state.update(timelineId, TimelineRecord(), timeline => timeline.withMutations(timeline => { if (!state[timelineId]) state[timelineId] = emptyTimeline;
timeline.set('isLoading', false);
timeline.set('loadingFailed', false);
timeline.set('isPartial', isPartial);
timeline.set('next', next);
timeline.set('prev', prev);
if (!next && !isLoadingRecent) timeline.set('hasMore', false); const timeline = state[timelineId];
// Pinned timelines can be replaced entirely timeline.isLoading = false;
if (timelineId.endsWith(':pinned')) { timeline.next = next;
timeline.set('items', newIds); timeline.prev = prev;
return;
if (!next && !isLoadingRecent) timeline.hasMore = false;
// Pinned timelines can be replaced entirely
if (timelineId.endsWith(':pinned')) {
timeline.items = newIds;
return;
}
if (newIds.length) {
let newItems: Array<string>;
if (pos === 'end') {
newItems = mergeStatusIds(newIds, timeline.items);
} else {
newItems = mergeStatusIds(timeline.items, newIds);
} }
if (!newIds.isEmpty()) { timeline.items = newItems;
timeline.update('items', oldIds => { }
if (pos === 'end') {
return mergeStatusIds(newIds, oldIds);
} else {
return mergeStatusIds(oldIds, newIds);
}
});
}
}));
}; };
// const shouldDelete = (timelineId: string, excludeAccount?: string | null) => { // const shouldDelete = (timelineId: string, excludeAccount?: string | null) => {
@ -106,35 +104,36 @@ const expandNormalizedTimeline = (
// }); // });
// }); // });
const clearTimeline = (state: State, timelineId: string) => state.set(timelineId, TimelineRecord()); const clearTimeline = (state: State, timelineId: string) => {
state[timelineId] = emptyTimeline;
return state;
};
const handleExpandFail = (state: State, timelineId: string) => const handleExpandFail = (state: State, timelineId: string) => {
state.withMutations(state => { setLoading(state, timelineId, false);
setLoading(state, timelineId, false); return state;
setFailed(state, timelineId, true); };
});
const timelines = (state: State = initialState, action: TimelineAction) => { const timelines = (state: State = initialState, action: TimelineAction) => produce(state, (draft) => {
switch (action.type) { switch (action.type) {
case TIMELINE_EXPAND_REQUEST: case TIMELINE_EXPAND_REQUEST:
return setLoading(state, action.timeline, true); return setLoading(draft, action.timeline, true);
case TIMELINE_EXPAND_FAIL: case TIMELINE_EXPAND_FAIL:
return handleExpandFail(state, action.timeline); return handleExpandFail(draft, action.timeline);
case TIMELINE_EXPAND_SUCCESS: case TIMELINE_EXPAND_SUCCESS:
return expandNormalizedTimeline( return expandNormalizedTimeline(
state, draft,
action.timeline, action.timeline,
action.statuses, action.statuses,
action.next, action.next,
action.prev, action.prev,
action.partial,
action.isLoadingRecent, action.isLoadingRecent,
); );
case TIMELINE_CLEAR: case TIMELINE_CLEAR:
return clearTimeline(state, action.timeline); return clearTimeline(draft, action.timeline);
default: default:
return state; return draft;
} }
}; });
export default timelines; export default timelines;