From f5aa5adcf7e4ac3bd3e3615e1e74959add29ada1 Mon Sep 17 00:00:00 2001 From: Echo Date: Fri, 13 Feb 2026 14:37:39 +0100 Subject: [PATCH] Profile redesign: About tab (#37851) --- .../mastodon/components/emoji/context.tsx | 7 +- .../mastodon/components/emoji/html.tsx | 2 +- .../mastodon/features/account_about/index.tsx | 125 ++++++++++ .../features/account_about/styles.module.css | 7 + .../components/account_header.tsx | 15 +- .../account_timeline/components/fields.tsx | 229 ++++++++++++------ .../components/redesign.module.scss | 163 +++++-------- .../account_timeline/components/tabs.tsx | 10 +- .../mastodon/features/emoji/normalize.ts | 2 +- app/javascript/mastodon/features/ui/index.jsx | 47 +++- .../features/ui/util/async-components.js | 5 + app/javascript/mastodon/hooks/useAccountId.ts | 36 ++- app/javascript/mastodon/locales/en.json | 5 +- config/routes.rb | 2 + 14 files changed, 455 insertions(+), 200 deletions(-) create mode 100644 app/javascript/mastodon/features/account_about/index.tsx create mode 100644 app/javascript/mastodon/features/account_about/styles.module.css diff --git a/app/javascript/mastodon/components/emoji/context.tsx b/app/javascript/mastodon/components/emoji/context.tsx index 3682b94141..ceed1d1ff6 100644 --- a/app/javascript/mastodon/components/emoji/context.tsx +++ b/app/javascript/mastodon/components/emoji/context.tsx @@ -92,8 +92,11 @@ export const CustomEmojiContext = createContext({}); export const CustomEmojiProvider = ({ children, emojis: rawEmojis, -}: PropsWithChildren<{ emojis?: CustomEmojiMapArg }>) => { - const emojis = useMemo(() => cleanExtraEmojis(rawEmojis) ?? {}, [rawEmojis]); +}: PropsWithChildren<{ emojis?: CustomEmojiMapArg | null }>) => { + const emojis = useMemo(() => cleanExtraEmojis(rawEmojis), [rawEmojis]); + if (!emojis) { + return children; + } return ( {children} diff --git a/app/javascript/mastodon/components/emoji/html.tsx b/app/javascript/mastodon/components/emoji/html.tsx index 604d08a772..bc3eda7a33 100644 --- a/app/javascript/mastodon/components/emoji/html.tsx +++ b/app/javascript/mastodon/components/emoji/html.tsx @@ -25,7 +25,7 @@ export const EmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>( extraEmojis, htmlString, as: asProp = 'div', // Rename for syntax highlighting - className = '', + className, onElement, onAttribute, ...props diff --git a/app/javascript/mastodon/features/account_about/index.tsx b/app/javascript/mastodon/features/account_about/index.tsx new file mode 100644 index 0000000000..28e9710886 --- /dev/null +++ b/app/javascript/mastodon/features/account_about/index.tsx @@ -0,0 +1,125 @@ +import type { FC } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { useParams } from 'react-router'; + +import { AccountBio } from '@/mastodon/components/account_bio'; +import { Column } from '@/mastodon/components/column'; +import { ColumnBackButton } from '@/mastodon/components/column_back_button'; +import { LoadingIndicator } from '@/mastodon/components/loading_indicator'; +import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error'; +import type { AccountId } from '@/mastodon/hooks/useAccountId'; +import { useAccountId } from '@/mastodon/hooks/useAccountId'; +import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility'; +import { createAppSelector, useAppSelector } from '@/mastodon/store'; + +import { AccountHeader } from '../account_timeline/components/account_header'; +import { AccountHeaderFields } from '../account_timeline/components/fields'; +import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint'; + +import classes from './styles.module.css'; + +const selectIsProfileEmpty = createAppSelector( + [(state) => state.accounts, (_, accountId: AccountId) => accountId], + (accounts, accountId) => { + // Null means still loading, otherwise it's a boolean. + if (!accountId) { + return null; + } + const account = accounts.get(accountId); + if (!account) { + return null; + } + return !account.note && !account.fields.size; + }, +); + +export const AccountAbout: FC<{ multiColumn: boolean }> = ({ multiColumn }) => { + const accountId = useAccountId(); + const { blockedBy, hidden, suspended } = useAccountVisibility(accountId); + const forceEmptyState = blockedBy || hidden || suspended; + + const isProfileEmpty = useAppSelector((state) => + selectIsProfileEmpty(state, accountId), + ); + + if (accountId === null) { + return ; + } + + if (!accountId || isProfileEmpty === null) { + return ( + + + + ); + } + + const showEmptyMessage = forceEmptyState || isProfileEmpty; + + return ( + + +
+ +
+ {!showEmptyMessage ? ( + <> + + + + ) : ( +
+ +
+ )} +
+
+
+ ); +}; + +const EmptyMessage: FC<{ accountId: string }> = ({ accountId }) => { + const { blockedBy, hidden, suspended } = useAccountVisibility(accountId); + const currentUserId = useAppSelector( + (state) => state.meta.get('me') as string | null, + ); + const { acct } = useParams<{ acct?: string }>(); + + if (suspended) { + return ( + + ); + } else if (hidden) { + return ; + } else if (blockedBy) { + return ( + + ); + } else if (accountId === currentUserId) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/app/javascript/mastodon/features/account_about/styles.module.css b/app/javascript/mastodon/features/account_about/styles.module.css new file mode 100644 index 0000000000..a0f5569b29 --- /dev/null +++ b/app/javascript/mastodon/features/account_about/styles.module.css @@ -0,0 +1,7 @@ +.wrapper { + padding: 16px; +} + +.bio { + color: var(--color-text-primary); +} diff --git a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx index e7de3d7be7..9f80bf0c6d 100644 --- a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx @@ -210,12 +210,15 @@ export const AccountHeader: React.FC<{ ))} - - - + {(!isRedesign || layout === 'single-column') && ( + <> + + + + )} diff --git a/app/javascript/mastodon/features/account_timeline/components/fields.tsx b/app/javascript/mastodon/features/account_timeline/components/fields.tsx index 0661c403b3..5d22acc09a 100644 --- a/app/javascript/mastodon/features/account_timeline/components/fields.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/fields.tsx @@ -1,24 +1,25 @@ -import type { FC } from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import type { FC, Key } from 'react'; -import { FormattedMessage, useIntl } from 'react-intl'; +import { defineMessage, FormattedMessage, useIntl } from 'react-intl'; import classNames from 'classnames'; +import htmlConfig from '@/config/html-tags.json'; import IconVerified from '@/images/icons/icon_verified.svg?react'; import { AccountFields } from '@/mastodon/components/account_fields'; +import { CustomEmojiProvider } from '@/mastodon/components/emoji/context'; +import type { EmojiHTMLProps } from '@/mastodon/components/emoji/html'; import { EmojiHTML } from '@/mastodon/components/emoji/html'; import { FormattedDateWrapper } from '@/mastodon/components/formatted_date'; -import { IconButton } from '@/mastodon/components/icon_button'; -import { MiniCard } from '@/mastodon/components/mini_card'; +import { Icon } from '@/mastodon/components/icon'; import { useElementHandledLink } from '@/mastodon/components/status/handled_link'; import { useAccount } from '@/mastodon/hooks/useAccount'; -import { useOverflowScroll } from '@/mastodon/hooks/useOverflow'; import type { Account } from '@/mastodon/models/account'; import { isValidUrl } from '@/mastodon/utils/checks'; -import IconLeftArrow from '@/material-icons/400-24px/chevron_left.svg?react'; -import IconRightArrow from '@/material-icons/400-24px/chevron_right.svg?react'; -import IconLink from '@/material-icons/400-24px/link_2.svg?react'; +import type { OnElementHandler } from '@/mastodon/utils/html'; +import { cleanExtraEmojis } from '../../emoji/normalize'; import { isRedesignEnabled } from '../common'; import classes from './redesign.module.scss'; @@ -57,96 +58,164 @@ export const AccountHeaderFields: FC<{ accountId: string }> = ({ ); }; +const verifyMessage = defineMessage({ + id: 'account.link_verified_on', + defaultMessage: 'Ownership of this link was checked on {date}', +}); +const dateFormatOptions: Intl.DateTimeFormatOptions = { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', +}; + const RedesignAccountHeaderFields: FC<{ account: Account }> = ({ account }) => { - const htmlHandlers = useElementHandledLink(); + const emojis = useMemo( + () => cleanExtraEmojis(account.emojis), + [account.emojis], + ); + const textHasCustomEmoji = useCallback( + (text: string) => { + if (!emojis) { + return false; + } + for (const emoji of Object.keys(emojis)) { + if (text.includes(`:${emoji}:`)) { + return true; + } + } + return false; + }, + [emojis], + ); + const htmlHandlers = useElementHandledLink({ + hashtagAccountId: account.id, + }); const intl = useIntl(); - const { - bodyRef, - canScrollLeft, - canScrollRight, - handleLeftNav, - handleRightNav, - handleScroll, - } = useOverflowScroll(); - return ( -
- {canScrollLeft && ( - - )} -
+ +
{account.fields.map( ( { name, name_emojified, value_emojified, value_plain, verified_at }, key, ) => ( - - } - value={ - - } - icon={fieldIcon(verified_at, value_plain)} className={classNames( - classes.fieldCard, - verified_at && classes.fieldCardVerified, + classes.fieldRow, + verified_at && classes.fieldVerified, )} - /> + > + + + {verified_at && ( + + )} +
), )} - {canScrollRight && ( - - )} - + ); }; -function fieldIcon(verified_at: string | null, value_plain: string | null) { - if (verified_at) { - return IconVerified; - } else if (value_plain && isValidUrl(value_plain)) { - return IconLink; +const FieldHTML: FC< + { + as: 'dd' | 'dt'; + text: string; + textEmojified: string; + textHasCustomEmoji: boolean; + titleLength: number; + } & Omit +> = ({ + as, + className, + extraEmojis, + text, + textEmojified, + textHasCustomEmoji, + titleLength, + onElement, + ...props +}) => { + const [showAll, setShowAll] = useState(false); + const handleClick = useCallback(() => { + setShowAll((prev) => !prev); + }, []); + + const handleElement: OnElementHandler = useCallback( + (element, props, children, extra) => { + if (element instanceof HTMLAnchorElement) { + // Don't allow custom emoji and links in the same field to prevent verification spoofing. + if (textHasCustomEmoji) { + return ( + + {children} + + ); + } + return onElement?.(element, props, children, extra); + } + return undefined; + }, + [onElement, textHasCustomEmoji], + ); + return ( + + ); +}; + +function filterAttributesForSpan(props: Record) { + const validAttributes: Record = {}; + for (const key of Object.keys(props)) { + if (key in htmlConfig.tags.span.attributes) { + validAttributes[key] = props[key]; + } + } + return validAttributes; +} + +function showTitleOnLength(value: string | null, maxLength: number) { + if (value && value.length > maxLength) { + return value; } return undefined; } diff --git a/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss b/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss index bbd32d579c..a1ee827507 100644 --- a/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss +++ b/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss @@ -192,115 +192,80 @@ svg.badgeIcon { } } -.fieldWrapper { - margin-top: 16px; - width: 100%; - position: relative; -} - -.fieldWrapper::before, -.fieldWrapper::after { - content: ''; - position: absolute; - top: 0; - bottom: 0; - width: 40px; - pointer-events: none; - opacity: 0; - transition: opacity 0.2s ease-in-out; - z-index: 1; -} - -.fieldWrapper::before { - left: 0; - background: linear-gradient( - to left, - transparent 0%, - var(--color-bg-primary) 100% - ); -} - -.fieldWrapper::after { - right: 0; - background: linear-gradient( - to right, - transparent 0%, - var(--color-bg-primary) 100% - ); -} - -.fieldWrapperLeft::before { - opacity: 1; -} - -.fieldWrapperRight::after { - opacity: 1; -} - .fieldList { - display: flex; - flex-wrap: nowrap; - gap: 4px; - scroll-snap-type: x mandatory; - scroll-padding-left: 40px; - scroll-padding-right: 40px; - scroll-behavior: smooth; - overflow-x: scroll; - scrollbar-width: none; - overflow-y: visible; -} + display: grid; + grid-template-columns: 160px 1fr min-content; + column-gap: 12px; + margin: 4px 0 16px; -.fieldCard { - scroll-snap-align: start; - - &:focus-visible, - &:focus-within { - outline: var(--outline-focus-default); - outline-offset: -2px; - } - - :is(dt, dd) { - max-width: 200px; + @container (width < 420px) { + grid-template-columns: 100px 1fr min-content; } } -.fieldCardVerified { +.fieldRow { + display: grid; + grid-column: 1 / -1; + align-items: start; + grid-template-columns: subgrid; + padding: 0 4px; + + > :is(dt, dd) { + margin: 8px 0; + + &:not(.fieldShowAll) { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; + text-overflow: ellipsis; + } + } + + > dt { + color: var(--color-text-secondary); + } + + &:not(.fieldVerified) > dd { + grid-column: span 2; + } + + a { + font-weight: 500; + color: var(--color-text-brand); + text-decoration: none; + transition: 0.2s ease-in-out; + + &:hover, + &:focus { + color: var(--color-text-brand-soft); + } + } +} + +.fieldVerified { background-color: var(--color-bg-brand-softer); } -.fieldArrowButton { - position: absolute; - top: 50%; - transform: translateY(-50%); - background-color: var(--color-bg-primary); - box-shadow: 0 1px 4px 0 var(--color-shadow-primary); - border-radius: 9999px; - transition: - color 0.2s ease-in-out, - background-color 0.2s ease-in-out; - outline-offset: 2px; - z-index: 2; +.fieldLink:is(dd, dt) { + margin: 0; +} - &:first-child { - left: 4px; - } +.fieldLink > a { + display: block; + padding: 8px 0; +} - &:last-child { - right: 4px; - } - - &:hover, - &:focus, - &:focus-visible { - background-color: color-mix( - in oklab, - var(--color-bg-brand-base) var(--overlay-strength-brand), - var(--color-bg-primary) - ); - } +.fieldVerifiedIcon { + width: 16px; + height: 16px; + margin-top: 8px; } .fieldNumbersWrapper { + padding: 0; + a { font-weight: unset; } @@ -358,7 +323,11 @@ svg.badgeIcon { border-bottom: 1px solid var(--color-border-primary); display: flex; gap: 12px; - padding: 0 24px; + padding: 0 12px; + + @container (width >= 500px) { + padding: 0 24px; + } a { display: block; diff --git a/app/javascript/mastodon/features/account_timeline/components/tabs.tsx b/app/javascript/mastodon/features/account_timeline/components/tabs.tsx index eeb48c1c53..b718839ed8 100644 --- a/app/javascript/mastodon/features/account_timeline/components/tabs.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/tabs.tsx @@ -5,15 +5,23 @@ import { FormattedMessage } from 'react-intl'; import type { NavLinkProps } from 'react-router-dom'; import { NavLink } from 'react-router-dom'; +import { useLayout } from '@/mastodon/hooks/useLayout'; + import { isRedesignEnabled } from '../common'; import classes from './redesign.module.scss'; export const AccountTabs: FC<{ acct: string }> = ({ acct }) => { + const { layout } = useLayout(); if (isRedesignEnabled()) { return (
- + {layout !== 'single-column' && ( + + + + )} + diff --git a/app/javascript/mastodon/features/emoji/normalize.ts b/app/javascript/mastodon/features/emoji/normalize.ts index bf06e058ef..f19b300f3f 100644 --- a/app/javascript/mastodon/features/emoji/normalize.ts +++ b/app/javascript/mastodon/features/emoji/normalize.ts @@ -181,7 +181,7 @@ export function emojiToInversionClassName(emoji: string): string | null { return null; } -export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg) { +export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg | null) { if (!extraEmojis) { return null; } diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index abe09e81a4..23025cf16c 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -78,6 +78,7 @@ import { PrivacyPolicy, TermsOfService, AccountFeatured, + AccountAbout, Quotes, } from './util/async-components'; import { ColumnsContextProvider } from './util/columns_context'; @@ -88,6 +89,7 @@ import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; // Without this it ends up in ~8 very commonly used bundles. import '../../components/status'; import { areCollectionsEnabled } from '../collections/utils'; +import { isClientFeatureEnabled } from '@/mastodon/utils/environment'; const messages = defineMessages({ beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' }, @@ -109,6 +111,7 @@ class SwitchingColumnsArea extends PureComponent { children: PropTypes.node, location: PropTypes.object, singleColumn: PropTypes.bool, + layout: PropTypes.string.isRequired, forceOnboarding: PropTypes.bool, }; @@ -159,6 +162,37 @@ class SwitchingColumnsArea extends PureComponent { redirect = ; } + const profileRedesignEnabled = isClientFeatureEnabled('profile_redesign'); + const profileRedesignRoutes = []; + if (profileRedesignEnabled) { + profileRedesignRoutes.push( + , + ); + // Check if we're in single-column mode. Confusingly, the singleColumn prop includes mobile. + if (this.props.layout === 'single-column') { + // When in single column mode (desktop w/o advanced view), redirect both the root and about to the posts tab. + profileRedesignRoutes.push( + , + , + , + , + ); + } else { + // Otherwise, provide and redirect to the /about page. + profileRedesignRoutes.push( + , + , + + ); + } + } else { + // If the redesign is not enabled but someone shares an /about link, redirect to the root. + profileRedesignRoutes.push( + , + + ); + } + return ( @@ -205,7 +239,8 @@ class SwitchingColumnsArea extends PureComponent { - + {!profileRedesignEnabled && } + {...profileRedesignRoutes} @@ -235,7 +270,7 @@ class SwitchingColumnsArea extends PureComponent { } {areCollectionsEnabled() && - } + } @@ -591,7 +626,13 @@ class UI extends PureComponent { return (
- + {children} diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 7500575d7a..27c634d07c 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -87,6 +87,11 @@ export function AccountFeatured() { return import('../../account_featured'); } +export function AccountAbout() { + return import('../../account_about') + .then((module) => ({ default: module.AccountAbout })); +} + export function Followers () { return import('../../followers'); } diff --git a/app/javascript/mastodon/hooks/useAccountId.ts b/app/javascript/mastodon/hooks/useAccountId.ts index af1c93d17d..cab8f48934 100644 --- a/app/javascript/mastodon/hooks/useAccountId.ts +++ b/app/javascript/mastodon/hooks/useAccountId.ts @@ -4,19 +4,41 @@ import { useParams } from 'react-router'; import { fetchAccount, lookupAccount } from 'mastodon/actions/accounts'; import { normalizeForLookup } from 'mastodon/reducers/accounts_map'; -import { useAppDispatch, useAppSelector } from 'mastodon/store'; +import { + createAppSelector, + useAppDispatch, + useAppSelector, +} from 'mastodon/store'; interface Params { acct?: string; id?: string; } -export const useAccountId = () => { +const selectNormalizedId = createAppSelector( + [ + (state) => state.accounts_map, + (_, acct?: string) => acct, + (_, _acct, id?: string) => id, + ], + (accountsMap, acct, id) => { + if (id) { + return id; + } + if (acct) { + return accountsMap[normalizeForLookup(acct)]; + } + return undefined; + }, +); + +export type AccountId = string | null | undefined; + +export function useAccountId() { const { acct, id } = useParams(); const dispatch = useAppDispatch(); - const accountId = useAppSelector( - (state) => - id ?? (acct ? state.accounts_map[normalizeForLookup(acct)] : undefined), + const accountId = useAppSelector((state) => + selectNormalizedId(state, acct, id), ); const account = useAppSelector((state) => accountId ? state.accounts.get(accountId) : undefined, @@ -31,5 +53,5 @@ export const useAccountId = () => { } }, [dispatch, accountId, acct, accountInStore]); - return accountId; -}; + return accountId satisfies AccountId; +} diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index d42d7e034f..41365aba99 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -13,6 +13,7 @@ "about.not_available": "This information has not been made available on this server.", "about.powered_by": "Decentralized social media powered by {mastodon}", "about.rules": "Server rules", + "account.about": "About", "account.account_note_header": "Personal note", "account.activity": "Activity", "account.add_note": "Add a personal note", @@ -47,8 +48,6 @@ "account.featured.hashtags": "Hashtags", "account.featured_tags.last_status_at": "Last post on {date}", "account.featured_tags.last_status_never": "No posts", - "account.fields.scroll_next": "Show next", - "account.fields.scroll_prev": "Show previous", "account.filters.all": "All activity", "account.filters.boosts_toggle": "Show boosts", "account.filters.posts_boosts": "Posts and boosts", @@ -454,6 +453,8 @@ "emoji_button.search_results": "Search results", "emoji_button.symbols": "Symbols", "emoji_button.travel": "Travel & Places", + "empty_column.account_about.me": "You have not added any information about yourself yet.", + "empty_column.account_about.other": "{acct} has not added any information about themselves yet.", "empty_column.account_featured.me": "You have not featured anything yet. Did you know that you can feature your hashtags you use the most, and even your friend’s accounts on your profile?", "empty_column.account_featured.other": "{acct} has not featured anything yet. Did you know that you can feature your hashtags you use the most, and even your friend’s accounts on your profile?", "empty_column.account_featured_other.unknown": "This account has not featured anything yet.", diff --git a/config/routes.rb b/config/routes.rb index b516a48866..7aeece5d00 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -155,7 +155,9 @@ Rails.application.routes.draw do constraints(username: %r{[^@/.]+}) do with_options to: 'accounts#show' do get '/@:username', as: :short_account + get '/@:username/posts' get '/@:username/featured' + get '/@:username/about' get '/@:username/with_replies', as: :short_account_with_replies get '/@:username/media', as: :short_account_media get '/@:username/tagged/:tag', as: :short_account_tag