diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index e3a1c11c1..f76baeb49 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -116,6 +116,8 @@ export type Item = { count?: number; /** Unique name for this tab. */ name: string; + /** Display a notificationicon over the tab */ + notification?: boolean; } interface ITabs { @@ -142,7 +144,7 @@ const Tabs = ({ items, activeItem }: ITabs) => { }; const renderItem = (item: Item, idx: number) => { - const { name, text, title, count } = item; + const { name, text, title, count, notification } = item; return ( { ) : null} - {text} +
+ {text} + {notification &&
} +
); diff --git a/src/features/ui/components/timeline.tsx b/src/features/ui/components/timeline.tsx index 07712bbff..f6dfe4cf1 100644 --- a/src/features/ui/components/timeline.tsx +++ b/src/features/ui/components/timeline.tsx @@ -1,20 +1,14 @@ import { debounce } from 'es-toolkit'; import { OrderedSet as ImmutableOrderedSet } from 'immutable'; -import { useCallback } from 'react'; -import { defineMessages } from 'react-intl'; +import { useCallback, useEffect, useState } from 'react'; import { dequeueTimeline, scrollTopTimeline } from 'soapbox/actions/timelines.ts'; -import ScrollTopButton from 'soapbox/components/scroll-top-button.tsx'; import StatusList, { IStatusList } from 'soapbox/components/status-list.tsx'; -import Portal from 'soapbox/components/ui/portal.tsx'; import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; +import { setNotification } from 'soapbox/reducers/notificationsSlice.ts'; import { makeGetStatusIds } from 'soapbox/selectors/index.ts'; -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,7 +31,11 @@ const Timeline: React.FC = ({ const isLoading = useAppSelector(state => (state.timelines.get(timelineId) || { isLoading: true }).isLoading === true); 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 hasQueuedItems = useAppSelector(state => state.timelines.get(timelineId)?.totalQueuedItemsCount || 0); + + const [isInTop, setIsInTop] = useState(window.scrollY < 50); + const [intervalId, setIntervalId] = useState(null); + const handleDequeueTimeline = useCallback(() => { dispatch(dequeueTimeline(timelineId, onLoadMore)); @@ -48,20 +46,42 @@ const Timeline: React.FC = ({ }, 100), [timelineId]); const handleScroll = useCallback(debounce(() => { + setIsInTop(window.scrollY < 50); dispatch(scrollTopTimeline(timelineId, false)); }, 100), [timelineId]); + useEffect(() => { + if (hasQueuedItems) { + dispatch(setNotification({ timelineId: timelineId, value: hasQueuedItems > 0 })); + } + }, [hasQueuedItems, timelineId]); + + useEffect(() => { + if (isInTop) { + handleDequeueTimeline(); + const interval = setInterval(handleDequeueTimeline, 2000); + setIntervalId(interval); + dispatch(setNotification({ timelineId: timelineId, value: false })); + + } else if (intervalId) { + clearInterval(intervalId); + setIntervalId(null); + } + + return () => { + if (intervalId) clearInterval(intervalId); + }; + }, [isInTop, handleDequeueTimeline]); + + useEffect(() => { + window.addEventListener('scroll', handleScroll); + return () => { + window.removeEventListener('scroll', handleScroll); + }; + }, [handleScroll]); + return ( <> - - - - = ({ children }) => { const intl = useIntl(); const dispatch = useAppDispatch(); const { pathname } = useLocation(); + const notifications = useSelector((state: RootState) => state.notificationsTab); const me = useAppSelector(state => state.me); const { account } = useOwnAccount(); @@ -105,8 +108,8 @@ const HomePage: React.FC = ({ children }) => {
, to: '/' }, - { name: 'local', text:
{instance.domain}
, to: '/timeline/local' }, + { name: 'home', text: , to: '/', notification: notifications.home }, + { name: 'local', text:
{instance.domain}
, to: '/timeline/local', notification: notifications.instance }, ]} activeItem={pathname === '/timeline/local' ? 'local' : 'home'} /> diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 21fae33b3..e57877462 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -32,6 +32,7 @@ import meta from './meta.ts'; import modals from './modals.ts'; import mutes from './mutes.ts'; import notifications from './notifications.ts'; +import notificationsTab from './notificationsSlice.ts'; import onboarding from './onboarding.ts'; import patron from './patron.ts'; import pending_statuses from './pending-statuses.ts'; @@ -87,6 +88,7 @@ export default combineReducers({ modals, mutes, notifications, + notificationsTab, onboarding, patron, pending_statuses, diff --git a/src/reducers/notificationsSlice.ts b/src/reducers/notificationsSlice.ts new file mode 100644 index 000000000..f62eb5d78 --- /dev/null +++ b/src/reducers/notificationsSlice.ts @@ -0,0 +1,36 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +interface NotificationState { + home: boolean; + public: boolean; + instance: boolean; +} + +const initialState: NotificationState = { + home: false, + public: false, + instance: false, +}; + +const notificationsTab = createSlice({ + name: 'notificationsSlice', + initialState, + reducers: { + setNotification: ( + state, + action: PayloadAction<{ timelineId: string; value: boolean }>, + ) => { + if (action.payload.timelineId in state) { + state[action.payload.timelineId as keyof NotificationState] = action.payload.value; + } + }, + resetNotifications: (state) => { + state.home = false; + state.public = false; + state.instance = false; + }, + }, +}); + +export const { setNotification, resetNotifications } = notificationsTab.actions; +export default notificationsTab.reducer; \ No newline at end of file