Merge remote-tracking branch 'parent/main' into kbtopic-remove-quote

This commit is contained in:
KMY 2025-06-12 10:17:21 +09:00
commit f3c3ea42c2
301 changed files with 6618 additions and 3070 deletions

View file

@ -14,6 +14,8 @@ import {
muteAccount,
unmuteAccount,
followAccountSuccess,
unpinAccount,
pinAccount,
} from 'mastodon/actions/accounts';
import { showAlertForError } from 'mastodon/actions/alerts';
import { openModal } from 'mastodon/actions/modal';
@ -64,7 +66,7 @@ const messages = defineMessages({
},
});
export const Account: React.FC<{
interface AccountProps {
size?: number;
id: string;
hidden?: boolean;
@ -73,7 +75,10 @@ export const Account: React.FC<{
withBio?: boolean;
hideButtons?: boolean;
children?: ReactNode;
}> = ({
withMenu?: boolean;
}
export const Account: React.FC<AccountProps> = ({
id,
size = 46,
hidden,
@ -82,6 +87,7 @@ export const Account: React.FC<{
withBio,
hideButtons,
children,
withMenu = true,
}) => {
const intl = useIntl();
const { signedIn } = useIdentity();
@ -132,8 +138,6 @@ export const Account: React.FC<{
},
];
} else if (defaultAction !== 'block') {
arr = [];
if (isRemote && accountUrl) {
arr.push({
text: intl.formatMessage(messages.openOriginalPage),
@ -186,6 +190,25 @@ export const Account: React.FC<{
text: intl.formatMessage(messages.addToLists),
action: handleAddToLists,
});
if (id !== me && (relationship?.following || relationship?.requested)) {
const handleEndorseToggle = () => {
if (relationship.endorsed) {
dispatch(unpinAccount(id));
} else {
dispatch(pinAccount(id));
}
};
arr.push({
text: intl.formatMessage(
// Defined in features/account_timeline/components/account_header.tsx
relationship.endorsed
? { id: 'account.unendorse' }
: { id: 'account.endorse' },
),
action: handleEndorseToggle,
});
}
}
}
@ -210,9 +233,10 @@ export const Account: React.FC<{
);
}
let button: React.ReactNode, dropdown: React.ReactNode;
let button: React.ReactNode;
let dropdown: React.ReactNode;
if (menu.length > 0) {
if (menu.length > 0 && withMenu) {
dropdown = (
<Dropdown
items={menu}
@ -268,43 +292,69 @@ export const Account: React.FC<{
}
return (
<div className={classNames('account', { 'account--minimal': minimal })}>
<div className='account__wrapper'>
<Link
className='account__display-name'
title={account?.acct}
to={`/@${account?.acct}`}
data-hover-card-account={id}
>
<div className='account__avatar-wrapper'>
{account ? (
<Avatar account={account} size={size} />
<div
className={classNames('account', {
'account--minimal': minimal,
})}
>
<div
className={classNames('account__wrapper', {
'account__wrapper--with-bio': account && withBio,
})}
>
<div className='account__info-wrapper'>
<Link
className='account__display-name'
title={account?.acct}
to={`/@${account?.acct}`}
data-hover-card-account={id}
>
<div className='account__avatar-wrapper'>
{account ? (
<Avatar account={account} size={size} />
) : (
<Skeleton width={size} height={size} />
)}
</div>
<div className='account__contents'>
<DisplayName account={account} />
{!minimal && (
<div className='account__details'>
{account ? (
<>
<ShortNumber
value={account.followers_count}
renderer={FollowersCounter}
isHide={account.other_settings.hide_followers_count}
/>{' '}
{verification} {muteTimeRemaining}
</>
) : (
<Skeleton width='7ch' />
)}
</div>
)}
</div>
</Link>
{account &&
withBio &&
(account.note.length > 0 ? (
<div
className='account__note translate'
dangerouslySetInnerHTML={{ __html: account.note_emojified }}
/>
) : (
<Skeleton width={size} height={size} />
)}
</div>
<div className='account__contents'>
<DisplayName account={account} />
{!minimal && (
<div className='account__details'>
{account ? (
<>
<ShortNumber
value={account.followers_count}
renderer={FollowersCounter}
isHide={account.other_settings.hide_followers_count}
/>{' '}
{verification} {muteTimeRemaining}
</>
) : (
<Skeleton width='7ch' />
)}
<div className='account__note account__note--missing'>
<FormattedMessage
id='account.no_bio'
defaultMessage='No description provided.'
/>
</div>
)}
</div>
</Link>
))}
</div>
{!minimal && children && (
<div>
@ -322,22 +372,6 @@ export const Account: React.FC<{
</div>
)}
</div>
{account &&
withBio &&
(account.note.length > 0 ? (
<div
className='account__note translate'
dangerouslySetInnerHTML={{ __html: account.note_emojified }}
/>
) : (
<div className='account__note account__note--missing'>
<FormattedMessage
id='account.no_bio'
defaultMessage='No description provided.'
/>
</div>
))}
</div>
);
};

View file

@ -18,6 +18,7 @@ interface Props {
withLink?: boolean;
counter?: number | string;
counterBorderColor?: string;
className?: string;
}
export const Avatar: React.FC<Props> = ({
@ -27,6 +28,7 @@ export const Avatar: React.FC<Props> = ({
inline = false,
withLink = false,
style: styleFromParent,
className,
counter,
counterBorderColor,
}) => {
@ -52,7 +54,7 @@ export const Avatar: React.FC<Props> = ({
const avatar = (
<div
className={classNames('account__avatar', {
className={classNames(className, 'account__avatar', {
'account__avatar--inline': inline,
'account__avatar--loading': loading,
})}

View file

@ -0,0 +1,97 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { fn, expect } from 'storybook/test';
import { Button } from '.';
const meta = {
title: 'Components/Button',
component: Button,
args: {
secondary: false,
compact: false,
dangerous: false,
disabled: false,
onClick: fn(),
},
argTypes: {
text: {
control: 'text',
type: 'string',
description:
'Alternative way of specifying the button label. Will override `children` if provided.',
},
type: {
type: 'string',
control: 'text',
table: {
type: { summary: 'string' },
},
},
},
tags: ['test'],
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
const buttonTest: Story['play'] = async ({ args, canvas, userEvent }) => {
await userEvent.click(canvas.getByRole('button'));
await expect(args.onClick).toHaveBeenCalled();
};
const disabledButtonTest: Story['play'] = async ({
args,
canvas,
userEvent,
}) => {
await userEvent.click(canvas.getByRole('button'));
await expect(args.onClick).not.toHaveBeenCalled();
};
export const Primary: Story = {
args: {
children: 'Primary button',
},
play: buttonTest,
};
export const Secondary: Story = {
args: {
secondary: true,
children: 'Secondary button',
},
play: buttonTest,
};
export const Compact: Story = {
args: {
compact: true,
children: 'Compact button',
},
play: buttonTest,
};
export const Dangerous: Story = {
args: {
dangerous: true,
children: 'Dangerous button',
},
play: buttonTest,
};
export const PrimaryDisabled: Story = {
args: {
...Primary.args,
disabled: true,
},
play: disabledButtonTest,
};
export const SecondaryDisabled: Story = {
args: {
...Secondary.args,
disabled: true,
},
play: disabledButtonTest,
};

View file

@ -22,6 +22,10 @@ interface PropsWithText extends BaseProps {
type Props = PropsWithText | PropsChildren;
/**
* Primary UI component for user interaction that doesn't result in navigation.
*/
export const Button: React.FC<Props> = ({
type = 'button',
onClick,

View file

@ -9,7 +9,8 @@ import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.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 CloseIcon from '@/material-icons/400-24px/close.svg?react';
import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
import UnfoldLessIcon from '@/material-icons/400-24px/unfold_less.svg?react';
import UnfoldMoreIcon from '@/material-icons/400-24px/unfold_more.svg?react';
import type { IconProp } from 'mastodon/components/icon';
import { Icon } from 'mastodon/components/icon';
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
@ -238,7 +239,10 @@ export const ColumnHeader: React.FC<Props> = ({
onClick={handleToggleClick}
>
<i className='icon-with-badge'>
<Icon id='sliders' icon={SettingsIcon} />
<Icon
id='sliders'
icon={collapsed ? UnfoldMoreIcon : UnfoldLessIcon}
/>
{collapseIssues && <i className='icon-with-badge__issue-badge' />}
</i>
</button>

View file

@ -1,5 +1,12 @@
import type { ComponentPropsWithRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
useId,
} from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
@ -11,11 +18,14 @@ import { animated, useSpring } from '@react-spring/web';
import { useDrag } from '@use-gesture/react';
import { expandAccountFeaturedTimeline } from '@/mastodon/actions/timelines';
import { Icon } from '@/mastodon/components/icon';
import { IconButton } from '@/mastodon/components/icon_button';
import StatusContainer from '@/mastodon/containers/status_container';
import { usePrevious } from '@/mastodon/hooks/usePrevious';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import PushPinIcon from '@/material-icons/400-24px/push_pin.svg?react';
const messages = defineMessages({
previous: { id: 'featured_carousel.previous', defaultMessage: 'Previous' },
@ -31,6 +41,7 @@ export const FeaturedCarousel: React.FC<{
tagged?: string;
}> = ({ accountId, tagged }) => {
const intl = useIntl();
const accessibilityId = useId();
// Load pinned statuses
const dispatch = useAppDispatch();
@ -74,6 +85,7 @@ export const FeaturedCarousel: React.FC<{
const [currentSlideHeight, setCurrentSlideHeight] = useState(
wrapperRef.current?.scrollHeight ?? 0,
);
const previousSlideHeight = usePrevious(currentSlideHeight);
const observerRef = useRef<ResizeObserver>(
new ResizeObserver(() => {
handleSlideChange(0);
@ -82,8 +94,10 @@ export const FeaturedCarousel: React.FC<{
const wrapperStyles = useSpring({
x: `-${slideIndex * 100}%`,
height: currentSlideHeight,
// Don't animate from zero to the height of the initial slide
immediate: !previousSlideHeight,
});
useEffect(() => {
useLayoutEffect(() => {
// Update slide height when the component mounts
if (currentSlideHeight === 0) {
handleSlideChange(0);
@ -110,11 +124,15 @@ export const FeaturedCarousel: React.FC<{
className='featured-carousel'
{...bind()}
aria-roledescription='carousel'
aria-labelledby='featured-carousel-title'
aria-labelledby={`${accessibilityId}-title`}
role='region'
>
<div className='featured-carousel__header'>
<h4 className='featured-carousel__title' id='featured-carousel-title'>
<h4
className='featured-carousel__title'
id={`${accessibilityId}-title`}
>
<Icon id='thumb-tack' icon={PushPinIcon} />
<FormattedMessage
id='featured_carousel.header'
defaultMessage='{count, plural, one {Pinned Post} other {Pinned Posts}}'

View file

@ -45,6 +45,19 @@ export const HoverCardAccount = forwardRef<
const { familiarFollowers } = useFetchFamiliarFollowers({ accountId });
const relationship = useAppSelector((state) =>
accountId ? state.relationships.get(accountId) : undefined,
);
const isMutual = relationship?.followed_by && relationship.following;
const isFollower = relationship?.followed_by;
const hasRelationshipLoaded = !!relationship;
const shouldDisplayFamiliarFollowers =
familiarFollowers.length > 0 &&
hasRelationshipLoaded &&
!isMutual &&
!isFollower;
return (
<div
ref={ref}
@ -86,7 +99,7 @@ export const HoverCardAccount = forwardRef<
renderer={FollowersCounter}
isHide={account.other_settings.hide_followers_count}
/>
{familiarFollowers.length > 0 && (
{shouldDisplayFamiliarFollowers && (
<>
&middot;
<div className='hover-card__familiar-followers'>
@ -102,6 +115,22 @@ export const HoverCardAccount = forwardRef<
</div>
</>
)}
{(isMutual || isFollower) && (
<>
&middot;
{isMutual ? (
<FormattedMessage
id='account.mutual'
defaultMessage='You follow each other'
/>
) : (
<FormattedMessage
id='account.follows_you'
defaultMessage='Follows you'
/>
)}
</>
)}
</div>
<FollowButton accountId={accountId} />

View file

@ -28,6 +28,7 @@ interface Props {
href?: string;
ariaHidden?: boolean;
data_id?: string;
ariaControls?: string;
}
export const IconButton = forwardRef<HTMLButtonElement, Props>(
@ -54,6 +55,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
tabIndex = 0,
ariaHidden = false,
data_id = undefined,
ariaControls,
},
buttonRef,
) => {
@ -155,6 +157,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(
aria-label={title}
aria-expanded={expanded}
aria-hidden={ariaHidden}
aria-controls={ariaControls}
title={title}
className={classes}
onClick={handleClick}

View file

@ -7,7 +7,7 @@ interface Props {
id: string;
icon: IconProp;
count: number;
issueBadge: boolean;
issueBadge?: boolean;
className: string;
}
export const IconWithBadge: React.FC<Props> = ({

View file

@ -14,7 +14,6 @@ import { fetchPoll, vote } from 'mastodon/actions/polls';
import { Icon } from 'mastodon/components/icon';
import emojify from 'mastodon/features/emoji/emoji';
import { useIdentity } from 'mastodon/identity_context';
import { reduceMotion } from 'mastodon/initial_state';
import { makeEmojiMap } from 'mastodon/models/custom_emoji';
import type * as Model from 'mastodon/models/poll';
import type { Status } from 'mastodon/models/status';
@ -265,7 +264,6 @@ const PollOption: React.FC<PollOptionProps> = (props) => {
to: {
width: `${percent}%`,
},
immediate: reduceMotion,
});
return (

View file

@ -624,11 +624,11 @@ class Status extends ImmutablePureComponent {
{...statusContentProps}
/>
{children}
{media}
{hashtagBar}
{emojiReactionsBar}
{children}
</>
)}