diff --git a/app/javascript/mastodon/components/mini_card/list.tsx b/app/javascript/mastodon/components/mini_card/list.tsx index 318c584953..9b5c859cf4 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 1492b67c88..f3c507b644 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 4dd4b89b55..8192854327 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 c1c120def7..a5b1f9f7c3 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 f525264ed4..eeb48c1c53 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 0000000000..bdcff2c7e9 --- /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 4254e3d7eb..c0a4cf5735 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 a8ba29afa5..c35b46524e 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 0000000000..e5a9ab407e --- /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 1468848715..c49db6f5f0 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 f9ba1a76a6..bf608fec4e 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 019b074813..f51b3ad8b3 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;