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 { action } from 'storybook/actions';
|
||||||
|
|
||||||
import type { LocaleData } from '@/mastodon/locales';
|
import type { LocaleData } from '@/mastodon/locales';
|
||||||
import { reducerWithInitialState, rootReducer } from '@/mastodon/reducers';
|
import { reducerWithInitialState } from '@/mastodon/reducers';
|
||||||
import { defaultMiddleware } from '@/mastodon/store/store';
|
import { defaultMiddleware } from '@/mastodon/store/store';
|
||||||
import { mockHandlers, unhandledRequestHandler } from '@/testing/api';
|
import { mockHandlers, unhandledRequestHandler } from '@/testing/api';
|
||||||
|
|
||||||
// If you want to run the dark theme during development,
|
// If you want to run the dark theme during development,
|
||||||
// you can change the below to `/application.scss`
|
// you can change the below to `/application.scss`
|
||||||
import '../app/javascript/styles/mastodon-light.scss';
|
import '../app/javascript/styles/mastodon-light.scss';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
const localeFiles = import.meta.glob('@/mastodon/locales/*.json', {
|
const localeFiles = import.meta.glob('@/mastodon/locales/*.json', {
|
||||||
query: { as: 'json' },
|
query: { as: 'json' },
|
||||||
|
@ -49,12 +50,17 @@ const preview: Preview = {
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
},
|
},
|
||||||
decorators: [
|
decorators: [
|
||||||
(Story, { parameters }) => {
|
(Story, { parameters, globals }) => {
|
||||||
|
const { locale } = globals as { locale: string };
|
||||||
const { state = {} } = parameters;
|
const { state = {} } = parameters;
|
||||||
let reducer = rootReducer;
|
const reducer = reducerWithInitialState(
|
||||||
if (typeof state === 'object' && state) {
|
{
|
||||||
reducer = reducerWithInitialState(state as Record<string, unknown>);
|
meta: {
|
||||||
}
|
locale,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state as Record<string, unknown>,
|
||||||
|
);
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
reducer,
|
reducer,
|
||||||
middleware(getDefaultMiddleware) {
|
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 type { ComponentPropsWithoutRef, ElementType } from 'react';
|
||||||
|
|
||||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
|
||||||
|
|
||||||
import { useEmojify } from './hooks';
|
import { useEmojify } from './hooks';
|
||||||
import type { CustomEmojiMapArg } from './types';
|
import type { CustomEmojiMapArg } from './types';
|
||||||
|
|
||||||
|
@ -12,16 +10,21 @@ type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
|
||||||
htmlString: string;
|
htmlString: string;
|
||||||
extraEmojis?: CustomEmojiMapArg;
|
extraEmojis?: CustomEmojiMapArg;
|
||||||
as?: Element;
|
as?: Element;
|
||||||
|
shallow?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ModernEmojiHTML = <Element extends ElementType>({
|
export const EmojiHTML = ({
|
||||||
extraEmojis,
|
extraEmojis,
|
||||||
htmlString,
|
htmlString,
|
||||||
as: asElement, // Rename for syntax highlighting
|
as: Wrapper = 'div', // Rename for syntax highlighting
|
||||||
|
shallow,
|
||||||
...props
|
...props
|
||||||
}: EmojiHTMLProps<Element>) => {
|
}: EmojiHTMLProps<ElementType>) => {
|
||||||
const Wrapper = asElement ?? 'div';
|
const emojifiedHtml = useEmojify({
|
||||||
const emojifiedHtml = useEmojify(htmlString, extraEmojis);
|
text: htmlString,
|
||||||
|
extraEmojis,
|
||||||
|
deep: !shallow,
|
||||||
|
});
|
||||||
|
|
||||||
if (emojifiedHtml === null) {
|
if (emojifiedHtml === null) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -31,14 +34,3 @@ export const ModernEmojiHTML = <Element extends ElementType>({
|
||||||
<Wrapper {...props} dangerouslySetInnerHTML={{ __html: emojifiedHtml }} />
|
<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 { toSupportedLocale } from './locale';
|
||||||
import { determineEmojiMode } from './mode';
|
import { determineEmojiMode } from './mode';
|
||||||
|
import { emojifyElement, emojifyText } from './render';
|
||||||
import type {
|
import type {
|
||||||
CustomEmojiMapArg,
|
CustomEmojiMapArg,
|
||||||
EmojiAppState,
|
EmojiAppState,
|
||||||
|
@ -15,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();
|
||||||
|
@ -36,17 +47,23 @@ export function useEmojify(text: string, extraEmojis?: CustomEmojiMapArg) {
|
||||||
|
|
||||||
const emojify = useCallback(
|
const emojify = useCallback(
|
||||||
async (input: string) => {
|
async (input: string) => {
|
||||||
const wrapper = document.createElement('div');
|
let result: string | null = null;
|
||||||
wrapper.innerHTML = input;
|
if (deep) {
|
||||||
const { emojifyElement } = await import('./render');
|
const wrapper = document.createElement('div');
|
||||||
const result = await emojifyElement(wrapper, appState, extra);
|
wrapper.innerHTML = input;
|
||||||
|
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)) {
|
||||||
|
|
|
@ -101,9 +101,9 @@ const RootStateRecord = ImmutableRecord(initialRootState, 'RootState');
|
||||||
export const rootReducer = combineReducers(reducers, RootStateRecord);
|
export const rootReducer = combineReducers(reducers, RootStateRecord);
|
||||||
|
|
||||||
export function reducerWithInitialState(
|
export function reducerWithInitialState(
|
||||||
stateOverrides: Record<string, unknown> = {},
|
...stateOverrides: Record<string, unknown>[]
|
||||||
) {
|
) {
|
||||||
const initialStateRecord = mergeDeep(initialRootState, stateOverrides);
|
const initialStateRecord = mergeDeep(initialRootState, ...stateOverrides);
|
||||||
const PatchedRootStateRecord = ImmutableRecord(
|
const PatchedRootStateRecord = ImmutableRecord(
|
||||||
initialStateRecord,
|
initialStateRecord,
|
||||||
'RootState',
|
'RootState',
|
||||||
|
|
Loading…
Reference in New Issue
Block a user