Adds DisplayName component (#35985)

This commit is contained in:
Echo 2025-09-04 12:09:27 +02:00 committed by GitHub
parent d7d83d44e6
commit 42be0ca0eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 259 additions and 33 deletions

View File

@ -12,13 +12,14 @@ import { initialize, mswLoader } from 'msw-storybook-addon';
import { action } from 'storybook/actions';
import type { LocaleData } from '@/mastodon/locales';
import { reducerWithInitialState, rootReducer } from '@/mastodon/reducers';
import { reducerWithInitialState } from '@/mastodon/reducers';
import { defaultMiddleware } from '@/mastodon/store/store';
import { mockHandlers, unhandledRequestHandler } from '@/testing/api';
// If you want to run the dark theme during development,
// you can change the below to `/application.scss`
import '../app/javascript/styles/mastodon-light.scss';
import './styles.css';
const localeFiles = import.meta.glob('@/mastodon/locales/*.json', {
query: { as: 'json' },
@ -49,12 +50,17 @@ const preview: Preview = {
locale: 'en',
},
decorators: [
(Story, { parameters }) => {
(Story, { parameters, globals }) => {
const { locale } = globals as { locale: string };
const { state = {} } = parameters;
let reducer = rootReducer;
if (typeof state === 'object' && state) {
reducer = reducerWithInitialState(state as Record<string, unknown>);
}
const reducer = reducerWithInitialState(
{
meta: {
locale,
},
},
state as Record<string, unknown>,
);
const store = configureStore({
reducer,
middleware(getDefaultMiddleware) {

8
.storybook/styles.css Normal file
View File

@ -0,0 +1,8 @@
a {
color: inherit;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}

View File

@ -0,0 +1,81 @@
import type { ComponentProps } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { accountFactoryState } from '@/testing/factories';
import { DisplayName, LinkedDisplayName } from './index';
type PageProps = Omit<ComponentProps<typeof DisplayName>, 'account'> & {
name: string;
username: string;
loading: boolean;
};
const meta = {
title: 'Components/DisplayName',
args: {
username: 'mastodon@mastodon.social',
name: 'Test User 🧪',
loading: false,
simple: false,
noDomain: false,
localDomain: 'mastodon.social',
},
tags: [],
render({ name, username, loading, ...args }) {
const account = !loading
? accountFactoryState({
display_name: name,
acct: username,
})
: undefined;
return <DisplayName {...args} account={account} />;
},
} satisfies Meta<PageProps>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {},
};
export const Loading: Story = {
args: {
loading: true,
},
};
export const NoDomain: Story = {
args: {
noDomain: true,
},
};
export const Simple: Story = {
args: {
simple: true,
},
};
export const LocalUser: Story = {
args: {
username: 'localuser',
name: 'Local User',
localDomain: '',
},
};
export const Linked: Story = {
render({ name, username, loading, ...args }) {
const account = !loading
? accountFactoryState({
display_name: name,
acct: username,
})
: undefined;
return <LinkedDisplayName {...args} account={account} />;
},
};

View File

@ -0,0 +1,122 @@
import type { ComponentPropsWithoutRef, FC } from 'react';
import { useMemo } from 'react';
import classNames from 'classnames';
import type { LinkProps } from 'react-router-dom';
import { Link } from 'react-router-dom';
import { EmojiHTML } from '@/mastodon/features/emoji/emoji_html';
import type { Account } from '@/mastodon/models/account';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import { Skeleton } from '../skeleton';
interface Props {
account?: Account;
localDomain?: string;
simple?: boolean;
noDomain?: boolean;
}
export const DisplayName: FC<Props & ComponentPropsWithoutRef<'span'>> = ({
account,
localDomain,
simple = false,
noDomain = false,
className,
...props
}) => {
const username = useMemo(() => {
if (!account || noDomain) {
return null;
}
let acct = account.get('acct');
if (!acct.includes('@') && localDomain) {
acct = `${acct}@${localDomain}`;
}
return `@${acct}`;
}, [account, localDomain, noDomain]);
if (!account) {
if (simple) {
return null;
}
return (
<span {...props} className={classNames('display-name', className)}>
<bdi>
<strong className='display-name__html'>
<Skeleton width='10ch' />
</strong>
</bdi>
{!noDomain && (
<span className='display-name__account'>
&nbsp;
<Skeleton width='7ch' />
</span>
)}
</span>
);
}
const accountName = isModernEmojiEnabled()
? account.get('display_name')
: account.get('display_name_html');
if (simple) {
return (
<bdi>
<EmojiHTML {...props} htmlString={accountName} shallow as='span' />
</bdi>
);
}
return (
<span {...props} className={classNames('display-name', className)}>
<bdi>
<EmojiHTML
className='display-name__html'
htmlString={accountName}
shallow
as='strong'
/>
</bdi>
{username && (
<span className='display-name__account'>&nbsp;{username}</span>
)}
</span>
);
};
export const LinkedDisplayName: FC<
Props & { asProps?: ComponentPropsWithoutRef<'span'> } & Partial<LinkProps>
> = ({
account,
asProps = {},
className,
localDomain,
simple,
noDomain,
...linkProps
}) => {
const displayProps = {
account,
className,
localDomain,
simple,
noDomain,
...asProps,
};
if (!account) {
return <DisplayName {...displayProps} />;
}
return (
<Link
to={`/@${account.acct}`}
title={`@${account.acct}`}
data-hover-card-account={account.id}
{...linkProps}
>
<DisplayName {...displayProps} />
</Link>
);
};

View File

@ -1,7 +1,5 @@
import type { ComponentPropsWithoutRef, ElementType } from 'react';
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import { useEmojify } from './hooks';
import type { CustomEmojiMapArg } from './types';
@ -12,16 +10,21 @@ type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
htmlString: string;
extraEmojis?: CustomEmojiMapArg;
as?: Element;
shallow?: boolean;
};
export const ModernEmojiHTML = <Element extends ElementType>({
export const EmojiHTML = ({
extraEmojis,
htmlString,
as: asElement, // Rename for syntax highlighting
as: Wrapper = 'div', // Rename for syntax highlighting
shallow,
...props
}: EmojiHTMLProps<Element>) => {
const Wrapper = asElement ?? 'div';
const emojifiedHtml = useEmojify(htmlString, extraEmojis);
}: EmojiHTMLProps<ElementType>) => {
const emojifiedHtml = useEmojify({
text: htmlString,
extraEmojis,
deep: !shallow,
});
if (emojifiedHtml === null) {
return null;
@ -31,14 +34,3 @@ export const ModernEmojiHTML = <Element extends ElementType>({
<Wrapper {...props} dangerouslySetInnerHTML={{ __html: emojifiedHtml }} />
);
};
export const EmojiHTML = <Element extends ElementType>(
props: EmojiHTMLProps<Element>,
) => {
if (isModernEmojiEnabled()) {
return <ModernEmojiHTML {...props} />;
}
const { as: asElement, htmlString, extraEmojis, ...rest } = props;
const Wrapper = asElement ?? 'div';
return <Wrapper {...rest} dangerouslySetInnerHTML={{ __html: htmlString }} />;
};

View File

@ -8,6 +8,7 @@ import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
import { toSupportedLocale } from './locale';
import { determineEmojiMode } from './mode';
import { emojifyElement, emojifyText } from './render';
import type {
CustomEmojiMapArg,
EmojiAppState,
@ -15,7 +16,17 @@ import type {
} from './types';
import { stringHasAnyEmoji } from './utils';
export function useEmojify(text: string, extraEmojis?: CustomEmojiMapArg) {
interface UseEmojifyOptions {
text: string;
extraEmojis?: CustomEmojiMapArg;
deep?: boolean;
}
export function useEmojify({
text,
extraEmojis,
deep = true,
}: UseEmojifyOptions) {
const [emojifiedText, setEmojifiedText] = useState<string | null>(null);
const appState = useEmojiAppState();
@ -36,17 +47,23 @@ export function useEmojify(text: string, extraEmojis?: CustomEmojiMapArg) {
const emojify = useCallback(
async (input: string) => {
const wrapper = document.createElement('div');
wrapper.innerHTML = input;
const { emojifyElement } = await import('./render');
const result = await emojifyElement(wrapper, appState, extra);
let result: string | null = null;
if (deep) {
const wrapper = document.createElement('div');
wrapper.innerHTML = input;
if (await emojifyElement(wrapper, appState, extra)) {
result = wrapper.innerHTML;
}
} else {
result = await emojifyText(text, appState, extra);
}
if (result) {
setEmojifiedText(result.innerHTML);
setEmojifiedText(result);
} else {
setEmojifiedText(input);
}
},
[appState, extra],
[appState, deep, extra, text],
);
useLayoutEffect(() => {
if (isModernEmojiEnabled() && !!text.trim() && stringHasAnyEmoji(text)) {

View File

@ -101,9 +101,9 @@ const RootStateRecord = ImmutableRecord(initialRootState, 'RootState');
export const rootReducer = combineReducers(reducers, RootStateRecord);
export function reducerWithInitialState(
stateOverrides: Record<string, unknown> = {},
...stateOverrides: Record<string, unknown>[]
) {
const initialStateRecord = mergeDeep(initialRootState, stateOverrides);
const initialStateRecord = mergeDeep(initialRootState, ...stateOverrides);
const PatchedRootStateRecord = ImmutableRecord(
initialStateRecord,
'RootState',