[Glitch] Profile redesign: Fields iteration

Port 0923e2cb26d179f197680c978495593c8b205a80 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
Echo 2026-02-03 14:02:33 +01:00 committed by Claire
parent 82f7cdcb48
commit b7443c4b9e
12 changed files with 447 additions and 380 deletions

View File

@ -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 classNames from 'classnames';
import type { OmitUnion } from '@/flavours/glitch/utils/types';
import { Icon } from '../icon';
import type { IconProp } from '../icon';
import classes from './styles.module.css'; import classes from './styles.module.css';
export interface MiniCardProps { export type MiniCardProps = OmitUnion<
label: ReactNode; ComponentPropsWithoutRef<'div'>,
value: ReactNode; {
className?: string; label: ReactNode;
hidden?: boolean; value: ReactNode;
} icon?: IconProp;
iconId?: string;
export const MiniCard: FC<MiniCardProps> = ({ iconClassName?: string;
label,
value,
className,
hidden,
}) => {
if (!label) {
return null;
} }
>;
return ( export const MiniCard = forwardRef<HTMLDivElement, MiniCardProps>(
<div (
className={classNames(classes.card, className)} { label, value, className, hidden, icon, iconId, iconClassName, ...props },
inert={hidden ? '' : undefined} ref,
> ) => {
<dt className={classes.label}>{label}</dt> if (!label) {
<dd className={classes.value}>{value}</dd> return null;
</div> }
);
}; return (
<div
{...props}
className={classNames(
classes.card,
icon && classes.cardWithIcon,
className,
)}
ref={ref}
>
{icon && (
<Icon
id={iconId ?? 'minicard'}
icon={icon}
className={classNames(classes.icon, iconClassName)}
noFill
/>
)}
<dt className={classes.label}>{label}</dt>
<dd className={classes.value}>{value}</dd>
</div>
);
},
);
MiniCard.displayName = 'MiniCard';

View File

@ -1,69 +1,37 @@
import type { FC, Key, MouseEventHandler } from 'react'; import { forwardRef } from 'react';
import type { ComponentPropsWithoutRef, Key } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { useOverflow } from '@/flavours/glitch/hooks/useOverflow'; import type { OmitUnion } from '@/flavours/glitch/utils/types';
import { MiniCard } from '.'; import { MiniCard } from '.';
import type { MiniCardProps } from '.'; import type { MiniCardProps as BaseCardProps } from '.';
import classes from './styles.module.css'; import classes from './styles.module.css';
export type MiniCardProps = BaseCardProps & {
key?: Key;
};
interface MiniCardListProps { interface MiniCardListProps {
cards?: (Pick<MiniCardProps, 'label' | 'value' | 'className'> & { cards?: MiniCardProps[];
key?: Key;
})[];
className?: string;
onOverflowClick?: MouseEventHandler;
} }
export const MiniCardList: FC<MiniCardListProps> = ({ export const MiniCardList = forwardRef<
cards = [], HTMLDListElement,
className, OmitUnion<ComponentPropsWithoutRef<'dl'>, MiniCardListProps>
onOverflowClick, >(({ cards = [], className, children, ...props }, ref) => {
}) => {
const {
wrapperRef,
listRef,
hiddenCount,
hasOverflow,
hiddenIndex,
maxWidth,
} = useOverflow();
if (!cards.length) { if (!cards.length) {
return null; return null;
} }
return ( return (
<div className={classNames(classes.wrapper, className)} ref={wrapperRef}> <dl {...props} className={classNames(classes.list, className)} ref={ref}>
<dl className={classes.list} ref={listRef} style={{ maxWidth }}> {cards.map((card, index) => (
{cards.map((card, index) => ( <MiniCard key={card.key ?? index} {...card} />
<MiniCard ))}
key={card.key ?? index} {children}
label={card.label} </dl>
value={card.value}
hidden={hasOverflow && index >= hiddenIndex}
className={card.className}
/>
))}
</dl>
{cards.length > 1 && (
<div>
<button
type='button'
className={classNames(classes.more, !hasOverflow && classes.hidden)}
onClick={onOverflowClick}
>
<FormattedMessage
id='minicard.more_items'
defaultMessage='+{count}'
values={{ count: hiddenCount }}
/>
</button>
</div>
)}
</div>
); );
}; });
MiniCardList.displayName = 'MiniCardList';

View File

