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