Merge branch 'update-notification-icon' into 'main'

Remove "ScrollTopButton" from timeline

Closes #1811

See merge request soapbox-pub/soapbox!3321
merge-requests/3325/head
Alex Gleason 2025-02-04 00:13:19 +00:00
commit 18523c4e77
5 zmienionych plików z 88 dodań i 22 usunięć

Wyświetl plik

@ -116,6 +116,8 @@ export type Item = {
count?: number; count?: number;
/** Unique name for this tab. */ /** Unique name for this tab. */
name: string; name: string;
/** Display a notificationicon over the tab */
notification?: boolean;
} }
interface ITabs { interface ITabs {
@ -142,7 +144,7 @@ const Tabs = ({ items, activeItem }: ITabs) => {
}; };
const renderItem = (item: Item, idx: number) => { const renderItem = (item: Item, idx: number) => {
const { name, text, title, count } = item; const { name, text, title, count, notification } = item;
return ( return (
<AnimatedTab <AnimatedTab
@ -160,7 +162,10 @@ const Tabs = ({ items, activeItem }: ITabs) => {
</span> </span>
) : null} ) : null}
<div className='relative flex items-center justify-center gap-1.5'>
{text} {text}
{notification && <div className='absolute -right-4 size-2 animate-pulse rounded-full bg-primary-500' />}
</div>
</div> </div>
</AnimatedTab> </AnimatedTab>
); );

Wyświetl plik

@ -1,20 +1,14 @@
import { debounce } from 'es-toolkit'; import { debounce } from 'es-toolkit';
import { OrderedSet as ImmutableOrderedSet } from 'immutable'; import { OrderedSet as ImmutableOrderedSet } from 'immutable';
import { useCallback } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { defineMessages } from 'react-intl';
import { dequeueTimeline, scrollTopTimeline } from 'soapbox/actions/timelines.ts'; 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 StatusList, { IStatusList } from 'soapbox/components/status-list.tsx';
import Portal from 'soapbox/components/ui/portal.tsx';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts'; import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { setNotification } from 'soapbox/reducers/notificationsSlice.ts';
import { makeGetStatusIds } from 'soapbox/selectors/index.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<IStatusList, 'statusIds' | 'isLoading' | 'hasMore'> { interface ITimeline extends Omit<IStatusList, 'statusIds' | 'isLoading' | 'hasMore'> {
/** ID of the timeline in Redux. */ /** ID of the timeline in Redux. */
timelineId: string; timelineId: string;
@ -37,7 +31,11 @@ const Timeline: React.FC<ITimeline> = ({
const isLoading = useAppSelector(state => (state.timelines.get(timelineId) || { isLoading: true }).isLoading === true); const isLoading = useAppSelector(state => (state.timelines.get(timelineId) || { isLoading: true }).isLoading === true);
const isPartial = useAppSelector(state => (state.timelines.get(timelineId)?.isPartial || false) === true); const isPartial = useAppSelector(state => (state.timelines.get(timelineId)?.isPartial || false) === true);
const hasMore = useAppSelector(state => state.timelines.get(timelineId)?.hasMore === 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<boolean>(window.scrollY < 50);
const [intervalId, setIntervalId] = useState<NodeJS.Timeout | null>(null);
const handleDequeueTimeline = useCallback(() => { const handleDequeueTimeline = useCallback(() => {
dispatch(dequeueTimeline(timelineId, onLoadMore)); dispatch(dequeueTimeline(timelineId, onLoadMore));
@ -48,20 +46,42 @@ const Timeline: React.FC<ITimeline> = ({
}, 100), [timelineId]); }, 100), [timelineId]);
const handleScroll = useCallback(debounce(() => { const handleScroll = useCallback(debounce(() => {
setIsInTop(window.scrollY < 50);
dispatch(scrollTopTimeline(timelineId, false)); dispatch(scrollTopTimeline(timelineId, false));
}, 100), [timelineId]); }, 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 ( return (
<> <>
<Portal>
<ScrollTopButton
key='timeline-queue-button-header'
onClick={handleDequeueTimeline}
count={totalQueuedItemsCount}
message={messages.queue}
/>
</Portal>
<StatusList <StatusList
timelineId={timelineId} timelineId={timelineId}
onScrollToTop={handleScrollToTop} onScrollToTop={handleScrollToTop}

Wyświetl plik

@ -1,6 +1,7 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { useRef } from 'react'; import { useRef } from 'react';
import { FormattedMessage, useIntl } from 'react-intl'; import { FormattedMessage, useIntl } from 'react-intl';
import { useSelector } from 'react-redux';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { uploadCompose } from 'soapbox/actions/compose.ts'; import { uploadCompose } from 'soapbox/actions/compose.ts';
@ -30,6 +31,7 @@ import { useInstance } from 'soapbox/hooks/useInstance.ts';
import { useIsMobile } from 'soapbox/hooks/useIsMobile.ts'; import { useIsMobile } from 'soapbox/hooks/useIsMobile.ts';
import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts'; import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts';
import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts'; import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts';
import { RootState } from 'soapbox/store.ts';
import ComposeForm from '../features/compose/components/compose-form.tsx'; import ComposeForm from '../features/compose/components/compose-form.tsx';
@ -41,6 +43,7 @@ const HomePage: React.FC<IHomePage> = ({ children }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { pathname } = useLocation(); const { pathname } = useLocation();
const notifications = useSelector((state: RootState) => state.notificationsTab);
const me = useAppSelector(state => state.me); const me = useAppSelector(state => state.me);
const { account } = useOwnAccount(); const { account } = useOwnAccount();
@ -105,8 +108,8 @@ const HomePage: React.FC<IHomePage> = ({ children }) => {
<div className='sticky top-12 z-20 bg-white/90 backdrop-blur black:bg-black/90 dark:bg-primary-900/90 lg:top-0'> <div className='sticky top-12 z-20 bg-white/90 backdrop-blur black:bg-black/90 dark:bg-primary-900/90 lg:top-0'>
<Tabs <Tabs
items={[ items={[
{ name: 'home', text: <FormattedMessage id='tabs_bar.home' defaultMessage='Home' />, to: '/' }, { name: 'home', text: <FormattedMessage id='tabs_bar.home' defaultMessage='Home' />, to: '/', notification: notifications.home },
{ name: 'local', text: <div className='block max-w-xs truncate'>{instance.domain}</div>, to: '/timeline/local' }, { name: 'local', text: <div className='block max-w-xs truncate'>{instance.domain}</div>, to: '/timeline/local', notification: notifications.instance },
]} ]}
activeItem={pathname === '/timeline/local' ? 'local' : 'home'} activeItem={pathname === '/timeline/local' ? 'local' : 'home'}
/> />

Wyświetl plik

@ -32,6 +32,7 @@ import meta from './meta.ts';
import modals from './modals.ts'; import modals from './modals.ts';
import mutes from './mutes.ts'; import mutes from './mutes.ts';
import notifications from './notifications.ts'; import notifications from './notifications.ts';
import notificationsTab from './notificationsSlice.ts';
import onboarding from './onboarding.ts'; import onboarding from './onboarding.ts';
import patron from './patron.ts'; import patron from './patron.ts';
import pending_statuses from './pending-statuses.ts'; import pending_statuses from './pending-statuses.ts';
@ -87,6 +88,7 @@ export default combineReducers({
modals, modals,
mutes, mutes,
notifications, notifications,
notificationsTab,
onboarding, onboarding,
patron, patron,
pending_statuses, pending_statuses,

Wyświetl plik

@ -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;