@ -1,30 +1,12 @@
import type { Meta, StoryObj } from '@storybook/react-vite'; 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'; import { MiniCardList } from './list';
const meta = { const meta = {
title: 'Components/MiniCard', title: 'Components/MiniCard',
component: MiniCardList, component: MiniCardList,
args: {
onOverflowClick: action('Overflow clicked'),
},
render(args) {
return (
<div
style={{
resize: 'horizontal',
padding: '1rem',
border: '1px solid gray',
overflow: 'auto',
width: '400px',
minWidth: '100px',
}}
>
<MiniCardList {...args} />
</div>
);
},
} satisfies Meta<typeof MiniCardList>; } satisfies Meta<typeof MiniCardList>;
export default meta; export default meta;
@ -38,10 +20,12 @@ export const Default: Story = {
{ {
label: 'Website', label: 'Website',
value: <a href='https://example.com'>bowie-the-db.meow</a>, value: <a href='https://example.com'>bowie-the-db.meow</a>,
icon: LinkIcon,
}, },
{ {
label: 'Free playlists', label: 'Free playlists',
value: <a href='https://soundcloud.com/bowie-the-dj'>soundcloud.com</a>, value: <a href='https://soundcloud.com/bowie-the-dj'>soundcloud.com</a>,
icon: LinkIcon,
}, },
{ label: 'Location', value: 'Purris, France' }, { label: 'Location', value: 'Purris, France' },
], ],
@ -54,11 +38,13 @@ export const LongValue: Story = {
{ {
label: 'Username', label: 'Username',
value: 'bowie-the-dj', value: 'bowie-the-dj',
style: { maxWidth: '250px' },
}, },
{ {
label: 'Bio', label: 'Bio',
value: value:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
style: { maxWidth: '250px' },
}, },
], ],
}, },

View File

@ -1,52 +1,49 @@
.wrapper {
display: flex;
flex-wrap: nowrap;
justify-content: flex-start;
gap: 4px;
}
.list { .list {
display: flex; display: flex;
gap: 4px; gap: 4px;
overflow: hidden;
position: relative;
} }
.card, .card {
.more {
border: 1px solid var(--color-border-primary); border: 1px solid var(--color-border-primary);
padding: 8px; padding: 8px;
border-radius: 8px; border-radius: 8px;
flex-shrink: 0; flex-shrink: 0;
} display: grid;
grid-template-rows: repeat(2, 1fr);
.more { column-gap: 8px;
font-size: 13px;
line-height: 1rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-weight: 600;
appearance: none;
background: none;
aspect-ratio: 1;
height: 100%;
transition: all 300ms linear;
} }
.more:hover { .cardWithIcon {
background-color: var(--color-bg-brand-softer); grid-template-columns: 16px 1fr;
color: var(--color-text-primary);
} }
.hidden { .icon {
display: none; grid-row: span 2;
} grid-column: 1;
width: 100%;
.label { height: auto;
color: var(--color-text-secondary); align-self: center;
margin-bottom: 2px; fill: currentColor;
} }
.value { .value {
color: var(--color-text-primary); 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, .label,
@ -54,4 +51,8 @@
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
.cardWithIcon & {
grid-column: 2;
}
} }

View File

