mirror of
https://github.com/mastodon/mastodon.git
synced 2025-09-05 17:31:12 +00:00
Adds DisplayName component (#35985)
This commit is contained in:
parent
d7d83d44e6
commit
42be0ca0eb
|
@ -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
8
.storybook/styles.css
Normal file
|
@ -0,0 +1,8 @@
|
|||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
|
@ -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} />;
|
||||
},
|
||||
};
|
122
app/javascript/mastodon/components/display_name/index.tsx
Normal file
122
app/javascript/mastodon/components/display_name/index.tsx
Normal 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'>
|
||||
|
||||
<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'> {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>
|
||||
);
|
||||
};
|
|
@ -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 }} />;
|
||||
};
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Reference in New Issue
Block a user