diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index f25d0547e83..fcba9230308 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -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); - } + const reducer = reducerWithInitialState( + { + meta: { + locale, + }, + }, + state as Record, + ); const store = configureStore({ reducer, middleware(getDefaultMiddleware) { diff --git a/.storybook/styles.css b/.storybook/styles.css new file mode 100644 index 00000000000..ac29890895e --- /dev/null +++ b/.storybook/styles.css @@ -0,0 +1,8 @@ +a { + color: inherit; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} diff --git a/app/javascript/mastodon/components/display_name/display_name.stories.tsx b/app/javascript/mastodon/components/display_name/display_name.stories.tsx new file mode 100644 index 00000000000..ccd7dcbb916 --- /dev/null +++ b/app/javascript/mastodon/components/display_name/display_name.stories.tsx @@ -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, '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 ; + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +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 ; + }, +}; diff --git a/app/javascript/mastodon/components/display_name/index.tsx b/app/javascript/mastodon/components/display_name/index.tsx new file mode 100644 index 00000000000..6bd4addded5 --- /dev/null +++ b/app/javascript/mastodon/components/display_name/index.tsx @@ -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> = ({ + 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 ( + + + + + + + {!noDomain && ( + +   + + + )} + + ); + } + const accountName = isModernEmojiEnabled() + ? account.get('display_name') + : account.get('display_name_html'); + if (simple) { + return ( + + + + ); + } + + return ( + + + + + {username && ( +  {username} + )} + + ); +}; + +export const LinkedDisplayName: FC< + Props & { asProps?: ComponentPropsWithoutRef<'span'> } & Partial +> = ({ + account, + asProps = {}, + className, + localDomain, + simple, + noDomain, + ...linkProps +}) => { + const displayProps = { + account, + className, + localDomain, + simple, + noDomain, + ...asProps, + }; + if (!account) { + return ; + } + + return ( + + + + ); +}; diff --git a/app/javascript/mastodon/features/emoji/emoji_html.tsx b/app/javascript/mastodon/features/emoji/emoji_html.tsx index 60a27306ddb..0bd10009226 100644 --- a/app/javascript/mastodon/features/emoji/emoji_html.tsx +++ b/app/javascript/mastodon/features/emoji/emoji_html.tsx @@ -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 = Omit< htmlString: string; extraEmojis?: CustomEmojiMapArg; as?: Element; + shallow?: boolean; }; -export const ModernEmojiHTML = ({ +export const EmojiHTML = ({ extraEmojis, htmlString, - as: asElement, // Rename for syntax highlighting + as: Wrapper = 'div', // Rename for syntax highlighting + shallow, ...props -}: EmojiHTMLProps) => { - const Wrapper = asElement ?? 'div'; - const emojifiedHtml = useEmojify(htmlString, extraEmojis); +}: EmojiHTMLProps) => { + const emojifiedHtml = useEmojify({ + text: htmlString, + extraEmojis, + deep: !shallow, + }); if (emojifiedHtml === null) { return null; @@ -31,14 +34,3 @@ export const ModernEmojiHTML = ({ ); }; - -export const EmojiHTML = ( - props: EmojiHTMLProps, -) => { - if (isModernEmojiEnabled()) { - return ; - } - const { as: asElement, htmlString, extraEmojis, ...rest } = props; - const Wrapper = asElement ?? 'div'; - return ; -}; diff --git a/app/javascript/mastodon/features/emoji/hooks.ts b/app/javascript/mastodon/features/emoji/hooks.ts index 3f397f4ef03..7e91486780a 100644 --- a/app/javascript/mastodon/features/emoji/hooks.ts +++ b/app/javascript/mastodon/features/emoji/hooks.ts @@ -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(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)) { diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts index cbf22b31184..19ecbbfff40 100644 --- a/app/javascript/mastodon/reducers/index.ts +++ b/app/javascript/mastodon/reducers/index.ts @@ -101,9 +101,9 @@ const RootStateRecord = ImmutableRecord(initialRootState, 'RootState'); export const rootReducer = combineReducers(reducers, RootStateRecord); export function reducerWithInitialState( - stateOverrides: Record = {}, + ...stateOverrides: Record[] ) { - const initialStateRecord = mergeDeep(initialRootState, stateOverrides); + const initialStateRecord = mergeDeep(initialRootState, ...stateOverrides); const PatchedRootStateRecord = ImmutableRecord( initialStateRecord, 'RootState',