@ -1,21 +1,23 @@
import { useCallback, useMemo } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { openModal } from '@/flavours/glitch/actions/modal';
import { AccountFields } from '@/flavours/glitch/components/account_fields'; import { AccountFields } from '@/flavours/glitch/components/account_fields';
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html'; import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
import { FormattedDateWrapper } from '@/flavours/glitch/components/formatted_date'; import { FormattedDateWrapper } from '@/flavours/glitch/components/formatted_date';
import { Icon } from '@/flavours/glitch/components/icon'; import { IconButton } from '@/flavours/glitch/components/icon_button';
import { MiniCardList } from '@/flavours/glitch/components/mini_card/list'; import { MiniCard } from '@/flavours/glitch/components/mini_card';
import { useElementHandledLink } from '@/flavours/glitch/components/status/handled_link'; import { useElementHandledLink } from '@/flavours/glitch/components/status/handled_link';
import { useAccount } from '@/flavours/glitch/hooks/useAccount'; import { useAccount } from '@/flavours/glitch/hooks/useAccount';
import { useOverflowScroll } from '@/flavours/glitch/hooks/useOverflow';
import type { Account } from '@/flavours/glitch/models/account'; import type { Account } from '@/flavours/glitch/models/account';
import { useAppDispatch } from '@/flavours/glitch/store'; import { isValidUrl } from '@/flavours/glitch/utils/checks';
import IconVerified from '@/images/icons/icon_verified.svg?react'; import IconVerified from '@/images/icons/icon_verified.svg?react';
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'; import { isRedesignEnabled } from '../common';
@ -57,61 +59,94 @@ export const AccountHeaderFields: FC<{ accountId: string }> = ({
const RedesignAccountHeaderFields: FC<{ account: Account }> = ({ account }) => { const RedesignAccountHeaderFields: FC<{ account: Account }> = ({ account }) => {
const htmlHandlers = useElementHandledLink(); const htmlHandlers = useElementHandledLink();
const cards = useMemo( const intl = useIntl();
() =>
account.fields
.toArray()
.map(({ value_emojified, name_emojified, verified_at }) => ({
label: (
<>
<EmojiHTML
htmlString={name_emojified}
extraEmojis={account.emojis}
className='translate'
as='span'
{...htmlHandlers}
/>
{!!verified_at && (
<Icon
id='verified'
icon={IconVerified}
className={classes.fieldIconVerified}
noFill
/>
)}
</>
),
value: (
<EmojiHTML
as='span'
htmlString={value_emojified}
extraEmojis={account.emojis}
{...htmlHandlers}
/>
),
className: classNames(
classes.fieldCard,
!!verified_at && classes.fieldCardVerified,
),
})),
[account.emojis, account.fields, htmlHandlers],
);
const dispatch = useAppDispatch(); const {
const handleOverflowClick = useCallback(() => { bodyRef,
dispatch( canScrollLeft,
openModal({ canScrollRight,
modalType: 'ACCOUNT_FIELDS', handleLeftNav,
modalProps: { accountId: account.id }, handleRightNav,
}), handleScroll,
); } = useOverflowScroll();
}, [account.id, dispatch]);
return ( return (
<MiniCardList <div
cards={cards} className={classNames(
className={classes.fieldList} classes.fieldWrapper,
onOverflowClick={handleOverflowClick} canScrollLeft && classes.fieldWrapperLeft,
/> canScrollRight && classes.fieldWrapperRight,
)}
>
{canScrollLeft && (
<IconButton
icon='more'
iconComponent={IconLeftArrow}
title={intl.formatMessage({
id: 'account.fields.scroll_prev',
defaultMessage: 'Show previous',
})}
className={classes.fieldArrowButton}
onClick={handleLeftNav}
/>
)}
<dl ref={bodyRef} className={classes.fieldList} onScroll={handleScroll}>
{account.fields.map(
(
{ name, name_emojified, value_emojified, value_plain, verified_at },
key,
) => (
<MiniCard
key={key}
label={
<EmojiHTML
htmlString={name_emojified}
extraEmojis={account.emojis}
className='translate'
as='span'
title={name}
{...htmlHandlers}
/>
}
value={
<EmojiHTML
as='span'
htmlString={value_emojified}
extraEmojis={account.emojis}
title={value_plain ?? undefined}
{...htmlHandlers}
/>
}
icon={fieldIcon(verified_at, value_plain)}
className={classNames(
classes.fieldCard,
verified_at && classes.fieldCardVerified,
)}
/>
),
)}
</dl>
{canScrollRight && (
<IconButton
icon='more'
iconComponent={IconRightArrow}
title={intl.formatMessage({
id: 'account.fields.scroll_next',
defaultMessage: 'Show next',
})}
className={classes.fieldArrowButton}
onClick={handleRightNav}
/>
)}
</div>
); );
}; };
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;
}

View File

@ -1,95 +0,0 @@
import type { FC } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { DisplayName } from '@/flavours/glitch/components/display_name';
import { AnimateEmojiProvider } from '@/flavours/glitch/components/emoji/context';
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
import { Icon } from '@/flavours/glitch/components/icon';
import { IconButton } from '@/flavours/glitch/components/icon_button';
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
import { useElementHandledLink } from '@/flavours/glitch/components/status/handled_link';
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
import IconVerified from '@/images/icons/icon_verified.svg?react';
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 (
<div className='modal-root__modal dialog-modal'>
<LoadingIndicator />
</div>
);
}
return (
<div className='modal-root__modal dialog-modal'>
<div className='dialog-modal__header'>
<IconButton
icon='close'
className={classes.modalCloseButton}
onClick={onClose}
iconComponent={CloseIcon}
title={intl.formatMessage({
id: 'account_fields_modal.close',
defaultMessage: 'Close',
})}
/>
<span className={`${classes.modalTitle} dialog-modal__header__title`}>
<FormattedMessage
id='account_fields_modal.title'
defaultMessage="{name}'s info"
values={{
name: <DisplayName account={account} variant='simple' />,
}}
/>
</span>
</div>
<div className='dialog-modal__content'>
<AnimateEmojiProvider>
<dl className={classes.modalFieldsList}>
{account.fields.map((field, index) => (
<div
key={index}
className={`${classes.modalFieldItem} ${classes.fieldCard}`}
>
<EmojiHTML
as='dt'
htmlString={field.name_emojified}
extraEmojis={account.emojis}
className='translate'
{...htmlHandlers}
/>
<dd>
<EmojiHTML
as='span'
htmlString={field.value_emojified}
extraEmojis={account.emojis}
{...htmlHandlers}
/>
{!!field.verified_at && (
<Icon
id='verified'
icon={IconVerified}
className={classes.fieldIconVerified}
noFill
/>
)}
</dd>
</div>
))}
</dl>
</AnimateEmojiProvider>
</div>
</div>
);
};

