From 56100254b7db01d2c1f6a1a7a1e1ede252725644 Mon Sep 17 00:00:00 2001 From: Echo Date: Thu, 29 Jan 2026 10:01:36 +0100 Subject: [PATCH] [Glitch] Profile redesign: Featured tags Port 9079a75574426e1e4712d5c9bba9af09af2e85a7 to glitch-soc Signed-off-by: Claire --- .../glitch/components/mini_card/list.tsx | 156 +--------------- .../glitch/components/tags/style.module.css | 2 +- .../flavours/glitch/components/tags/tag.tsx | 15 +- .../flavours/glitch/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 +++ .../flavours/glitch/hooks/useOverflow.ts | 172 ++++++++++++++++++ .../flavours/glitch/selectors/accounts.ts | 56 +++++- app/javascript/flavours/glitch/utils/types.ts | 2 + 11 files changed, 413 insertions(+), 187 deletions(-) create mode 100644 app/javascript/flavours/glitch/features/account_timeline/v2/featured_tags.tsx create mode 100644 app/javascript/flavours/glitch/hooks/useOverflow.ts diff --git a/app/javascript/flavours/glitch/components/mini_card/list.tsx b/app/javascript/flavours/glitch/components/mini_card/list.tsx index 318c584953..bb032d7965 100644 --- a/app/javascript/flavours/glitch/components/mini_card/list.tsx +++ b/app/javascript/flavours/glitch/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 '@/flavours/glitch/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/flavours/glitch/components/tags/style.module.css b/app/javascript/flavours/glitch/components/tags/style.module.css index 1492b67c88..f3c507b644 100644 --- a/app/javascript/flavours/glitch/components/tags/style.module.css +++ b/app/javascript/flavours/glitch/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/flavours/glitch/components/tags/tag.tsx b/app/javascript/flavours/glitch/components/tags/tag.tsx index 4dd4b89b55..fb1f133099 100644 --- a/app/javascript/flavours/glitch/components/tags/tag.tsx +++ b/app/javascript/flavours/glitch/components/tags/tag.tsx @@ -5,6 +5,7 @@ import { useIntl } from 'react-intl'; import classNames from 'classnames'; +import type { OmitUnion } from '@/flavours/glitch/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/flavours/glitch/components/tags/tags.tsx b/app/javascript/flavours/glitch/components/tags/tags.tsx index c1c120def7..a5b1f9f7c3 100644 --- a/app/javascript/flavours/glitch/components/tags/tags.tsx +++ b/app/javascript/flavours/glitch/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/flavours/glitch/features/account_timeline/components/tabs.tsx b/app/javascript/flavours/glitch/features/account_timeline/components/tabs.tsx index f525264ed4..eeb48c1c53 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/components/tabs.tsx +++ b/app/javascript/flavours/glitch/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/flavours/glitch/features/account_timeline/v2/featured_tags.tsx b/app/javascript/flavours/glitch/features/account_timeline/v2/featured_tags.tsx new file mode 100644 index 0000000000..637c18c2aa --- /dev/null +++ b/app/javascript/flavours/glitch/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 '@/flavours/glitch/actions/featured_tags'; +import { useAppHistory } from '@/flavours/glitch/components/router'; +import { Tag } from '@/flavours/glitch/components/tags/tag'; +import { useOverflow } from '@/flavours/glitch/hooks/useOverflow'; +import { selectAccountFeaturedTags } from '@/flavours/glitch/selectors/accounts'; +import { useAppDispatch, useAppSelector } from '@/flavours/glitch/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/flavours/glitch/features/account_timeline/v2/index.tsx b/app/javascript/flavours/glitch/features/account_timeline/v2/index.tsx index 115710a62d..7274bb05de 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/v2/index.tsx +++ b/app/javascript/flavours/glitch/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/flavours/glitch/features/account_timeline/v2/styles.module.scss b/app/javascript/flavours/glitch/features/account_timeline/v2/styles.module.scss index a8ba29afa5..c35b46524e 100644 --- a/app/javascript/flavours/glitch/features/account_timeline/v2/styles.module.scss +++ b/app/javascript/flavours/glitch/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/flavours/glitch/hooks/useOverflow.ts b/app/javascript/flavours/glitch/hooks/useOverflow.ts new file mode 100644 index 0000000000..e5a9ab407e --- /dev/null +++ b/app/javascript/flavours/glitch/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/flavours/glitch/selectors/accounts.ts b/app/javascript/flavours/glitch/selectors/accounts.ts index a5626cf042..aec7668e3a 100644 --- a/app/javascript/flavours/glitch/selectors/accounts.ts +++ b/app/javascript/flavours/glitch/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 'flavours/glitch/initial_state'; import { accountDefaultValues } from 'flavours/glitch/models/account'; import type { Account, AccountShape } from 'flavours/glitch/models/account'; import type { Relationship } from 'flavours/glitch/models/relationship'; +import { createAppSelector } from 'flavours/glitch/store'; import type { RootState } from 'flavours/glitch/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/flavours/glitch/utils/types.ts b/app/javascript/flavours/glitch/utils/types.ts index 019b074813..f51b3ad8b3 100644 --- a/app/javascript/flavours/glitch/utils/types.ts +++ b/app/javascript/flavours/glitch/utils/types.ts @@ -22,3 +22,5 @@ export type OmitValueType = { }; export type AnyFunction = (...args: never) => unknown; + +export type OmitUnion = TBase & Omit;