From 0923e2cb26d179f197680c978495593c8b205a80 Mon Sep 17 00:00:00 2001 From: Echo Date: Tue, 3 Feb 2026 14:02:33 +0100 Subject: [PATCH] Profile redesign: Fields iteration (#37682) --- .../mastodon/components/mini_card/index.tsx | 76 ++++++--- .../mastodon/components/mini_card/list.tsx | 74 +++------ .../mini_card/mini_card.stories.tsx | 26 +-- .../components/mini_card/styles.module.css | 63 ++++---- .../account_timeline/components/fields.tsx | 153 +++++++++++------- .../components/fields_modal.tsx | 95 ----------- .../components/redesign.module.scss | 112 ++++++++++--- .../account_timeline/v2/featured_tags.tsx | 4 +- .../components/inline_follow_suggestions.tsx | 71 ++------ .../features/ui/components/modal_root.jsx | 1 - app/javascript/mastodon/hooks/useOverflow.ts | 141 ++++++++++++++-- app/javascript/mastodon/locales/en.json | 5 +- app/javascript/mastodon/utils/checks.ts | 11 ++ 13 files changed, 449 insertions(+), 383 deletions(-) delete mode 100644 app/javascript/mastodon/features/account_timeline/components/fields_modal.tsx create mode 100644 app/javascript/mastodon/utils/checks.ts diff --git a/app/javascript/mastodon/components/mini_card/index.tsx b/app/javascript/mastodon/components/mini_card/index.tsx index a619bb214a..9ddb964d71 100644 --- a/app/javascript/mastodon/components/mini_card/index.tsx +++ b/app/javascript/mastodon/components/mini_card/index.tsx @@ -1,33 +1,57 @@ -import type { FC, ReactNode } from 'react'; +import { forwardRef } from 'react'; +import type { ComponentPropsWithoutRef, ReactNode } from 'react'; import classNames from 'classnames'; +import type { OmitUnion } from '@/mastodon/utils/types'; + +import { Icon } from '../icon'; +import type { IconProp } from '../icon'; + import classes from './styles.module.css'; -export interface MiniCardProps { - label: ReactNode; - value: ReactNode; - className?: string; - hidden?: boolean; -} - -export const MiniCard: FC = ({ - label, - value, - className, - hidden, -}) => { - if (!label) { - return null; +export type MiniCardProps = OmitUnion< + ComponentPropsWithoutRef<'div'>, + { + label: ReactNode; + value: ReactNode; + icon?: IconProp; + iconId?: string; + iconClassName?: string; } +>; - return ( -
-
{label}
-
{value}
-
- ); -}; +export const MiniCard = forwardRef( + ( + { label, value, className, hidden, icon, iconId, iconClassName, ...props }, + ref, + ) => { + if (!label) { + return null; + } + + return ( +
+ {icon && ( + + )} +
{label}
+
{value}
+
+ ); + }, +); +MiniCard.displayName = 'MiniCard'; diff --git a/app/javascript/mastodon/components/mini_card/list.tsx b/app/javascript/mastodon/components/mini_card/list.tsx index 9b5c859cf4..c98f57c863 100644 --- a/app/javascript/mastodon/components/mini_card/list.tsx +++ b/app/javascript/mastodon/components/mini_card/list.tsx @@ -1,69 +1,37 @@ -import type { FC, Key, MouseEventHandler } from 'react'; - -import { FormattedMessage } from 'react-intl'; +import { forwardRef } from 'react'; +import type { ComponentPropsWithoutRef, Key } from 'react'; import classNames from 'classnames'; -import { useOverflow } from '@/mastodon/hooks/useOverflow'; +import type { OmitUnion } from '@/mastodon/utils/types'; import { MiniCard } from '.'; -import type { MiniCardProps } from '.'; +import type { MiniCardProps as BaseCardProps } from '.'; import classes from './styles.module.css'; +export type MiniCardProps = BaseCardProps & { + key?: Key; +}; + interface MiniCardListProps { - cards?: (Pick & { - key?: Key; - })[]; - className?: string; - onOverflowClick?: MouseEventHandler; + cards?: MiniCardProps[]; } -export const MiniCardList: FC = ({ - cards = [], - className, - onOverflowClick, -}) => { - const { - wrapperRef, - listRef, - hiddenCount, - hasOverflow, - hiddenIndex, - maxWidth, - } = useOverflow(); - +export const MiniCardList = forwardRef< + HTMLDListElement, + OmitUnion, MiniCardListProps> +>(({ cards = [], className, children, ...props }, ref) => { if (!cards.length) { return null; } return ( -
-
- {cards.map((card, index) => ( -
- {cards.length > 1 && ( -
- -
- )} -
+
+ {cards.map((card, index) => ( + + ))} + {children} +
); -}; +}); +MiniCardList.displayName = 'MiniCardList'; diff --git a/app/javascript/mastodon/components/mini_card/mini_card.stories.tsx b/app/javascript/mastodon/components/mini_card/mini_card.stories.tsx index 60534f05f6..e099820d29 100644 --- a/app/javascript/mastodon/components/mini_card/mini_card.stories.tsx +++ b/app/javascript/mastodon/components/mini_card/mini_card.stories.tsx @@ -1,30 +1,12 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { action } from 'storybook/actions'; + +import LinkIcon from '@/material-icons/400-24px/link_2.svg?react'; import { MiniCardList } from './list'; const meta = { title: 'Components/MiniCard', component: MiniCardList, - args: { - onOverflowClick: action('Overflow clicked'), - }, - render(args) { - return ( -
- -
- ); - }, } satisfies Meta; export default meta; @@ -38,10 +20,12 @@ export const Default: Story = { { label: 'Website', value: bowie-the-db.meow, + icon: LinkIcon, }, { label: 'Free playlists', value: soundcloud.com, + icon: LinkIcon, }, { label: 'Location', value: 'Purris, France' }, ], @@ -54,11 +38,13 @@ export const LongValue: Story = { { label: 'Username', value: 'bowie-the-dj', + style: { maxWidth: '250px' }, }, { label: 'Bio', value: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + style: { maxWidth: '250px' }, }, ], }, diff --git a/app/javascript/mastodon/components/mini_card/styles.module.css b/app/javascript/mastodon/components/mini_card/styles.module.css index 642c08c5fa..07de32eef0 100644 --- a/app/javascript/mastodon/components/mini_card/styles.module.css +++ b/app/javascript/mastodon/components/mini_card/styles.module.css @@ -1,52 +1,49 @@ -.wrapper { - display: flex; - flex-wrap: nowrap; - justify-content: flex-start; - gap: 4px; -} - .list { display: flex; gap: 4px; - overflow: hidden; - position: relative; } -.card, -.more { +.card { border: 1px solid var(--color-border-primary); padding: 8px; border-radius: 8px; flex-shrink: 0; -} - -.more { + display: grid; + grid-template-rows: repeat(2, 1fr); + column-gap: 8px; + font-size: 13px; + line-height: 1rem; color: var(--color-text-secondary); - font-weight: 600; - appearance: none; - background: none; - aspect-ratio: 1; - height: 100%; - transition: all 300ms linear; } -.more:hover { - background-color: var(--color-bg-brand-softer); - color: var(--color-text-primary); +.cardWithIcon { + grid-template-columns: 16px 1fr; } -.hidden { - display: none; -} - -.label { - color: var(--color-text-secondary); - margin-bottom: 2px; +.icon { + grid-row: span 2; + grid-column: 1; + width: 100%; + height: auto; + align-self: center; + fill: currentColor; } .value { color: var(--color-text-primary); - font-weight: 600; + font-weight: 500; + + a { + color: var(--color-text-brand); + text-decoration: none; + transition: color 0.2s ease-in-out; + outline: none; + + &:hover, + &:focus { + color: var(--color-text-brand-soft); + } + } } .label, @@ -54,4 +51,8 @@ overflow: hidden; white-space: nowrap; text-overflow: ellipsis; + + .cardWithIcon & { + grid-column: 2; + } } diff --git a/app/javascript/mastodon/features/account_timeline/components/fields.tsx b/app/javascript/mastodon/features/account_timeline/components/fields.tsx index d407c5010f..0661c403b3 100644 --- a/app/javascript/mastodon/features/account_timeline/components/fields.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/fields.tsx @@ -1,21 +1,23 @@ -import { useCallback, useMemo } from 'react'; import type { FC } from 'react'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, useIntl } from 'react-intl'; import classNames from 'classnames'; import IconVerified from '@/images/icons/icon_verified.svg?react'; -import { openModal } from '@/mastodon/actions/modal'; import { AccountFields } from '@/mastodon/components/account_fields'; import { EmojiHTML } from '@/mastodon/components/emoji/html'; import { FormattedDateWrapper } from '@/mastodon/components/formatted_date'; -import { Icon } from '@/mastodon/components/icon'; -import { MiniCardList } from '@/mastodon/components/mini_card/list'; +import { IconButton } from '@/mastodon/components/icon_button'; +import { MiniCard } from '@/mastodon/components/mini_card'; import { useElementHandledLink } from '@/mastodon/components/status/handled_link'; import { useAccount } from '@/mastodon/hooks/useAccount'; +import { useOverflowScroll } from '@/mastodon/hooks/useOverflow'; import type { Account } from '@/mastodon/models/account'; -import { useAppDispatch } from '@/mastodon/store'; +import { isValidUrl } from '@/mastodon/utils/checks'; +import IconLeftArrow from '@/material-icons/400-24px/chevron_left.svg?react'; +import IconRightArrow from '@/material-icons/400-24px/chevron_right.svg?react'; +import IconLink from '@/material-icons/400-24px/link_2.svg?react'; import { isRedesignEnabled } from '../common'; @@ -57,61 +59,94 @@ export const AccountHeaderFields: FC<{ accountId: string }> = ({ const RedesignAccountHeaderFields: FC<{ account: Account }> = ({ account }) => { const htmlHandlers = useElementHandledLink(); - const cards = useMemo( - () => - account.fields - .toArray() - .map(({ value_emojified, name_emojified, verified_at }) => ({ - label: ( - <> - - {!!verified_at && ( - - )} - - ), - value: ( - - ), - className: classNames( - classes.fieldCard, - !!verified_at && classes.fieldCardVerified, - ), - })), - [account.emojis, account.fields, htmlHandlers], - ); + const intl = useIntl(); - const dispatch = useAppDispatch(); - const handleOverflowClick = useCallback(() => { - dispatch( - openModal({ - modalType: 'ACCOUNT_FIELDS', - modalProps: { accountId: account.id }, - }), - ); - }, [account.id, dispatch]); + const { + bodyRef, + canScrollLeft, + canScrollRight, + handleLeftNav, + handleRightNav, + handleScroll, + } = useOverflowScroll(); return ( - +
+ {canScrollLeft && ( + + )} +
+ {account.fields.map( + ( + { name, name_emojified, value_emojified, value_plain, verified_at }, + key, + ) => ( + + } + value={ + + } + icon={fieldIcon(verified_at, value_plain)} + className={classNames( + classes.fieldCard, + verified_at && classes.fieldCardVerified, + )} + /> + ), + )} +
+ {canScrollRight && ( + + )} +
); }; + +function fieldIcon(verified_at: string | null, value_plain: string | null) { + if (verified_at) { + return IconVerified; + } else if (value_plain && isValidUrl(value_plain)) { + return IconLink; + } + return undefined; +} diff --git a/app/javascript/mastodon/features/account_timeline/components/fields_modal.tsx b/app/javascript/mastodon/features/account_timeline/components/fields_modal.tsx deleted file mode 100644 index 5c29e77c11..0000000000 --- a/app/javascript/mastodon/features/account_timeline/components/fields_modal.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import type { FC } from 'react'; - -import { FormattedMessage, useIntl } from 'react-intl'; - -import IconVerified from '@/images/icons/icon_verified.svg?react'; -import { DisplayName } from '@/mastodon/components/display_name'; -import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context'; -import { EmojiHTML } from '@/mastodon/components/emoji/html'; -import { Icon } from '@/mastodon/components/icon'; -import { IconButton } from '@/mastodon/components/icon_button'; -import { LoadingIndicator } from '@/mastodon/components/loading_indicator'; -import { useElementHandledLink } from '@/mastodon/components/status/handled_link'; -import { useAccount } from '@/mastodon/hooks/useAccount'; -import CloseIcon from '@/material-icons/400-24px/close.svg?react'; - -import classes from './redesign.module.scss'; - -export const AccountFieldsModal: FC<{ - accountId: string; - onClose: () => void; -}> = ({ accountId, onClose }) => { - const intl = useIntl(); - const account = useAccount(accountId); - const htmlHandlers = useElementHandledLink(); - - if (!account) { - return ( -
- -
- ); - } - - return ( -
-
- - - , - }} - /> - -
-
- -
- {account.fields.map((field, index) => ( -
- -
- - {!!field.verified_at && ( - - )} -
-
- ))} -
-
-
-
- ); -}; diff --git a/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss b/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss index b86446efa3..944f6f7a7a 100644 --- a/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss +++ b/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss @@ -125,36 +125,112 @@ svg.badgeIcon { } } -.fieldList { +.fieldWrapper { margin-top: 16px; + width: 100%; + position: relative; +} + +.fieldWrapper::before, +.fieldWrapper::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + width: 40px; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease-in-out; + z-index: 1; +} + +.fieldWrapper::before { + left: 0; + background: linear-gradient( + to left, + transparent 0%, + var(--color-bg-primary) 100% + ); +} + +.fieldWrapper::after { + right: 0; + background: linear-gradient( + to right, + transparent 0%, + var(--color-bg-primary) 100% + ); +} + +.fieldWrapperLeft::before { + opacity: 1; +} + +.fieldWrapperRight::after { + opacity: 1; +} + +.fieldList { + display: flex; + flex-wrap: nowrap; + gap: 4px; + scroll-snap-type: x mandatory; + scroll-padding-left: 40px; + scroll-padding-right: 40px; + scroll-behavior: smooth; + overflow-x: scroll; + scrollbar-width: none; + overflow-y: visible; } .fieldCard { - position: relative; + scroll-snap-align: start; - a { - color: var(--color-text-brand); - text-decoration: none; + &:focus-visible, + &:focus-within { + outline: var(--outline-focus-default); + outline-offset: -2px; + } + + :is(dt, dd) { + max-width: 200px; } } .fieldCardVerified { background-color: var(--color-bg-brand-softer); - - dt { - padding-right: 1rem; - } - - .fieldIconVerified { - position: absolute; - top: 4px; - right: 4px; - } } -.fieldIconVerified { - width: 1rem; - height: 1rem; +.fieldArrowButton { + position: absolute; + top: 50%; + transform: translateY(-50%); + background-color: var(--color-bg-elevated); + box-shadow: 0 1px 4px 0 var(--color-shadow-primary); + border-radius: 9999px; + transition: + color 0.2s ease-in-out, + background-color 0.2s ease-in-out; + outline-offset: 2px; + z-index: 2; + + &:first-child { + left: 4px; + } + + &:last-child { + right: 4px; + } + + &:hover, + &:focus, + &:focus-visible { + background-color: color-mix( + in oklab, + var(--color-bg-brand-base) var(--overlay-strength-brand), + var(--color-bg-elevated) + ); + } } .fieldNumbersWrapper { 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 bdcff2c7e9..48ce9cfaa5 100644 --- a/app/javascript/mastodon/features/account_timeline/v2/featured_tags.tsx +++ b/app/javascript/mastodon/features/account_timeline/v2/featured_tags.tsx @@ -9,7 +9,7 @@ import { useParams } from 'react-router'; import { fetchFeaturedTags } from '@/mastodon/actions/featured_tags'; import { useAppHistory } from '@/mastodon/components/router'; import { Tag } from '@/mastodon/components/tags/tag'; -import { useOverflow } from '@/mastodon/hooks/useOverflow'; +import { useOverflowButton } from '@/mastodon/hooks/useOverflow'; import { selectAccountFeaturedTags } from '@/mastodon/selectors/accounts'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; @@ -30,7 +30,7 @@ export const FeaturedTags: FC<{ accountId: string }> = ({ accountId }) => { // Get list of tags with overflow handling. const [showOverflow, setShowOverflow] = useState(false); const { hiddenCount, wrapperRef, listRef, hiddenIndex, maxWidth } = - useOverflow(); + useOverflowButton(); // Handle whether to show all tags. const handleOverflowClick: MouseEventHandler = useCallback(() => { diff --git a/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx b/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx index d297d7cee5..45b867ad9d 100644 --- a/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx +++ b/app/javascript/mastodon/features/home_timeline/components/inline_follow_suggestions.tsx @@ -1,9 +1,10 @@ -import { useEffect, useCallback, useRef, useState, useId } from 'react'; +import { useEffect, useCallback, useId } from 'react'; import { FormattedMessage, useIntl, defineMessages } from 'react-intl'; import { Link } from 'react-router-dom'; +import { useOverflowScroll } from '@/mastodon/hooks/useOverflow'; import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; import CloseIcon from '@/material-icons/400-24px/close.svg?react'; @@ -178,74 +179,24 @@ export const InlineFollowSuggestions: React.FC<{ hidden?: boolean }> = ({ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call state.settings.getIn(['dismissed_banners', DISMISSIBLE_ID]) as boolean, ); - const bodyRef = useRef(null); - const [canScrollLeft, setCanScrollLeft] = useState(false); - const [canScrollRight, setCanScrollRight] = useState(true); useEffect(() => { void dispatch(fetchSuggestions()); }, [dispatch]); - useEffect(() => { - if (!bodyRef.current) { - return; - } - - if (getComputedStyle(bodyRef.current).direction === 'rtl') { - setCanScrollLeft( - bodyRef.current.clientWidth - bodyRef.current.scrollLeft < - bodyRef.current.scrollWidth, - ); - setCanScrollRight(bodyRef.current.scrollLeft < 0); - } else { - setCanScrollLeft(bodyRef.current.scrollLeft > 0); - setCanScrollRight( - bodyRef.current.scrollLeft + bodyRef.current.clientWidth < - bodyRef.current.scrollWidth, - ); - } - }, [setCanScrollRight, setCanScrollLeft, suggestions]); - - const handleLeftNav = useCallback(() => { - if (!bodyRef.current) { - return; - } - - bodyRef.current.scrollLeft -= 200; - }, []); - - const handleRightNav = useCallback(() => { - if (!bodyRef.current) { - return; - } - - bodyRef.current.scrollLeft += 200; - }, []); - - const handleScroll = useCallback(() => { - if (!bodyRef.current) { - return; - } - - if (getComputedStyle(bodyRef.current).direction === 'rtl') { - setCanScrollLeft( - bodyRef.current.clientWidth - bodyRef.current.scrollLeft < - bodyRef.current.scrollWidth, - ); - setCanScrollRight(bodyRef.current.scrollLeft < 0); - } else { - setCanScrollLeft(bodyRef.current.scrollLeft > 0); - setCanScrollRight( - bodyRef.current.scrollLeft + bodyRef.current.clientWidth < - bodyRef.current.scrollWidth, - ); - } - }, [setCanScrollRight, setCanScrollLeft]); - const handleDismiss = useCallback(() => { dispatch(changeSetting(['dismissed_banners', DISMISSIBLE_ID], true)); }, [dispatch]); + const { + bodyRef, + handleScroll, + canScrollLeft, + canScrollRight, + handleLeftNav, + handleRightNav, + } = useOverflowScroll({ absoluteDistance: true }); + if (dismissed || (!isLoading && suggestions.length === 0)) { return null; } diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx index 30d7578c55..4aae94574b 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.jsx +++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx @@ -88,7 +88,6 @@ export const MODAL_COMPONENTS = { 'ANNUAL_REPORT': AnnualReportModal, 'COMPOSE_PRIVACY': () => Promise.resolve({ default: VisibilityModal }), 'ACCOUNT_NOTE': () => import('@/mastodon/features/account_timeline/modals/note_modal').then(module => ({ default: module.AccountNoteModal })), - 'ACCOUNT_FIELDS': () => import('mastodon/features/account_timeline/components/fields_modal.tsx').then(module => ({ default: module.AccountFieldsModal })), }; export default class ModalRoot extends PureComponent { diff --git a/app/javascript/mastodon/hooks/useOverflow.ts b/app/javascript/mastodon/hooks/useOverflow.ts index e5a9ab407e..b306fb4871 100644 --- a/app/javascript/mastodon/hooks/useOverflow.ts +++ b/app/javascript/mastodon/hooks/useOverflow.ts @@ -1,14 +1,15 @@ +import type { MutableRefObject, RefCallback } from 'react'; import { useState, useRef, useCallback, useEffect } from 'react'; /** - * Calculate and manage overflow of child elements within a container. + * Hook to manage overflow of items in a container with a "more" button. * * To use, wire up the `wrapperRef` to the container element, and the `listRef` to the * child element that contains the items to be measured. If autoResize is true, * the list element will have its max-width set to prevent wrapping. The listRef element * requires both position:relative and overflow:hidden styles to work correctly. */ -export function useOverflow({ +export function useOverflowButton({ autoResize, padding = 4, }: { autoResize?: boolean; padding?: number } = {}) { @@ -76,6 +77,111 @@ export function useOverflow({ } }, [autoResize, maxWidth]); + const { listRefCallback, wrapperRefCallback } = useOverflowObservers({ + onRecalculate: handleRecalculate, + onListRef: listRef, + }); + + return { + hiddenCount, + hasOverflow: hiddenCount > 0, + wrapperRef: wrapperRefCallback, + hiddenIndex, + maxWidth, + listRef: listRefCallback, + recalculate: handleRecalculate, + }; +} + +export function useOverflowScroll({ + widthOffset = 200, + absoluteDistance = false, +} = {}) { + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + + const bodyRef = useRef(null); + + // Recalculate scrollable state + const handleRecalculate = useCallback(() => { + if (!bodyRef.current) { + return; + } + + if (getComputedStyle(bodyRef.current).direction === 'rtl') { + setCanScrollLeft( + bodyRef.current.clientWidth - bodyRef.current.scrollLeft < + bodyRef.current.scrollWidth, + ); + setCanScrollRight(bodyRef.current.scrollLeft < 0); + } else { + setCanScrollLeft(bodyRef.current.scrollLeft > 0); + setCanScrollRight( + bodyRef.current.scrollLeft + bodyRef.current.clientWidth < + bodyRef.current.scrollWidth, + ); + } + }, []); + + const { wrapperRefCallback } = useOverflowObservers({ + onRecalculate: handleRecalculate, + onWrapperRef: bodyRef, + }); + + useEffect(() => { + handleRecalculate(); + }, [handleRecalculate]); + + // Handle scroll event using requestAnimationFrame to avoid excessive recalculations. + const handleScroll = useCallback(() => { + requestAnimationFrame(handleRecalculate); + }, [handleRecalculate]); + + // Jump a full screen minus the width offset so that we don't skip a lot. + const handleLeftNav = useCallback(() => { + if (!bodyRef.current) { + return; + } + + bodyRef.current.scrollLeft -= absoluteDistance + ? widthOffset + : Math.max(widthOffset, bodyRef.current.clientWidth - widthOffset); + }, [absoluteDistance, widthOffset]); + + const handleRightNav = useCallback(() => { + if (!bodyRef.current) { + return; + } + + bodyRef.current.scrollLeft += absoluteDistance + ? widthOffset + : Math.max(widthOffset, bodyRef.current.clientWidth - widthOffset); + }, [absoluteDistance, widthOffset]); + + return { + bodyRef: wrapperRefCallback, + canScrollLeft, + canScrollRight, + handleLeftNav, + handleRightNav, + handleScroll, + }; +} + +export function useOverflowObservers({ + onRecalculate, + onListRef, + onWrapperRef, +}: { + onRecalculate: () => void; + onListRef?: RefCallback | MutableRefObject; + onWrapperRef?: + | RefCallback + | MutableRefObject; +}) { + // This is the item container element. + const listRef = useRef(null); + // Set up observers to watch for size and content changes. const resizeObserverRef = useRef(null); const mutationObserverRef = useRef(null); @@ -83,10 +189,10 @@ export function useOverflow({ // Helper to get or create the resize observer. const resizeObserver = useCallback(() => { const observer = (resizeObserverRef.current ??= new ResizeObserver( - handleRecalculate, + onRecalculate, )); return observer; - }, [handleRecalculate]); + }, [onRecalculate]); // Iterate through children and observe them for size changes. const handleChildrenChange = useCallback(() => { @@ -100,8 +206,8 @@ export function useOverflow({ } } } - handleRecalculate(); - }, [handleRecalculate, resizeObserver]); + onRecalculate(); + }, [onRecalculate, resizeObserver]); // Helper to get or create the mutation observer. const mutationObserver = useCallback(() => { @@ -129,9 +235,14 @@ export function useOverflow({ if (node) { wrapperRef.current = node; handleObserve(); + if (typeof onWrapperRef === 'function') { + onWrapperRef(node); + } else if (onWrapperRef && 'current' in onWrapperRef) { + onWrapperRef.current = node; + } } }, - [handleObserve], + [handleObserve, onWrapperRef], ); // If there are changes to the children, recalculate which are visible. @@ -140,9 +251,14 @@ export function useOverflow({ if (node) { listRef.current = node; handleObserve(); + if (typeof onListRef === 'function') { + onListRef(node); + } else if (onListRef && 'current' in onListRef) { + onListRef.current = node; + } } }, - [handleObserve], + [handleObserve, onListRef], ); useEffect(() => { @@ -161,12 +277,7 @@ export function useOverflow({ }, [handleObserve]); return { - hiddenCount, - hasOverflow: hiddenCount > 0, - wrapperRef: wrapperRefCallback, - hiddenIndex, - maxWidth, - listRef: listRefCallback, - recalculate: handleRecalculate, + wrapperRefCallback, + listRefCallback, }; } diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 810b4c90fa..b722b21898 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -46,6 +46,8 @@ "account.featured.hashtags": "Hashtags", "account.featured_tags.last_status_at": "Last post on {date}", "account.featured_tags.last_status_never": "No posts", + "account.fields.scroll_next": "Show next", + "account.fields.scroll_prev": "Show previous", "account.filters.all": "All activity", "account.filters.boosts_toggle": "Show boosts", "account.filters.posts_boosts": "Posts and boosts", @@ -129,8 +131,6 @@ "account.unmute": "Unmute @{name}", "account.unmute_notifications_short": "Unmute notifications", "account.unmute_short": "Unmute", - "account_fields_modal.close": "Close", - "account_fields_modal.title": "{name}'s info", "account_note.placeholder": "Click to add note", "admin.dashboard.daily_retention": "User retention rate by day after sign-up", "admin.dashboard.monthly_retention": "User retention rate by month after sign-up", @@ -653,7 +653,6 @@ "load_pending": "{count, plural, one {# new item} other {# new items}}", "loading_indicator.label": "Loading…", "media_gallery.hide": "Hide", - "minicard.more_items": "+{count}", "moved_to_account_banner.text": "Your account {disabledAccount} is currently disabled because you moved to {movedToAccount}.", "mute_modal.hide_from_notifications": "Hide from notifications", "mute_modal.hide_options": "Hide options", diff --git a/app/javascript/mastodon/utils/checks.ts b/app/javascript/mastodon/utils/checks.ts new file mode 100644 index 0000000000..8b05ac24a7 --- /dev/null +++ b/app/javascript/mastodon/utils/checks.ts @@ -0,0 +1,11 @@ +export function isValidUrl( + url: string, + allowedProtocols = ['https:'], +): boolean { + try { + const parsedUrl = new URL(url); + return allowedProtocols.includes(parsedUrl.protocol); + } catch { + return false; + } +}