View File

@ -125,36 +125,112 @@ svg.badgeIcon {
} }
} }
.fieldList { .fieldWrapper {
margin-top: 16px; 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 { .fieldCard {
position: relative; scroll-snap-align: start;
a { &:focus-visible,
color: var(--color-text-brand); &:focus-within {
text-decoration: none; outline: var(--outline-focus-default);
outline-offset: -2px;
}
:is(dt, dd) {
max-width: 200px;
} }
} }
.fieldCardVerified { .fieldCardVerified {
background-color: var(--color-bg-brand-softer); background-color: var(--color-bg-brand-softer);
dt {
padding-right: 1rem;
}
.fieldIconVerified {
position: absolute;
top: 4px;
right: 4px;
}
} }
.fieldIconVerified { .fieldArrowButton {
width: 1rem; position: absolute;
height: 1rem; 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 { .fieldNumbersWrapper {

View File

@ -9,7 +9,7 @@ import { useParams } from 'react-router';
import { fetchFeaturedTags } from '@/flavours/glitch/actions/featured_tags'; import { fetchFeaturedTags } from '@/flavours/glitch/actions/featured_tags';
import { useAppHistory } from '@/flavours/glitch/components/router'; import { useAppHistory } from '@/flavours/glitch/components/router';
import { Tag } from '@/flavours/glitch/components/tags/tag'; import { Tag } from '@/flavours/glitch/components/tags/tag';
import { useOverflow } from '@/flavours/glitch/hooks/useOverflow'; import { useOverflowButton } from '@/flavours/glitch/hooks/useOverflow';
import { selectAccountFeaturedTags } from '@/flavours/glitch/selectors/accounts'; import { selectAccountFeaturedTags } from '@/flavours/glitch/selectors/accounts';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store'; import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
@ -30,7 +30,7 @@ export const FeaturedTags: FC<{ accountId: string }> = ({ accountId }) => {
// Get list of tags with overflow handling. // Get list of tags with overflow handling.
const [showOverflow, setShowOverflow] = useState(false); const [showOverflow, setShowOverflow] = useState(false);
const { hiddenCount, wrapperRef, listRef, hiddenIndex, maxWidth } = const { hiddenCount, wrapperRef, listRef, hiddenIndex, maxWidth } =
useOverflow(); useOverflowButton();
// Handle whether to show all tags. // Handle whether to show all tags.
const handleOverflowClick: MouseEventHandler = useCallback(() => { const handleOverflowClick: MouseEventHandler = useCallback(() => {

View File

@ -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 { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useOverflowScroll } from '@/flavours/glitch/hooks/useOverflow';
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react'; import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.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 // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
state.settings.getIn(['dismissed_banners', DISMISSIBLE_ID]) as boolean, state.settings.getIn(['dismissed_banners', DISMISSIBLE_ID]) as boolean,
); );
const bodyRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(true);
useEffect(() => { useEffect(() => {
void dispatch(fetchSuggestions()); void dispatch(fetchSuggestions());
}, [dispatch]); }, [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(() => { const handleDismiss = useCallback(() => {
dispatch(changeSetting(['dismissed_banners', DISMISSIBLE_ID], true)); dispatch(changeSetting(['dismissed_banners', DISMISSIBLE_ID], true));
}, [dispatch]); }, [dispatch]);
const {
bodyRef,
handleScroll,
canScrollLeft,
canScrollRight,
handleLeftNav,
handleRightNav,
} = useOverflowScroll({ absoluteDistance: true });
if (dismissed || (!isLoading && suggestions.length === 0)) { if (dismissed || (!isLoading && suggestions.length === 0)) {
return null; return null;
} }

View File

@ -96,7 +96,6 @@ export const MODAL_COMPONENTS = {
'ANNUAL_REPORT': AnnualReportModal, 'ANNUAL_REPORT': AnnualReportModal,
'COMPOSE_PRIVACY': () => Promise.resolve({ default: VisibilityModal }), 'COMPOSE_PRIVACY': () => Promise.resolve({ default: VisibilityModal }),
'ACCOUNT_NOTE': () => import('@/flavours/glitch/features/account_timeline/modals/note_modal').then(module => ({ default: module.AccountNoteModal })), 'ACCOUNT_NOTE': () => import('@/flavours/glitch/features/account_timeline/modals/note_modal').then(module => ({ default: module.AccountNoteModal })),
'ACCOUNT_FIELDS': () => import('flavours/glitch/features/account_timeline/components/fields_modal.tsx').then(module => ({ default: module.AccountFieldsModal })),
}; };
export default class ModalRoot extends PureComponent { export default class ModalRoot extends PureComponent {

View File

@ -1,14 +1,15 @@
import type { MutableRefObject, RefCallback } from 'react';
import { useState, useRef, useCallback, useEffect } 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 * 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, * 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 * 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. * requires both position:relative and overflow:hidden styles to work correctly.
*/ */
export function useOverflow({ export function useOverflowButton({
autoResize, autoResize,
padding = 4, padding = 4,
}: { autoResize?: boolean; padding?: number } = {}) { }: { autoResize?: boolean; padding?: number } = {}) {
@ -76,6 +77,111 @@ export function useOverflow({
} }
}, [autoResize, maxWidth]); }, [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<HTMLElement | null>(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<HTMLElement> | MutableRefObject<HTMLElement | null>;
onWrapperRef?:
| RefCallback<HTMLElement>
| MutableRefObject<HTMLElement | null>;
}) {
// This is the item container element.
const listRef = useRef<HTMLElement | null>(null);
// Set up observers to watch for size and content changes. // Set up observers to watch for size and content changes.
const resizeObserverRef = useRef<ResizeObserver | null>(null); const resizeObserverRef = useRef<ResizeObserver | null>(null);
const mutationObserverRef = useRef<MutationObserver | null>(null); const mutationObserverRef = useRef<MutationObserver | null>(null);
@ -83,10 +189,10 @@ export function useOverflow({
// Helper to get or create the resize observer. // Helper to get or create the resize observer.
const resizeObserver = useCallback(() => { const resizeObserver = useCallback(() => {
const observer = (resizeObserverRef.current ??= new ResizeObserver( const observer = (resizeObserverRef.current ??= new ResizeObserver(
handleRecalculate, onRecalculate,
)); ));
return observer; return observer;
}, [handleRecalculate]); }, [onRecalculate]);
// Iterate through children and observe them for size changes. // Iterate through children and observe them for size changes.
const handleChildrenChange = useCallback(() => { const handleChildrenChange = useCallback(() => {
@ -100,8 +206,8 @@ export function useOverflow({
} }
} }
} }
handleRecalculate(); onRecalculate();
}, [handleRecalculate, resizeObserver]); }, [onRecalculate, resizeObserver]);
// Helper to get or create the mutation observer. // Helper to get or create the mutation observer.
const mutationObserver = useCallback(() => { const mutationObserver = useCallback(() => {
@ -129,9 +235,14 @@ export function useOverflow({
if (node) { if (node) {
wrapperRef.current = node; wrapperRef.current = node;
handleObserve(); 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. // If there are changes to the children, recalculate which are visible.
@ -140,9 +251,14 @@ export function useOverflow({
if (node) { if (node) {
listRef.current = node; listRef.current = node;
handleObserve(); handleObserve();
if (typeof onListRef === 'function') {
onListRef(node);
} else if (onListRef && 'current' in onListRef) {
onListRef.current = node;
}
} }
}, },
[handleObserve], [handleObserve, onListRef],
); );
useEffect(() => { useEffect(() => {
@ -161,12 +277,7 @@ export function useOverflow({
}, [handleObserve]); }, [handleObserve]);
return { return {
hiddenCount, wrapperRefCallback,
hasOverflow: hiddenCount > 0, listRefCallback,
wrapperRef: wrapperRefCallback,
hiddenIndex,
maxWidth,
listRef: listRefCallback,
recalculate: handleRecalculate,
}; };
} }

View File

@ -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;
}
}