[Fix] Timeline jump (#247)

This commit is contained in:
Clovis 2023-08-12 18:54:01 +02:00 committed by GitHub
parent cdded423c0
commit 5b7cb4fd47
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 79 additions and 58 deletions

View File

@ -245,6 +245,7 @@ const expandTimelineSuccess = (timeline: string, statuses: APIEntity[], next: st
partial,
isLoadingRecent,
skipLoading: !isLoadingMore,
isLoadingMore,
});
const expandTimelineFail = (timeline: string, error: AxiosError, isLoadingMore: boolean) => ({

View File

@ -1,4 +1,3 @@
import classNames from 'classnames';
import throttle from 'lodash/throttle';
import React, { useState, useEffect, useCallback } from 'react';
import { useIntl, MessageDescriptor } from 'react-intl';
@ -31,38 +30,38 @@ const ScrollTopButton: React.FC<IScrollTopButton> = ({
const intl = useIntl();
const settings = useSettings();
const timer = React.useRef(null);
const [scrolled, setScrolled] = useState<boolean>(false);
const autoload = settings.get('autoloadTimelines') === true;
const visible = count > 0 && scrolled;
const classes = classNames('left-1/2 -translate-x-1/2 fixed top-20 z-50', {
'hidden': !visible,
});
const getScrollTop = (): number => {
const getScrollTop = React.useCallback((): number => {
return (document.scrollingElement || document.documentElement).scrollTop;
};
}, []);
const maybeUnload = () => {
if (autoload && getScrollTop() <= autoloadThreshold) {
onClick();
}
};
const maybeUnload = React.useCallback(() => {
// we need to add a timer since there is a delay between content render and
// scroll top calculation. Without it, new content is always loaded because
// scrollTop is 0 at first.
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => {
if (count > 0 && autoload && getScrollTop() <= autoloadThreshold) {
onClick();
}
timer.current = null;
}, 250);
}, [autoload, autoloadThreshold, onClick, count]);
const handleScroll = useCallback(throttle(() => {
maybeUnload();
if (getScrollTop() > threshold) {
setScrolled(true);
} else {
setScrolled(false);
}
}, 150, { trailing: true }), [autoload, threshold, autoloadThreshold, onClick]);
}, 150, { trailing: true }), [threshold]);
const scrollUp = () => {
const scrollUp = React.useCallback(() => {
window.scrollTo({ top: 0 });
};
}, []);
const handleClick: React.MouseEventHandler = () => {
setTimeout(scrollUp, 10);
@ -79,19 +78,23 @@ const ScrollTopButton: React.FC<IScrollTopButton> = ({
useEffect(() => {
maybeUnload();
}, [count]);
}, [maybeUnload]);
const visible = React.useMemo(() => count > 0 && scrolled, [count, scrolled]) ;
if (!visible) return null;
return (
<div className={classes}>
<a className='flex items-center bg-primary-600 hover:bg-primary-700 hover:scale-105 active:scale-100 transition-transform text-white rounded-full px-4 py-2 space-x-1.5 cursor-pointer whitespace-nowrap' onClick={handleClick}>
<div className='left-1/2 -translate-x-1/2 fixed top-20 z-50'>
<button
className='flex items-center bg-primary-600 hover:bg-primary-700 hover:scale-105 active:scale-100 transition-transform text-white rounded-full px-4 py-2 space-x-1.5 cursor-pointer whitespace-nowrap'
onClick={handleClick}
>
<Icon src={require('@tabler/icons/arrow-bar-to-up.svg')} />
{(count > 0) && (
<Text theme='inherit' size='sm'>
{intl.formatMessage(message, { count })}
</Text>
)}
</a>
<Text theme='inherit' size='sm'>
{intl.formatMessage(message, { count })}
</Text>
</button>
</div>
);
};

View File

