From bd84f09f102ab115acf26f474d28f87c276ed7b2 Mon Sep 17 00:00:00 2001 From: Ahmad Ansori Palembani Date: Sun, 29 Oct 2023 07:01:20 +0000 Subject: [PATCH] enhance: Twitter-like new post scroll top button --- CHANGELOG.md | 1 + src/locales/en-US/app.ftl | 19 +++- src/locales/id-ID/app.ftl | 7 +- .../__tests__/scroll-top-button.test.tsx | 3 + src/soapbox/components/scroll-top-button.tsx | 90 +++++++++++++++---- src/soapbox/features/notifications/index.tsx | 3 +- .../features/ui/components/timeline.tsx | 10 +-- src/soapbox/reducers/notifications.ts | 2 +- tailwind.config.cjs | 6 ++ 9 files changed, 113 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d3379a68..35ecd2b12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/locales/en-US/app.ftl b/src/locales/en-US/app.ftl index f6c71bff2..32e686d75 100644 --- a/src/locales/en-US/app.ftl +++ b/src/locales/en-US/app.ftl @@ -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 \ No newline at end of file + .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 + } \ No newline at end of file diff --git a/src/locales/id-ID/app.ftl b/src/locales/id-ID/app.ftl index 40f263dad..bc265f2f6 100644 --- a/src/locales/id-ID/app.ftl +++ b/src/locales/id-ID/app.ftl @@ -125,4 +125,9 @@ account-StatusAction--unmute--MenuItem = { -icon }Berhenti membisukan @ search-Input--placeholder = Pencarian search-Input--placeholder-attrs = .placeholder = Pencarian - .aria-label = Pencarian \ No newline at end of file + .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 \ No newline at end of file diff --git a/src/soapbox/components/__tests__/scroll-top-button.test.tsx b/src/soapbox/components/__tests__/scroll-top-button.test.tsx index 76e6b7c1c..bb7ae9b72 100644 --- a/src/soapbox/components/__tests__/scroll-top-button.test.tsx +++ b/src/soapbox/components/__tests__/scroll-top-button.test.tsx @@ -16,6 +16,7 @@ describe('', () => { onClick={() => {}} count={0} message={messages.queue} + type='status' />, ); expect(screen.queryAllByRole('link')).toHaveLength(0); @@ -26,6 +27,7 @@ describe('', () => { onClick={() => {}} count={1} message={messages.queue} + type='status' />, ); expect(screen.getByText('Click to see 1 new post')).toBeInTheDocument(); @@ -36,6 +38,7 @@ describe('', () => { onClick={() => {}} count={9999999} message={messages.queue} + type='status' />, ); expect(screen.getByText('Click to see 9999999 new posts')).toBeInTheDocument(); diff --git a/src/soapbox/components/scroll-top-button.tsx b/src/soapbox/components/scroll-top-button.tsx index 4a163b5e1..0bc0d93cd 100644 --- a/src/soapbox/components/scroll-top-button.tsx +++ b/src/soapbox/components/scroll-top-button.tsx @@ -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 + type: 'status' | 'notification' } /** Floating new post counter above timelines, clicked to scroll to top. */ const ScrollTopButton: React.FC = ({ onClick, count, - message, threshold = 400, autoloadThreshold = 50, + timelineId, + queuedItems, + type, }) => { - const intl = useIntl(); const settings = useSettings(); const timer = React.useRef(null); const [scrolled, setScrolled] = useState(false); const autoload = settings.get('autoloadTimelines') === true; + const [moreThanThree, setMoreThanThree] = useState(false); + + const getStatus = useCallback(makeGetStatus(), []); + const statuses = useAppSelector(state => { + const rt = ImmutableList(); + if (!queuedItems || !timelineId || type !== 'status') return rt; + + const knownAccount = new Array(); + return rt.withMutations(rt => (queuedItems as ImmutableOrderedSet).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; @@ -84,17 +118,39 @@ const ScrollTopButton: React.FC = ({ 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 ( +
+ +
+ ); + return (
-
); diff --git a/src/soapbox/features/notifications/index.tsx b/src/soapbox/features/notifications/index.tsx index 794073330..561cc756b 100644 --- a/src/soapbox/features/notifications/index.tsx +++ b/src/soapbox/features/notifications/index.tsx @@ -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([ @@ -179,7 +178,7 @@ const Notifications = () => { {scrollContainer} diff --git a/src/soapbox/features/ui/components/timeline.tsx b/src/soapbox/features/ui/components/timeline.tsx index 75ffd347e..c78749b61 100644 --- a/src/soapbox/features/ui/components/timeline.tsx +++ b/src/soapbox/features/ui/components/timeline.tsx @@ -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'; @@ -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 { /** ID of the timeline in Redux. */ timelineId: string @@ -37,6 +32,7 @@ const Timeline: React.FC = ({ 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()); const isFilteringFeed = useAppSelector(state => !!state.timelines.get(timelineId)?.feedAccountId); const handleDequeueTimeline = () => { @@ -62,7 +58,9 @@ const Timeline: React.FC = ({ key='timeline-queue-button-header' onClick={handleDequeueTimeline} count={totalQueuedItemsCount} - message={messages.queue} + timelineId={timelineId} + queuedItems={queuedItems} + type='status' /> diff --git a/src/soapbox/reducers/notifications.ts b/src/soapbox/reducers/notifications.ts index f2147b2cf..6f32dc7d5 100644 --- a/src/soapbox/reducers/notifications.ts +++ b/src/soapbox/reducers/notifications.ts @@ -56,7 +56,7 @@ const ReducerRecord = ImmutableRecord({ type State = ReturnType; type NotificationRecord = ReturnType; -type QueuedNotification = ReturnType; +export type QueuedNotification = ReturnType; const parseId = (id: string | number) => parseInt(id as string, 10); diff --git a/tailwind.config.cjs b/tailwind.config.cjs index bad172bc9..619b2ff54 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -1,3 +1,5 @@ +const plugin = require('tailwindcss/plugin'); + const { parseColorMatrix } = require('./tailwind/colors.cjs'); /** @type {import('tailwindcss').Config} */ @@ -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)'); + }), ], };