Replace DisplayName with new component

This commit is contained in:
ChaosExAnima 2025-07-30 12:40:00 +02:00
parent 6bca52453a
commit f84982e5cf
No known key found for this signature in database
GPG Key ID: 8F2B333100FB6117
9 changed files with 188 additions and 178 deletions

View File

@ -1,27 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<DisplayName /> > renders display name + account name 1`] = `
<span
className="display-name"
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
<bdi>
<strong
className="display-name__html"
dangerouslySetInnerHTML={
{
"__html": "<p>Foo</p>",
}
}
/>
</bdi>
<span
className="display-name__account"
>
@
bar@baz
</span>
</span>
`;

View File

@ -1,19 +0,0 @@
import { fromJS } from 'immutable';
import renderer from 'react-test-renderer';
import { DisplayName } from '../display_name';
describe('<DisplayName />', () => {
it('renders display name + account name', () => {
const account = fromJS({
username: 'bar',
acct: 'bar@baz',
display_name_html: '<p>Foo</p>',
});
const component = renderer.create(<DisplayName account={account} />);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
});

View File

@ -1,122 +0,0 @@
import React from 'react';
import type { List } from 'immutable';
import type { Account } from 'mastodon/models/account';
import { autoPlayGif } from '../initial_state';
import { Skeleton } from './skeleton';
interface Props {
account?: Account;
others?: List<Account>;
localDomain?: string;
}
export class DisplayName extends React.PureComponent<Props> {
handleMouseEnter: React.ReactEventHandler<HTMLSpanElement> = ({
currentTarget,
}) => {
if (autoPlayGif) {
return;
}
const emojis =
currentTarget.querySelectorAll<HTMLImageElement>('img.custom-emoji');
emojis.forEach((emoji) => {
const originalSrc = emoji.getAttribute('data-original');
if (originalSrc != null) emoji.src = originalSrc;
});
};
handleMouseLeave: React.ReactEventHandler<HTMLSpanElement> = ({
currentTarget,
}) => {
if (autoPlayGif) {
return;
}
const emojis =
currentTarget.querySelectorAll<HTMLImageElement>('img.custom-emoji');
emojis.forEach((emoji) => {
const staticSrc = emoji.getAttribute('data-static');
if (staticSrc != null) emoji.src = staticSrc;
});
};
render() {
const { others, localDomain } = this.props;
let displayName: React.ReactNode,
suffix: React.ReactNode,
account: Account | undefined;
if (others && others.size > 0) {
account = others.first();
} else if (this.props.account) {
account = this.props.account;
}
if (others && others.size > 1) {
displayName = others
.take(2)
.map((a) => (
<bdi key={a.get('id')}>
<strong
className='display-name__html'
dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }}
/>
</bdi>
))
.reduce((prev, cur) => [prev, ', ', cur]);
if (others.size - 2 > 0) {
suffix = `+${others.size - 2}`;
}
} else if (account) {
let acct = account.get('acct');
if (!acct.includes('@') && localDomain) {
acct = `${acct}@${localDomain}`;
}
displayName = (
<bdi>
<strong
className='display-name__html'
dangerouslySetInnerHTML={{
__html: account.get('display_name_html'),
}}
/>
</bdi>
);
suffix = <span className='display-name__account'>@{acct}</span>;
} else {
displayName = (
<bdi>
<strong className='display-name__html'>
<Skeleton width='10ch' />
</strong>
</bdi>
);
suffix = (
<span className='display-name__account'>
<Skeleton width='7ch' />
</span>
);
}
return (
<span
className='display-name'
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
>
{displayName} {suffix}
</span>
);
}
}

View File

@ -0,0 +1,69 @@
import type { ComponentProps } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { accountFactoryState } from '@/testing/factories';
import { DisplayName } from '.';
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: 'locale',
name: 'Local User',
localDomain: '',
},
};

View File

@ -0,0 +1,87 @@
import type { ComponentPropsWithoutRef, FC } from 'react';
import { useMemo } from 'react';
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={`display-name ${className}`}>
<bdi>
<strong className='display-name__html'>
<Skeleton width='10ch' />
</strong>
</bdi>
{!noDomain && (
<span className='display-name__account'>
<Skeleton width='7ch' />
</span>
)}
</span>
);
}
if (simple) {
return (
<EmojiHTML
{...props}
htmlString={
isModernEmojiEnabled()
? account.get('display_name')
: account.get('display_name_html')
}
shallow
as='span'
/>
);
}
return (
<span {...props} className={`display-name ${className}`}>
<bdi>
<EmojiHTML
{...props}
className={`display-name__html ${className}`}
htmlString={
isModernEmojiEnabled()
? account.get('display_name')
: account.get('display_name_html')
}
shallow
as='strong'
/>
</bdi>
{username && <span className='display-name__account'>{username}</span>}
</span>
);
};

View File

@ -10,16 +10,22 @@ type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
htmlString: string; htmlString: string;
extraEmojis?: CustomEmojiMapArg; extraEmojis?: CustomEmojiMapArg;
as?: Element; as?: Element;
shallow?: boolean;
}; };
export const EmojiHTML = <Element extends ElementType>({ export const EmojiHTML = <Element extends ElementType>({
extraEmojis, extraEmojis,
htmlString, htmlString,
as: asElement, // Rename for syntax highlighting as: asElement, // Rename for syntax highlighting
shallow,
...props ...props
}: EmojiHTMLProps<Element>) => { }: EmojiHTMLProps<Element>) => {
const Wrapper = asElement ?? 'div'; const Wrapper = asElement ?? 'div';
const emojifiedHtml = useEmojify(htmlString, extraEmojis); const emojifiedHtml = useEmojify({
text: htmlString,
extraEmojis,
deep: !shallow,
});
if (emojifiedHtml === null) { if (emojifiedHtml === null) {
return null; return null;

View File

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

View File

@ -103,7 +103,7 @@ export async function emojifyText(
text: string, text: string,
appState: EmojiAppState, appState: EmojiAppState,
extraEmojis: ExtraCustomEmojiMap = {}, extraEmojis: ExtraCustomEmojiMap = {},
): Promise<string | null> { ) {
const cacheKey = createCacheKey(text, appState, extraEmojis); const cacheKey = createCacheKey(text, appState, extraEmojis);
const cached = getCached(cacheKey); const cached = getCached(cacheKey);
if (cached !== undefined) { if (cached !== undefined) {

View File

@ -2567,7 +2567,6 @@ a.account__display-name {
} }
.display-name { .display-name {
display: block;
max-width: 100%; max-width: 100%;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;