@ -1,6 +1,7 @@
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import debounce from 'lodash/debounce';
import React, { useCallback } from 'react';
import { createPortal } from 'react-dom';
import { defineMessages } from 'react-intl';
import { dequeueTimeline, scrollTopTimeline } from 'soapbox/actions/timelines';
@ -53,13 +54,14 @@ const Timeline: React.FC<ITimeline> = ({
return (
<>
<ScrollTopButton
key='timeline-queue-button-header'
onClick={handleDequeueTimeline}
count={totalQueuedItemsCount}
message={messages.queue}
/>
{
createPortal(<ScrollTopButton
key='timeline-queue-button-header'
onClick={handleDequeueTimeline}
count={totalQueuedItemsCount}
message={messages.queue}
/>, document.body)
}
<StatusList
timelineId={timelineId}
onScrollToTop={handleScrollToTop}

View File

@ -89,32 +89,47 @@ const setFailed = (state: State, timelineId: string, failed: boolean) => {
return state.update(timelineId, TimelineRecord(), timeline => timeline.set('loadingFailed', failed));
};
const expandNormalizedTimeline = (state: State, timelineId: string, statuses: ImmutableList<ImmutableMap<string, any>>, next: string | null, isPartial: boolean, isLoadingRecent: boolean) => {
const newIds = getStatusIds(statuses);
const expandNormalizedTimeline = (state: State, timelineId: string, statuses: ImmutableList<ImmutableMap<string, any>>, next: string | null, isPartial: boolean, isLoadingRecent: boolean, isLoadingMore: boolean) => {
let newIds = getStatusIds(statuses);
let unseens = ImmutableOrderedSet<any>();
return state.update(timelineId, TimelineRecord(), timeline => timeline.withMutations(timeline => {
timeline.set('isLoading', false);
timeline.set('loadingFailed', false);
timeline.set('isPartial', isPartial);
return state.withMutations((s) => {
s.update(timelineId, TimelineRecord(), timeline => timeline.withMutations(timeline => {
timeline.set('isLoading', false);
timeline.set('loadingFailed', false);
timeline.set('isPartial', isPartial);
if (!next && !isLoadingRecent) timeline.set('hasMore', false);
if (!next && !isLoadingRecent) timeline.set('hasMore', false);
// Pinned timelines can be replaced entirely
if (timelineId.endsWith(':pinned')) {
timeline.set('items', newIds);
return;
}
// Pinned timelines can be replaced entirely
if (timelineId.endsWith(':pinned')) {
timeline.set('items', newIds);
return;
}
if (!newIds.isEmpty()) {
timeline.update('items', oldIds => {
if (newIds.first() > oldIds.first()!) {
return mergeStatusIds(oldIds, newIds);
} else {
return mergeStatusIds(newIds, oldIds);
if (!newIds.isEmpty()) {
// we need to sort between queue and actual list to avoid
// messing with user position in the timeline by inserting inseen statuses
unseens = ImmutableOrderedSet<any>();
if (!isLoadingMore
&& timeline.items.count() > 0
&& newIds.first() > timeline.items.first()
) {
unseens = newIds.subtract(timeline.items);
}
});
}
}));
newIds = newIds.subtract(unseens);
timeline.update('items', oldIds => {
if (newIds.first() > oldIds.first()!) {
return mergeStatusIds(oldIds, newIds);
} else {
return mergeStatusIds(newIds, oldIds);
}
});
}
}));
unseens.forEach((statusId) => s.set(timelineId, updateTimelineQueue(s, timelineId, statusId).get(timelineId)));
});
};
const updateTimeline = (state: State, timelineId: string, statusId: string) => {
@ -326,7 +341,7 @@ export default function timelines(state: State = initialState, action: AnyAction
case TIMELINE_EXPAND_FAIL:
return handleExpandFail(state, action.timeline);
case TIMELINE_EXPAND_SUCCESS:
return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses) as ImmutableList<ImmutableMap<string, any>>, action.next, action.partial, action.isLoadingRecent);
return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses) as ImmutableList<ImmutableMap<string, any>>, action.next, action.partial, action.isLoadingRecent, action.isLoadingMore);
case TIMELINE_UPDATE:
return updateTimeline(state, action.timeline, action.statusId);
case TIMELINE_UPDATE_QUEUE: