Skip to content

Commit

Permalink
enhance: Twitter-like new post scroll top button
Browse files Browse the repository at this point in the history
  • Loading branch information
null2264 committed Oct 29, 2023
1 parent 3e74602 commit bd84f09
Show file tree
Hide file tree
Showing 9 changed files with 113 additions and 28 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- Timeline: fix inconsistent padding
- UI: fixed legacy domain button rendered too big
- Profile: fixed 'Follows you' badge rendered as 'INVALID!'
- Timeline: re-design ScrollTopButton

## 2023.10.4
- UX: fetch account when mention component is rendering
Expand Down
19 changes: 18 additions & 1 deletion src/locales/en-US/app.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -185,4 +185,21 @@ account-Toast--unsubscribe-success = You have unsubscribed to this account
search-Input--placeholder = Search
search-Input--placeholder-attrs =
.placeholder = Search
.aria-label = Search
.aria-label = Search
# Timeline
timeline-ScrollToTop--new-posts =
{ $count ->
[one] { $count } New Post
*[other] { $count } New Posts
}
timeline-ScrollToTop--new-posts-legacy =
{ $count ->
[one] Click to see { $count } new post
*[other] Click to see { $count } new posts
}
timeline-ScrollToTop--new-notifications =
{ $count ->
[one] Click to see { $count } new notification
*[other] Click to see { $count } new notifications
}
7 changes: 6 additions & 1 deletion src/locales/id-ID/app.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,9 @@ account-StatusAction--unmute--MenuItem = { -icon }<wrapper>Berhenti membisukan @
search-Input--placeholder = Pencarian
search-Input--placeholder-attrs =
.placeholder = Pencarian
.aria-label = Pencarian
.aria-label = Pencarian
# Timeline
timeline-ScrollToTop--new-posts = { $count } Postingan Baru
timeline-ScrollToTop--new-posts-legacy = Klik untuk melihat { $count } postingan baru
timeline-ScrollToTop--new-notifications = Klik untuk melihat { $count } notifikasi baru
3 changes: 3 additions & 0 deletions src/soapbox/components/__tests__/scroll-top-button.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ describe('<ScrollTopButton />', () => {
onClick={() => {}}
count={0}
message={messages.queue}
type='status'
/>,
);
expect(screen.queryAllByRole('link')).toHaveLength(0);
Expand All @@ -26,6 +27,7 @@ describe('<ScrollTopButton />', () => {
onClick={() => {}}
count={1}
message={messages.queue}
type='status'
/>,
);
expect(screen.getByText('Click to see 1 new post')).toBeInTheDocument();
Expand All @@ -36,6 +38,7 @@ describe('<ScrollTopButton />', () => {
onClick={() => {}}
count={9999999}
message={messages.queue}
type='status'
/>,
);
expect(screen.getByText('Click to see 9999999 new posts')).toBeInTheDocument();
Expand Down
90 changes: 73 additions & 17 deletions src/soapbox/components/scroll-top-button.tsx
Original file line number Diff line number Diff line change
@@ -1,38 +1,72 @@
import { Localized } from '@fluent/react';
import { OrderedSet as ImmutableOrderedSet, List as ImmutableList } from 'immutable';
import throttle from 'lodash/throttle';
import React, { useState, useEffect, useCallback } from 'react';
import { useIntl, MessageDescriptor } from 'react-intl';
import { MessageDescriptor } from 'react-intl';

import Icon from 'soapbox/components/icon';
import { Text } from 'soapbox/components/ui';
import { useSettings } from 'soapbox/hooks';
import { Icon, Text } from 'soapbox/components/ui';
import { useAppSelector, useSettings } from 'soapbox/hooks';
import { makeGetStatus } from 'soapbox/selectors';
import { Status } from 'soapbox/types/entities';

interface IScrollTopButton {
/** Callback when clicked, and also when scrolled to the top. */
onClick: () => void
/** Number of unread items. */
count: number
/** Message to display in the button (should contain a `{count}` value). */
message: MessageDescriptor
/** Message to display in the button (should contain a `{count}` value).
*
* @deprecated Use fluent instead
*/
message?: MessageDescriptor
/** Distance from the top of the screen (scrolling down) before the button appears. */
threshold?: number
/** Distance from the top of the screen (scrolling up) before the action is triggered. */
autoloadThreshold?: number
timelineId?: string
queuedItems?: ImmutableOrderedSet<string>
type: 'status' | 'notification'
}

/** Floating new post counter above timelines, clicked to scroll to top. */
const ScrollTopButton: React.FC<IScrollTopButton> = ({
onClick,
count,
message,
threshold = 400,
autoloadThreshold = 50,
timelineId,
queuedItems,
type,
}) => {
const intl = useIntl();
const settings = useSettings();

const timer = React.useRef<NodeJS.Timeout | null>(null);
const [scrolled, setScrolled] = useState<boolean>(false);
const autoload = settings.get('autoloadTimelines') === true;
const [moreThanThree, setMoreThanThree] = useState<boolean>(false);

const getStatus = useCallback(makeGetStatus(), []);
const statuses = useAppSelector(state => {
const rt = ImmutableList<Status>();
if (!queuedItems || !timelineId || type !== 'status') return rt;

const knownAccount = new Array<string>();
return rt.withMutations(rt => (queuedItems as ImmutableOrderedSet<string>).forEach(id => {
if (rt.size >= 3) {
if (!moreThanThree) setMoreThanThree(true);
return;
}

const status = getStatus(state, { id, contextType: timelineId });

if (!status) return;

if (knownAccount.includes(status.account.id)) return;

knownAccount.push(status.account.id);
rt.push(status);
}));
});

const getScrollTop = React.useCallback((): number => {
return (document.scrollingElement || document.documentElement).scrollTop;
Expand Down Expand Up @@ -84,17 +118,39 @@ const ScrollTopButton: React.FC<IScrollTopButton> = ({

if (!visible) return null;

const className = 'flex cursor-pointer items-center space-x-1.5 whitespace-nowrap rounded-full bg-primary-600 px-3 py-1.5 text-white transition-transform hover:scale-105 hover:bg-primary-700 active:scale-100';

if (typeof queuedItems === 'undefined')
return (
<div className='fixed left-1/2 top-20 z-50 -translate-x-1/2'>
<button className={className} onClick={handleClick}>
<Icon src={require('@tabler/icons/arrow-up.svg')} size={18} />

<Localized id={type === 'status' ? 'timeline-ScrollToTop--new-posts-legacy' : 'timeline-ScrollToTop--new-notifications'} vars={{ count }}>
<Text theme='inherit' size='sm'>
{count}
</Text>
</Localized>
</button>
</div>
);

return (
<div className='fixed left-1/2 top-20 z-50 -translate-x-1/2'>
<button
className='flex cursor-pointer items-center space-x-1.5 whitespace-nowrap rounded-full bg-primary-600 px-4 py-2 text-white transition-transform hover:scale-105 hover:bg-primary-700 active:scale-100'
onClick={handleClick}
>
<Icon src={require('@tabler/icons/arrow-bar-to-up.svg')} />

<Text theme='inherit' size='sm'>
{intl.formatMessage(message, { count })}
</Text>
<button className={className} onClick={handleClick}>
<Icon src={require('@tabler/icons/arrow-up.svg')} size={18} />

<div className='flex'>
{statuses.map(status => (
<img src={status.account.avatar} key={status.id} className='h-7 w-7 rounded-full border border-solid border-primary-600 bg-black not-first:-ml-3 not-first:rtl:-mr-3 not-first:rtl:ml-0' alt={status.account.username} />
))}
</div>

<Localized id='timeline-ScrollToTop--new-posts' vars={{ count }}>
<Text theme='inherit' size='sm'>
{count} New Post(s)
</Text>
</Localized>
</button>
</div>
);
Expand Down
3 changes: 1 addition & 2 deletions src/soapbox/features/notifications/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import type { Notification as NotificationEntity } from 'soapbox/types/entities'

const messages = defineMessages({
title: { id: 'column.notifications', defaultMessage: 'Notifications' },
queue: { id: 'notifications.queue_label', defaultMessage: 'Click to see {count} new {count, plural, one {notification} other {notifications}}' },
});

const getNotifications = createSelector([
Expand Down Expand Up @@ -179,7 +178,7 @@ const Notifications = () => {
<ScrollTopButton
onClick={handleDequeueNotifications}
count={totalQueuedNotificationsCount}
message={messages.queue}
type='notification'
/>
<PullToRefresh onRefresh={handleRefresh}>
{scrollContainer}
Expand Down
10 changes: 4 additions & 6 deletions src/soapbox/features/ui/components/timeline.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import debounce from 'lodash/debounce';
import React, { useCallback } from 'react';
import { defineMessages } from 'react-intl';

import { dequeueTimeline, scrollTopTimeline } from 'soapbox/actions/timelines';
import ScrollTopButton from 'soapbox/components/scroll-top-button';
Expand All @@ -10,10 +9,6 @@ import { Portal } from 'soapbox/components/ui';
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
import { makeGetStatusIds } from 'soapbox/selectors';

const messages = defineMessages({
queue: { id: 'status_list.queue_label', defaultMessage: 'Click to see {count} new {count, plural, one {post} other {posts}}' },
});

interface ITimeline extends Omit<IStatusList, 'statusIds' | 'isLoading' | 'hasMore'> {
/** ID of the timeline in Redux. */
timelineId: string
Expand All @@ -37,6 +32,7 @@ const Timeline: React.FC<ITimeline> = ({
const isPartial = useAppSelector(state => (state.timelines.get(timelineId)?.isPartial || false) === true);
const hasMore = useAppSelector(state => state.timelines.get(timelineId)?.hasMore === true);
const totalQueuedItemsCount = useAppSelector(state => state.timelines.get(timelineId)?.totalQueuedItemsCount || 0);
const queuedItems = useAppSelector(state => state.timelines.get(timelineId)?.queuedItems || ImmutableOrderedSet<string>());
const isFilteringFeed = useAppSelector(state => !!state.timelines.get(timelineId)?.feedAccountId);

const handleDequeueTimeline = () => {
Expand All @@ -62,7 +58,9 @@ const Timeline: React.FC<ITimeline> = ({
key='timeline-queue-button-header'
onClick={handleDequeueTimeline}
count={totalQueuedItemsCount}
message={messages.queue}
timelineId={timelineId}
queuedItems={queuedItems}
type='status'
/>
</Portal>

Expand Down
2 changes: 1 addition & 1 deletion src/soapbox/reducers/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ const ReducerRecord = ImmutableRecord({

type State = ReturnType<typeof ReducerRecord>;
type NotificationRecord = ReturnType<typeof normalizeNotification>;
type QueuedNotification = ReturnType<typeof QueuedNotificationRecord>;
export type QueuedNotification = ReturnType<typeof QueuedNotificationRecord>;

const parseId = (id: string | number) => parseInt(id as string, 10);

Expand Down
6 changes: 6 additions & 0 deletions tailwind.config.cjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const plugin = require('tailwindcss/plugin');

const { parseColorMatrix } = require('./tailwind/colors.cjs');

/** @type {import('tailwindcss').Config} */
Expand Down Expand Up @@ -101,5 +103,9 @@ module.exports = {
require('@tailwindcss/forms'),
require('@tailwindcss/typography'),
require('@tailwindcss/aspect-ratio'),
plugin(({ addVariant }) => {
addVariant('not-first', '&:not(:first-child)');
addVariant('not-last', '&:not(:last-child)');
}),
],
};

0 comments on commit bd84f09

Please sign in to comment.