From 0279a52216b789a759f4f8556827cfb316eaeb6d Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 10 Feb 2026 18:20:02 +0100 Subject: [PATCH] Profile redesign: Account filter fixes (#37811) --- .../account_timeline/hooks/useFilters.ts | 28 ------ .../features/account_timeline/v2/context.tsx | 97 +++++++++++++++++++ .../account_timeline/v2/featured_tags.tsx | 5 +- .../features/account_timeline/v2/filters.tsx | 5 +- .../features/account_timeline/v2/index.tsx | 9 +- .../account_timeline/v2/pinned_statuses.tsx | 50 +--------- app/javascript/mastodon/hooks/useStorage.ts | 64 ++++++++++++ 7 files changed, 174 insertions(+), 84 deletions(-) delete mode 100644 app/javascript/mastodon/features/account_timeline/hooks/useFilters.ts create mode 100644 app/javascript/mastodon/features/account_timeline/v2/context.tsx create mode 100644 app/javascript/mastodon/hooks/useStorage.ts diff --git a/app/javascript/mastodon/features/account_timeline/hooks/useFilters.ts b/app/javascript/mastodon/features/account_timeline/hooks/useFilters.ts deleted file mode 100644 index d979d895ac9..00000000000 --- a/app/javascript/mastodon/features/account_timeline/hooks/useFilters.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useCallback } from 'react'; - -import { useSearchParam } from '@/mastodon/hooks/useSearchParam'; - -export function useFilters() { - const [boosts, setBoosts] = useSearchParam('boosts'); - const [replies, setReplies] = useSearchParam('replies'); - - const handleSetBoosts = useCallback( - (value: boolean) => { - setBoosts(value ? '1' : null); - }, - [setBoosts], - ); - const handleSetReplies = useCallback( - (value: boolean) => { - setReplies(value ? '1' : null); - }, - [setReplies], - ); - - return { - boosts: boosts === '1', - replies: replies === '1', - setBoosts: handleSetBoosts, - setReplies: handleSetReplies, - }; -} diff --git a/app/javascript/mastodon/features/account_timeline/v2/context.tsx b/app/javascript/mastodon/features/account_timeline/v2/context.tsx new file mode 100644 index 00000000000..d0d1332c2d1 --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/v2/context.tsx @@ -0,0 +1,97 @@ +import type { FC, ReactNode } from 'react'; +import { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; + +import { useStorage } from '@/mastodon/hooks/useStorage'; + +interface AccountTimelineContextValue { + accountId: string; + boosts: boolean; + replies: boolean; + showAllPinned: boolean; + setBoosts: (value: boolean) => void; + setReplies: (value: boolean) => void; + onShowAllPinned: () => void; +} + +const AccountTimelineContext = + createContext(null); + +export const AccountTimelineProvider: FC<{ + accountId: string; + children: ReactNode; +}> = ({ accountId, children }) => { + const { getItem, setItem } = useStorage({ + type: 'session', + prefix: `filters-${accountId}:`, + }); + const [boosts, setBoosts] = useState( + () => (getItem('boosts') === '0' ? false : true), // Default to enabled. + ); + const [replies, setReplies] = useState(() => + getItem('replies') === '1' ? true : false, + ); + + const handleSetBoosts = useCallback( + (value: boolean) => { + setBoosts(value); + setItem('boosts', value ? '1' : '0'); + }, + [setBoosts, setItem], + ); + const handleSetReplies = useCallback( + (value: boolean) => { + setReplies(value); + setItem('replies', value ? '1' : '0'); + }, + [setReplies, setItem], + ); + + const [showAllPinned, setShowAllPinned] = useState(false); + const handleShowAllPinned = useCallback(() => { + setShowAllPinned(true); + }, []); + + // Memoize the context value to avoid unnecessary re-renders. + const value = useMemo( + () => ({ + accountId, + boosts, + replies, + showAllPinned, + setBoosts: handleSetBoosts, + setReplies: handleSetReplies, + onShowAllPinned: handleShowAllPinned, + }), + [ + accountId, + boosts, + handleSetBoosts, + handleSetReplies, + handleShowAllPinned, + replies, + showAllPinned, + ], + ); + + return ( + + {children} + + ); +}; + +export function useAccountContext() { + const values = useContext(AccountTimelineContext); + if (!values) { + throw new Error( + 'useAccountFilters must be used within an AccountTimelineProvider', + ); + } + return values; +} diff --git a/app/javascript/mastodon/features/account_timeline/v2/featured_tags.tsx b/app/javascript/mastodon/features/account_timeline/v2/featured_tags.tsx index 48ce9cfaa5f..b8061fced43 100644 --- a/app/javascript/mastodon/features/account_timeline/v2/featured_tags.tsx +++ b/app/javascript/mastodon/features/account_timeline/v2/featured_tags.tsx @@ -13,8 +13,7 @@ import { useOverflowButton } from '@/mastodon/hooks/useOverflow'; import { selectAccountFeaturedTags } from '@/mastodon/selectors/accounts'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; -import { useFilters } from '../hooks/useFilters'; - +import { useAccountContext } from './context'; import classes from './styles.module.scss'; export const FeaturedTags: FC<{ accountId: string }> = ({ accountId }) => { @@ -83,7 +82,7 @@ export const FeaturedTags: FC<{ accountId: string }> = ({ accountId }) => { function useTagNavigate() { // Get current account, tag, and filters. const { acct, tagged } = useParams<{ acct: string; tagged?: string }>(); - const { boosts, replies } = useFilters(); + const { boosts, replies } = useAccountContext(); const history = useAppHistory(); diff --git a/app/javascript/mastodon/features/account_timeline/v2/filters.tsx b/app/javascript/mastodon/features/account_timeline/v2/filters.tsx index d9adec13fac..28dcb5f5c47 100644 --- a/app/javascript/mastodon/features/account_timeline/v2/filters.tsx +++ b/app/javascript/mastodon/features/account_timeline/v2/filters.tsx @@ -12,8 +12,8 @@ import { Icon } from '@/mastodon/components/icon'; import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react'; import { AccountTabs } from '../components/tabs'; -import { useFilters } from '../hooks/useFilters'; +import { useAccountContext } from './context'; import classes from './styles.module.scss'; export const AccountFilters: FC = () => { @@ -42,7 +42,7 @@ const FilterDropdown: FC = () => { setOpen(false); }, []); - const { boosts, replies, setBoosts, setReplies } = useFilters(); + const { boosts, replies, setBoosts, setReplies } = useAccountContext(); const handleChange: ChangeEventHandler = useCallback( (event) => { const { name, checked } = event.target; @@ -101,7 +101,6 @@ const FilterDropdown: FC = () => { = ({ multiColumn }) => { // Add this key to remount the timeline when accountId changes. return ( - + - + ); }; @@ -71,7 +70,7 @@ const InnerTimeline: FC<{ accountId: string; multiColumn: boolean }> = ({ multiColumn, }) => { const { tagged } = useParams<{ tagged?: string }>(); - const { boosts, replies } = useFilters(); + const { boosts, replies } = useAccountContext(); const key = timelineKey({ type: 'account', userId: accountId, diff --git a/app/javascript/mastodon/features/account_timeline/v2/pinned_statuses.tsx b/app/javascript/mastodon/features/account_timeline/v2/pinned_statuses.tsx index eec92cdc380..7a8523c9de5 100644 --- a/app/javascript/mastodon/features/account_timeline/v2/pinned_statuses.tsx +++ b/app/javascript/mastodon/features/account_timeline/v2/pinned_statuses.tsx @@ -1,12 +1,5 @@ -import type { FC, ReactNode } from 'react'; -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from 'react'; +import type { FC } from 'react'; +import { useEffect, useMemo } from 'react'; import { FormattedMessage } from 'react-intl'; @@ -28,42 +21,9 @@ import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import { isRedesignEnabled } from '../common'; import { PinnedBadge } from '../components/badges'; +import { useAccountContext } from './context'; import classes from './styles.module.scss'; -const PinnedStatusContext = createContext<{ - showAllPinned: boolean; - onShowAllPinned: () => void; -}>({ - showAllPinned: false, - onShowAllPinned: () => { - throw new Error('No onShowAllPinned provided'); - }, -}); - -export const PinnedStatusProvider: FC<{ children: ReactNode }> = ({ - children, -}) => { - const [showAllPinned, setShowAllPinned] = useState(false); - const handleShowAllPinned = useCallback(() => { - setShowAllPinned(true); - }, []); - - // Memoize so the context doesn't change every render. - const value = useMemo( - () => ({ - showAllPinned, - onShowAllPinned: handleShowAllPinned, - }), - [handleShowAllPinned, showAllPinned], - ); - - return ( - - {children} - - ); -}; - export function usePinnedStatusIds({ accountId, tagged, @@ -89,7 +49,7 @@ export function usePinnedStatusIds({ selectTimelineByKey(state, pinnedKey), ); - const { showAllPinned } = useContext(PinnedStatusContext); + const { showAllPinned } = useAccountContext(); const pinnedTimelineItems = pinnedTimeline?.items; // Make a const to avoid the React Compiler complaining. const pinnedStatusIds = useMemo(() => { @@ -125,7 +85,7 @@ export const renderPinnedStatusHeader: StatusHeaderRenderFn = ({ }; export const PinnedShowAllButton: FC = () => { - const { onShowAllPinned } = useContext(PinnedStatusContext); + const { onShowAllPinned } = useAccountContext(); if (!isRedesignEnabled()) { return null; diff --git a/app/javascript/mastodon/hooks/useStorage.ts b/app/javascript/mastodon/hooks/useStorage.ts new file mode 100644 index 00000000000..6ee64217d28 --- /dev/null +++ b/app/javascript/mastodon/hooks/useStorage.ts @@ -0,0 +1,64 @@ +import { useCallback, useMemo } from 'react'; + +export function useStorage({ + type = 'local', + prefix = '', +}: { type?: 'local' | 'session'; prefix?: string } = {}) { + const storageType = type === 'local' ? 'localStorage' : 'sessionStorage'; + const isAvailable = useMemo( + () => storageAvailable(storageType), + [storageType], + ); + + const getItem = useCallback( + (key: string) => { + if (!isAvailable) { + return null; + } + try { + return window[storageType].getItem(prefix ? `${prefix};${key}` : key); + } catch { + return null; + } + }, + [isAvailable, storageType, prefix], + ); + const setItem = useCallback( + (key: string, value: string) => { + if (!isAvailable) { + return; + } + try { + window[storageType].setItem(prefix ? `${prefix};${key}` : key, value); + } catch {} + }, + [isAvailable, storageType, prefix], + ); + + return { + isAvailable, + getItem, + setItem, + }; +} + +// Tests the storage availability for the given type. Taken from MDN: +// https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Using_the_Web_Storage_API +export function storageAvailable(type: 'localStorage' | 'sessionStorage') { + let storage; + try { + storage = window[type]; + const x = '__storage_test__'; + storage.setItem(x, x); + storage.removeItem(x); + return true; + } catch (e) { + return ( + e instanceof DOMException && + e.name === 'QuotaExceededError' && + // acknowledge QuotaExceededError only if there's something already stored + storage && + storage.length !== 0 + ); + } +}