From 9079a75574426e1e4712d5c9bba9af09af2e85a7 Mon Sep 17 00:00:00 2001 From: Echo Date: Thu, 29 Jan 2026 10:01:36 +0100 Subject: [PATCH] Profile redesign: Featured tags (#37645) --- .../mastodon/components/mini_card/list.tsx | 156 +--------------- .../mastodon/components/tags/style.module.css | 2 +- .../mastodon/components/tags/tag.tsx | 15 +- .../mastodon/components/tags/tags.tsx | 42 +++-- .../account_timeline/components/tabs.tsx | 7 +- .../account_timeline/v2/featured_tags.tsx | 124 +++++++++++++ .../features/account_timeline/v2/index.tsx | 2 + .../account_timeline/v2/styles.module.scss | 22 +++ app/javascript/mastodon/hooks/useOverflow.ts | 172 ++++++++++++++++++ app/javascript/mastodon/locales/en.json | 1 + app/javascript/mastodon/selectors/accounts.ts | 56 +++++- app/javascript/mastodon/utils/types.ts | 2 + 12 files changed, 414 insertions(+), 187 deletions(-) create mode 100644 app/javascript/mastodon/features/account_timeline/v2/featured_tags.tsx create mode 100644 app/javascript/mastodon/hooks/useOverflow.ts diff --git a/app/javascript/mastodon/components/mini_card/list.tsx b/app/javascript/mastodon/components/mini_card/list.tsx index 318c5849538..9b5c859cf48 100644 --- a/app/javascript/mastodon/components/mini_card/list.tsx +++ b/app/javascript/mastodon/components/mini_card/list.tsx @@ -1,10 +1,11 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; import type { FC, Key, MouseEventHandler } from 'react'; import { FormattedMessage } from 'react-intl'; import classNames from 'classnames'; +import { useOverflow } from '@/mastodon/hooks/useOverflow'; + import { MiniCard } from '.'; import type { MiniCardProps } from '.'; import classes from './styles.module.css'; @@ -66,156 +67,3 @@ export const MiniCardList: FC = ({ ); }; - -function useOverflow() { - const [hiddenIndex, setHiddenIndex] = useState(-1); - const [hiddenCount, setHiddenCount] = useState(0); - const [maxWidth, setMaxWidth] = useState('none'); - - // This is the item container element. - const listRef = useRef(null); - - // The main recalculation function. - const handleRecalculate = useCallback(() => { - const listEle = listRef.current; - if (!listEle) return; - - const reset = () => { - setHiddenIndex(-1); - setHiddenCount(0); - setMaxWidth('none'); - }; - - // Calculate the width via the parent element, minus the more button, minus the padding. - const maxWidth = - (listEle.parentElement?.offsetWidth ?? 0) - - (listEle.nextElementSibling?.scrollWidth ?? 0) - - 4; - if (maxWidth <= 0) { - reset(); - return; - } - - // Iterate through children until we exceed max width. - let visible = 0; - let index = 0; - let totalWidth = 0; - for (const child of listEle.children) { - if (child instanceof HTMLElement) { - const rightOffset = child.offsetLeft + child.offsetWidth; - if (rightOffset <= maxWidth) { - visible += 1; - totalWidth = rightOffset; - } else { - break; - } - } - index++; - } - - // All are visible, so remove max-width restriction. - if (visible === listEle.children.length) { - reset(); - return; - } - - // Set the width to avoid wrapping, and set hidden count. - setHiddenIndex(index); - setHiddenCount(listEle.children.length - visible); - setMaxWidth(totalWidth); - }, []); - - // Set up observers to watch for size and content changes. - const resizeObserverRef = useRef(null); - const mutationObserverRef = useRef(null); - - // Helper to get or create the resize observer. - const resizeObserver = useCallback(() => { - const observer = (resizeObserverRef.current ??= new ResizeObserver( - handleRecalculate, - )); - return observer; - }, [handleRecalculate]); - - // Iterate through children and observe them for size changes. - const handleChildrenChange = useCallback(() => { - const listEle = listRef.current; - const observer = resizeObserver(); - - if (listEle) { - for (const child of listEle.children) { - if (child instanceof HTMLElement) { - observer.observe(child); - } - } - } - handleRecalculate(); - }, [handleRecalculate, resizeObserver]); - - // Helper to get or create the mutation observer. - const mutationObserver = useCallback(() => { - const observer = (mutationObserverRef.current ??= new MutationObserver( - handleChildrenChange, - )); - return observer; - }, [handleChildrenChange]); - - // Set up observers. - const handleObserve = useCallback(() => { - if (wrapperRef.current) { - resizeObserver().observe(wrapperRef.current); - } - if (listRef.current) { - mutationObserver().observe(listRef.current, { childList: true }); - handleChildrenChange(); - } - }, [handleChildrenChange, mutationObserver, resizeObserver]); - - // Watch the wrapper for size changes, and recalculate when it resizes. - const wrapperRef = useRef(null); - const wrapperRefCallback = useCallback( - (node: HTMLElement | null) => { - if (node) { - wrapperRef.current = node; - handleObserve(); - } - }, - [handleObserve], - ); - - // If there are changes to the children, recalculate which are visible. - const listRefCallback = useCallback( - (node: HTMLElement | null) => { - if (node) { - listRef.current = node; - handleObserve(); - } - }, - [handleObserve], - ); - - useEffect(() => { - handleObserve(); - - return () => { - if (resizeObserverRef.current) { - resizeObserverRef.current.disconnect(); - resizeObserverRef.current = null; - } - if (mutationObserverRef.current) { - mutationObserverRef.current.disconnect(); - mutationObserverRef.current = null; - } - }; - }, [handleObserve]); - - return { - hiddenCount, - hasOverflow: hiddenCount > 0, - wrapperRef: wrapperRefCallback, - hiddenIndex, - maxWidth, - listRef: listRefCallback, - recalculate: handleRecalculate, - }; -} diff --git a/app/javascript/mastodon/components/tags/style.module.css b/app/javascript/mastodon/components/tags/style.module.css index 1492b67c883..f3c507b644f 100644 --- a/app/javascript/mastodon/components/tags/style.module.css +++ b/app/javascript/mastodon/components/tags/style.module.css @@ -3,7 +3,7 @@ border: 1px solid var(--color-border-primary); appearance: none; background: none; - padding: 8px; + padding: 6px 8px; transition: all 0.2s ease-in-out; color: var(--color-text-primary); display: inline-flex; diff --git a/app/javascript/mastodon/components/tags/tag.tsx b/app/javascript/mastodon/components/tags/tag.tsx index 4dd4b89b55f..81928543277 100644 --- a/app/javascript/mastodon/components/tags/tag.tsx +++ b/app/javascript/mastodon/components/tags/tag.tsx @@ -5,6 +5,7 @@ import { useIntl } from 'react-intl'; import classNames from 'classnames'; +import type { OmitUnion } from '@/mastodon/utils/types'; import CloseIcon from '@/material-icons/400-24px/close.svg?react'; import type { IconProp } from '../icon'; @@ -23,7 +24,7 @@ export interface TagProps { export const Tag = forwardRef< HTMLButtonElement, - TagProps & ComponentPropsWithoutRef<'button'> + OmitUnion, TagProps> >(({ name, active, icon, className, children, ...props }, ref) => { if (!name) { return null; @@ -34,6 +35,7 @@ export const Tag = forwardRef< type='button' ref={ref} className={classNames(className, classes.tag, active && classes.active)} + aria-pressed={active} > {icon && } {typeof name === 'string' ? `#${name}` : name} @@ -45,10 +47,13 @@ Tag.displayName = 'Tag'; export const EditableTag = forwardRef< HTMLSpanElement, - TagProps & { - onRemove: () => void; - removeIcon?: IconProp; - } & ComponentPropsWithoutRef<'span'> + OmitUnion< + ComponentPropsWithoutRef<'span'>, + TagProps & { + onRemove: () => void; + removeIcon?: IconProp; + } + > >( ( { diff --git a/app/javascript/mastodon/components/tags/tags.tsx b/app/javascript/mastodon/components/tags/tags.tsx index c1c120def70..a5b1f9f7c37 100644 --- a/app/javascript/mastodon/components/tags/tags.tsx +++ b/app/javascript/mastodon/components/tags/tags.tsx @@ -1,6 +1,8 @@ -import { useCallback } from 'react'; +import { forwardRef, useCallback } from 'react'; import type { ComponentPropsWithoutRef, FC } from 'react'; +import classNames from 'classnames'; + import classes from './style.module.css'; import { EditableTag, Tag } from './tag'; import type { TagProps } from './tag'; @@ -17,31 +19,39 @@ export type TagsProps = { | ({ onRemove?: (tag: string) => void } & ComponentPropsWithoutRef<'span'>) ); -export const Tags: FC = ({ tags, active, onRemove, ...props }) => { - if (onRemove) { +export const Tags = forwardRef( + ({ tags, active, onRemove, className, ...props }, ref) => { + if (onRemove) { + return ( +
+ {tags.map((tag) => ( + + ))} +
+ ); + } + return ( -
+
{tags.map((tag) => ( - ))}
); - } - - return ( -
- {tags.map((tag) => ( - - ))} -
- ); -}; + }, +); +Tags.displayName = 'Tags'; const MappedTag: FC void }> = ({ onRemove, diff --git a/app/javascript/mastodon/features/account_timeline/components/tabs.tsx b/app/javascript/mastodon/features/account_timeline/components/tabs.tsx index f525264ed4f..eeb48c1c532 100644 --- a/app/javascript/mastodon/features/account_timeline/components/tabs.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/tabs.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react'; import { FormattedMessage } from 'react-intl'; +import type { NavLinkProps } from 'react-router-dom'; import { NavLink } from 'react-router-dom'; import { isRedesignEnabled } from '../common'; @@ -12,7 +13,7 @@ export const AccountTabs: FC<{ acct: string }> = ({ acct }) => { if (isRedesignEnabled()) { return (
- + @@ -44,3 +45,7 @@ export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
); }; + +const isActive: Required['isActive'] = (match, location) => + match?.url === location.pathname || + (!!match?.url && location.pathname.startsWith(`${match.url}/tagged/`)); diff --git a/app/javascript/mastodon/features/account_timeline/v2/featured_tags.tsx b/app/javascript/mastodon/features/account_timeline/v2/featured_tags.tsx new file mode 100644 index 00000000000..bdcff2c7e93 --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/v2/featured_tags.tsx @@ -0,0 +1,124 @@ +import { useCallback, useEffect, useState } from 'react'; +import type { FC, MouseEventHandler } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import classNames from 'classnames'; +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 { selectAccountFeaturedTags } from '@/mastodon/selectors/accounts'; +import { useAppDispatch, useAppSelector } from '@/mastodon/store'; + +import { useFilters } from '../hooks/useFilters'; + +import classes from './styles.module.scss'; + +export const FeaturedTags: FC<{ accountId: string }> = ({ accountId }) => { + // Fetch tags. + const featuredTags = useAppSelector((state) => + selectAccountFeaturedTags(state, accountId), + ); + const dispatch = useAppDispatch(); + useEffect(() => { + void dispatch(fetchFeaturedTags({ accountId })); + }, [accountId, dispatch]); + + // Get list of tags with overflow handling. + const [showOverflow, setShowOverflow] = useState(false); + const { hiddenCount, wrapperRef, listRef, hiddenIndex, maxWidth } = + useOverflow(); + + // Handle whether to show all tags. + const handleOverflowClick: MouseEventHandler = useCallback(() => { + setShowOverflow(true); + }, []); + + const { onClick, currentTag } = useTagNavigate(); + + if (featuredTags.length === 0) { + return null; + } + + return ( +
+
+ {featuredTags.map(({ id, name }, index) => ( + 0 && index >= hiddenIndex ? '' : undefined} + onClick={onClick} + active={currentTag === name} + data-name={name} + /> + ))} +
+ {!showOverflow && hiddenCount > 0 && ( + + } + /> + )} +
+ ); +}; + +function useTagNavigate() { + // Get current account, tag, and filters. + const { acct, tagged } = useParams<{ acct: string; tagged?: string }>(); + const { boosts, replies } = useFilters(); + + const history = useAppHistory(); + + const handleTagClick: MouseEventHandler = useCallback( + (event) => { + const name = event.currentTarget.getAttribute('data-name'); + if (!name || !acct) { + return; + } + + // Determine whether to navigate to or from the tag. + let url = `/@${acct}/tagged/${encodeURIComponent(name)}`; + if (name === tagged) { + url = `/@${acct}`; + } + + // Append filters. + const params = new URLSearchParams(); + if (boosts) { + params.append('boosts', '1'); + } + if (replies) { + params.append('replies', '1'); + } + + history.push({ + pathname: url, + search: params.toString(), + }); + }, + [acct, tagged, boosts, replies, history], + ); + + return { + onClick: handleTagClick, + currentTag: tagged, + }; +} diff --git a/app/javascript/mastodon/features/account_timeline/v2/index.tsx b/app/javascript/mastodon/features/account_timeline/v2/index.tsx index 4254e3d7eb0..c0a4cf57354 100644 --- a/app/javascript/mastodon/features/account_timeline/v2/index.tsx +++ b/app/javascript/mastodon/features/account_timeline/v2/index.tsx @@ -27,6 +27,7 @@ import { AccountHeader } from '../components/account_header'; import { LimitedAccountHint } from '../components/limited_account_hint'; import { useFilters } from '../hooks/useFilters'; +import { FeaturedTags } from './featured_tags'; import { AccountFilters } from './filters'; const emptyList = ImmutableList(); @@ -135,6 +136,7 @@ const Prepend: FC<{ <> + ); diff --git a/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss b/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss index a8ba29afa56..c35b46524e1 100644 --- a/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss +++ b/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss @@ -35,3 +35,25 @@ align-items: center; } } + +.tagsWrapper { + margin: 0 24px 8px; + display: flex; + flex-wrap: nowrap; + justify-content: flex-start; + gap: 8px; +} + +.tagsList { + display: flex; + gap: 4px; + flex-wrap: nowrap; + overflow: hidden; + position: relative; +} + +.tagsListShowAll { + flex-wrap: wrap; + overflow: visible; + max-width: none !important; +} diff --git a/app/javascript/mastodon/hooks/useOverflow.ts b/app/javascript/mastodon/hooks/useOverflow.ts new file mode 100644 index 00000000000..e5a9ab407e5 --- /dev/null +++ b/app/javascript/mastodon/hooks/useOverflow.ts @@ -0,0 +1,172 @@ +import { useState, useRef, useCallback, useEffect } from 'react'; + +/** + * Calculate and manage overflow of child elements within a container. + * + * 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({ + autoResize, + padding = 4, +}: { autoResize?: boolean; padding?: number } = {}) { + const [hiddenIndex, setHiddenIndex] = useState(-1); + const [hiddenCount, setHiddenCount] = useState(0); + const [maxWidth, setMaxWidth] = useState('none'); + + // This is the item container element. + const listRef = useRef(null); + + // The main recalculation function. + const handleRecalculate = useCallback(() => { + const listEle = listRef.current; + if (!listEle) return; + + const reset = () => { + setHiddenIndex(-1); + setHiddenCount(0); + setMaxWidth('none'); + }; + + // Calculate the width via the parent element, minus the more button, minus the padding. + const maxWidth = + (listEle.parentElement?.offsetWidth ?? 0) - + (listEle.nextElementSibling?.scrollWidth ?? 0) - + padding; + if (maxWidth <= 0) { + reset(); + return; + } + + // Iterate through children until we exceed max width. + let visible = 0; + let index = 0; + let totalWidth = 0; + for (const child of listEle.children) { + if (child instanceof HTMLElement) { + const rightOffset = child.offsetLeft + child.offsetWidth; + if (rightOffset <= maxWidth) { + visible += 1; + totalWidth = rightOffset; + } else { + break; + } + } + index++; + } + + // All are visible, so remove max-width restriction. + if (visible === listEle.children.length) { + reset(); + return; + } + + // Set the width to avoid wrapping, and set hidden count. + setHiddenIndex(index); + setHiddenCount(listEle.children.length - visible); + setMaxWidth(totalWidth); + }, [padding]); + + useEffect(() => { + if (listRef.current && autoResize) { + listRef.current.style.maxWidth = + typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth; + } + }, [autoResize, maxWidth]); + + // Set up observers to watch for size and content changes. + const resizeObserverRef = useRef(null); + const mutationObserverRef = useRef(null); + + // Helper to get or create the resize observer. + const resizeObserver = useCallback(() => { + const observer = (resizeObserverRef.current ??= new ResizeObserver( + handleRecalculate, + )); + return observer; + }, [handleRecalculate]); + + // Iterate through children and observe them for size changes. + const handleChildrenChange = useCallback(() => { + const listEle = listRef.current; + const observer = resizeObserver(); + + if (listEle) { + for (const child of listEle.children) { + if (child instanceof HTMLElement) { + observer.observe(child); + } + } + } + handleRecalculate(); + }, [handleRecalculate, resizeObserver]); + + // Helper to get or create the mutation observer. + const mutationObserver = useCallback(() => { + const observer = (mutationObserverRef.current ??= new MutationObserver( + handleChildrenChange, + )); + return observer; + }, [handleChildrenChange]); + + // Set up observers. + const handleObserve = useCallback(() => { + if (wrapperRef.current) { + resizeObserver().observe(wrapperRef.current); + } + if (listRef.current) { + mutationObserver().observe(listRef.current, { childList: true }); + handleChildrenChange(); + } + }, [handleChildrenChange, mutationObserver, resizeObserver]); + + // Watch the wrapper for size changes, and recalculate when it resizes. + const wrapperRef = useRef(null); + const wrapperRefCallback = useCallback( + (node: HTMLElement | null) => { + if (node) { + wrapperRef.current = node; + handleObserve(); + } + }, + [handleObserve], + ); + + // If there are changes to the children, recalculate which are visible. + const listRefCallback = useCallback( + (node: HTMLElement | null) => { + if (node) { + listRef.current = node; + handleObserve(); + } + }, + [handleObserve], + ); + + useEffect(() => { + handleObserve(); + + return () => { + if (resizeObserverRef.current) { + resizeObserverRef.current.disconnect(); + resizeObserverRef.current = null; + } + if (mutationObserverRef.current) { + mutationObserverRef.current.disconnect(); + mutationObserverRef.current = null; + } + }; + }, [handleObserve]); + + return { + hiddenCount, + hasOverflow: hiddenCount > 0, + wrapperRef: wrapperRefCallback, + hiddenIndex, + maxWidth, + listRef: listRefCallback, + recalculate: handleRecalculate, + }; +} diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 1468848715f..c49db6f5f0c 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -427,6 +427,7 @@ "featured_carousel.current": "Post {current, number} / {max, number}", "featured_carousel.header": "{count, plural, one {Pinned Post} other {Pinned Posts}}", "featured_carousel.slide": "Post {current, number} of {max, number}", + "featured_tags.more_items": "+{count}", "filter_modal.added.context_mismatch_explanation": "This filter category does not apply to the context in which you have accessed this post. If you want the post to be filtered in this context too, you will have to edit the filter.", "filter_modal.added.context_mismatch_title": "Context mismatch!", "filter_modal.added.expired_explanation": "This filter category has expired, you will need to change the expiration date for it to apply.", diff --git a/app/javascript/mastodon/selectors/accounts.ts b/app/javascript/mastodon/selectors/accounts.ts index f9ba1a76a6a..bf608fec4e4 100644 --- a/app/javascript/mastodon/selectors/accounts.ts +++ b/app/javascript/mastodon/selectors/accounts.ts @@ -1,12 +1,15 @@ -import { createSelector } from '@reduxjs/toolkit'; -import { Record as ImmutableRecord } from 'immutable'; +import type { Map as ImmutableMap } from 'immutable'; +import { Record as ImmutableRecord, List as ImmutableList } from 'immutable'; import { me } from 'mastodon/initial_state'; import { accountDefaultValues } from 'mastodon/models/account'; import type { Account, AccountShape } from 'mastodon/models/account'; import type { Relationship } from 'mastodon/models/relationship'; +import { createAppSelector } from 'mastodon/store'; import type { RootState } from 'mastodon/store'; +import type { ApiHashtagJSON } from '../api_types/tags'; + const getAccountBase = (state: RootState, id: string) => state.accounts.get(id, null); @@ -33,7 +36,7 @@ const FullAccountFactory = ImmutableRecord({ }); export function makeGetAccount() { - return createSelector( + return createAppSelector( [getAccountBase, getAccountRelationship, getAccountMoved], (base, relationship, moved) => { if (base === null) { @@ -47,23 +50,23 @@ export function makeGetAccount() { ); } -export const getAccountHidden = createSelector( +export const getAccountHidden = createAppSelector( [ - (state: RootState, id: string) => state.accounts.get(id)?.hidden, - (state: RootState, id: string) => + (state, id: string) => state.accounts.get(id)?.hidden, + (state, id: string) => state.relationships.get(id)?.following || state.relationships.get(id)?.requested, - (state: RootState, id: string) => id === me, + (_, id: string) => id === me, ], (hidden, followingOrRequested, isSelf) => { return hidden && !(isSelf || followingOrRequested); }, ); -export const getAccountFamiliarFollowers = createSelector( +export const getAccountFamiliarFollowers = createAppSelector( [ - (state: RootState) => state.accounts, - (state: RootState, id: string) => state.accounts_familiar_followers[id], + (state) => state.accounts, + (state, id: string) => state.accounts_familiar_followers[id], ], (accounts, accounts_familiar_followers) => { if (!accounts_familiar_followers) return null; @@ -72,3 +75,36 @@ export const getAccountFamiliarFollowers = createSelector( .filter((f) => !!f); }, ); + +export type TagType = Omit< + ApiHashtagJSON, + 'history' | 'following' | 'featured' +> & { + accountId: string; + statuses_count: number; + last_status_at: string; +}; + +export const selectAccountFeaturedTags = createAppSelector( + [(state) => state.user_lists, (_, accountId: string) => accountId], + (user_lists, accountId) => { + const list = user_lists.getIn( + ['featured_tags', accountId, 'items'], + ImmutableList(), + ) as ImmutableList>; + return list.toArray().map( + (tag) => + ({ + id: tag.get('id') as string, + name: tag.get('name') as string, + url: tag.get('url') as string, + accountId: tag.get('accountId') as string, + statuses_count: Number.parseInt( + tag.get('statuses_count') as string, + 10, + ), + last_status_at: tag.get('last_status_at') as string, + }) satisfies TagType, + ); + }, +); diff --git a/app/javascript/mastodon/utils/types.ts b/app/javascript/mastodon/utils/types.ts index 019b0748138..f51b3ad8b31 100644 --- a/app/javascript/mastodon/utils/types.ts +++ b/app/javascript/mastodon/utils/types.ts @@ -22,3 +22,5 @@ export type OmitValueType = { }; export type AnyFunction = (...args: never) => unknown; + +export type OmitUnion = TBase & Omit;