From dcbf7ab8dc74271eb24614369867c9cb430751cd Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 25 Feb 2026 17:59:18 +0100 Subject: [PATCH] Profile redesign: Account fields grid (#37976) --- .../mastodon/components/emoji/html.tsx | 20 +- .../mastodon/components/mini_card/index.tsx | 13 +- .../mastodon/features/account_about/index.tsx | 125 ------ .../features/account_about/styles.module.css | 7 - .../features/account_timeline/common.ts | 7 + .../components/account_header.tsx | 20 +- .../account_timeline/components/fields.tsx | 394 ++++++++++++------ .../components/redesign.module.scss | 88 ++-- .../account_timeline/components/tabs.tsx | 10 +- .../account_timeline/hooks/useFieldHtml.tsx | 38 ++ .../account_timeline/modals/field_modal.tsx | 44 ++ .../account_timeline/modals/note_modal.tsx | 2 +- .../{modals.module.css => styles.module.css} | 6 + .../features/ui/components/modal_root.jsx | 1 + app/javascript/mastodon/features/ui/index.jsx | 34 +- .../features/ui/util/async-components.js | 5 - app/javascript/mastodon/hooks/useObserver.ts | 29 ++ app/javascript/mastodon/hooks/useOverflow.ts | 51 +-- app/javascript/mastodon/locales/en.json | 4 +- config/routes.rb | 2 - 20 files changed, 488 insertions(+), 412 deletions(-) delete mode 100644 app/javascript/mastodon/features/account_about/index.tsx delete mode 100644 app/javascript/mastodon/features/account_about/styles.module.css create mode 100644 app/javascript/mastodon/features/account_timeline/hooks/useFieldHtml.tsx create mode 100644 app/javascript/mastodon/features/account_timeline/modals/field_modal.tsx rename app/javascript/mastodon/features/account_timeline/modals/{modals.module.css => styles.module.css} (80%) create mode 100644 app/javascript/mastodon/hooks/useObserver.ts diff --git a/app/javascript/mastodon/components/emoji/html.tsx b/app/javascript/mastodon/components/emoji/html.tsx index bc3eda7a33..a9f2b64e33 100644 --- a/app/javascript/mastodon/components/emoji/html.tsx +++ b/app/javascript/mastodon/components/emoji/html.tsx @@ -20,18 +20,7 @@ export interface EmojiHTMLProps { } export const EmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>( - ( - { - extraEmojis, - htmlString, - as: asProp = 'div', // Rename for syntax highlighting - className, - onElement, - onAttribute, - ...props - }, - ref, - ) => { + ({ extraEmojis, htmlString, onElement, onAttribute, ...props }, ref) => { const contents = useMemo( () => htmlStringToComponents(htmlString, { @@ -44,12 +33,7 @@ export const EmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>( return ( - + {contents} diff --git a/app/javascript/mastodon/components/mini_card/index.tsx b/app/javascript/mastodon/components/mini_card/index.tsx index 9ddb964d71..78e240b01e 100644 --- a/app/javascript/mastodon/components/mini_card/index.tsx +++ b/app/javascript/mastodon/components/mini_card/index.tsx @@ -23,7 +23,17 @@ export type MiniCardProps = OmitUnion< export const MiniCard = forwardRef( ( - { label, value, className, hidden, icon, iconId, iconClassName, ...props }, + { + label, + value, + className, + hidden, + icon, + iconId, + iconClassName, + children, + ...props + }, ref, ) => { if (!label) { @@ -50,6 +60,7 @@ export const MiniCard = forwardRef( )}
{label}
{value}
+ {children} ); }, diff --git a/app/javascript/mastodon/features/account_about/index.tsx b/app/javascript/mastodon/features/account_about/index.tsx deleted file mode 100644 index 28e9710886..0000000000 --- a/app/javascript/mastodon/features/account_about/index.tsx +++ /dev/null @@ -1,125 +0,0 @@ -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 deleted file mode 100644 index a0f5569b29..0000000000 --- a/app/javascript/mastodon/features/account_about/styles.module.css +++ /dev/null @@ -1,7 +0,0 @@ -.wrapper { - padding: 16px; -} - -.bio { - color: var(--color-text-primary); -} diff --git a/app/javascript/mastodon/features/account_timeline/common.ts b/app/javascript/mastodon/features/account_timeline/common.ts index 1bb62a4220..7a939bbec9 100644 --- a/app/javascript/mastodon/features/account_timeline/common.ts +++ b/app/javascript/mastodon/features/account_timeline/common.ts @@ -1,5 +1,12 @@ +import type { AccountFieldShape } from '@/mastodon/models/account'; import { isServerFeatureEnabled } from '@/mastodon/utils/environment'; export function isRedesignEnabled() { return isServerFeatureEnabled('profile_redesign'); } + +export interface AccountField extends AccountFieldShape { + nameHasEmojis: boolean; + value_plain: string; + valueHasEmojis: boolean; +} 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 e31e13b8dd..9731f4cc33 100644 --- a/app/javascript/mastodon/features/account_timeline/components/account_header.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/account_header.tsx @@ -210,18 +210,14 @@ 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 539546759d..c2802c2c51 100644 --- a/app/javascript/mastodon/features/account_timeline/components/fields.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/fields.tsx @@ -1,25 +1,31 @@ -import { useCallback, useMemo, useState } from 'react'; -import type { FC, Key } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import type { FC } from 'react'; 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 { openModal } from '@/mastodon/actions/modal'; 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 { Icon } from '@/mastodon/components/icon'; +import { IconButton } from '@/mastodon/components/icon_button'; +import { MiniCard } from '@/mastodon/components/mini_card'; import { useElementHandledLink } from '@/mastodon/components/status/handled_link'; import { useAccount } from '@/mastodon/hooks/useAccount'; -import type { Account, AccountFieldShape } from '@/mastodon/models/account'; -import type { OnElementHandler } from '@/mastodon/utils/html'; +import { useResizeObserver } from '@/mastodon/hooks/useObserver'; +import type { Account } from '@/mastodon/models/account'; +import { useAppDispatch } from '@/mastodon/store'; +import MoreIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import { cleanExtraEmojis } from '../../emoji/normalize'; +import type { AccountField } from '../common'; import { isRedesignEnabled } from '../common'; +import { useFieldHtml } from '../hooks/useFieldHtml'; import classes from './redesign.module.scss'; @@ -74,172 +80,310 @@ const RedesignAccountHeaderFields: FC<{ account: Account }> = ({ account }) => { () => cleanExtraEmojis(account.emojis), [account.emojis], ); - const textHasCustomEmoji = useCallback( - (text?: string | null) => { - if (!emojis || !text) { - return false; - } - for (const emoji of Object.keys(emojis)) { - if (text.includes(`:${emoji}:`)) { - return true; - } - } - return false; - }, - [emojis], - ); + const fields: AccountField[] = useMemo(() => { + const fields = account.fields.toJS(); + if (!emojis) { + return fields.map((field) => ({ + ...field, + nameHasEmojis: false, + value_plain: field.value_plain ?? '', + valueHasEmojis: false, + })); + } + + const shortcodes = Object.keys(emojis); + return fields.map((field) => ({ + ...field, + nameHasEmojis: shortcodes.some((code) => + field.name.includes(`:${code}:`), + ), + value_plain: field.value_plain ?? '', + valueHasEmojis: shortcodes.some((code) => + field.value_plain?.includes(`:${code}:`), + ), + })); + }, [account.fields, emojis]); + const htmlHandlers = useElementHandledLink({ hashtagAccountId: account.id, }); - if (account.fields.isEmpty()) { + const { wrapperRef } = useColumnWrap(); + + if (fields.length === 0) { return null; } return ( -
- {account.fields.map((field, key) => ( - +
+ {fields.map((field, key) => ( + ))}
); }; -const FieldRow: FC< - { - textHasCustomEmoji: (text?: string | null) => boolean; - htmlHandlers: ReturnType; - } & AccountFieldShape -> = ({ - textHasCustomEmoji, - htmlHandlers, - name, - name_emojified, - value_emojified, - value_plain, - verified_at, -}) => { +const FieldRow: FC<{ + htmlHandlers: ReturnType; + field: AccountField; +}> = ({ htmlHandlers, field }) => { const intl = useIntl(); - const [showAll, setShowAll] = useState(false); - const handleClick = useCallback(() => { - setShowAll((prev) => !prev); - }, []); + const { + name, + name_emojified, + nameHasEmojis, + value_emojified, + value_plain, + valueHasEmojis, + verified_at, + } = field; + + const { wrapperRef, isLabelOverflowing, isValueOverflowing } = + useFieldOverflow(); + + const dispatch = useAppDispatch(); + const handleOverflowClick = useCallback(() => { + dispatch( + openModal({ + modalType: 'ACCOUNT_FIELD_OVERFLOW', + modalProps: { field }, + }), + ); + }, [dispatch, field]); return ( - /* eslint-disable -- This method of showing field contents is not very accessible, but it's what we've got for now */ -
- -
+ label={ - - {verified_at && ( - - )} -
-
+ } + value={ + + } + ref={wrapperRef} + > + {verified_at && ( + + )} + ); }; -const FieldHTML: FC< - { - as?: 'span' | 'dt'; - text: string; - textEmojified: string; - textHasCustomEmoji: boolean; - titleLength: number; - } & Omit -> = ({ - as, +type FieldHTMLProps = { + text: string; + textEmojified: string; + textHasCustomEmoji: boolean; + isOverflowing?: boolean; + onOverflowClick?: () => void; +} & Omit; + +const FieldHTML: FC = ({ className, extraEmojis, text, textEmojified, textHasCustomEmoji, - titleLength, + isOverflowing, + onOverflowClick, onElement, ...props }) => { - 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], - ); + const intl = useIntl(); + const handleElement = useFieldHtml(textHasCustomEmoji, onElement); - return ( + const html = ( ); + if (!isOverflowing) { + return html; + } + + return ( + <> + {html} + + + ); }; -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]; +function useColumnWrap() { + const listRef = useRef(null); + + const handleRecalculate = useCallback(() => { + const listEle = listRef.current; + if (!listEle) { + return; } - } - return validAttributes; + + // Calculate dimensions from styles and element size to determine column spans. + const styles = getComputedStyle(listEle); + const gap = parseFloat(styles.columnGap || styles.gap || '0'); + const columnCount = parseInt(styles.getPropertyValue('--cols')) || 2; + const listWidth = listEle.offsetWidth; + const colWidth = (listWidth - gap * (columnCount - 1)) / columnCount; + + // Matrix to hold the grid layout. + const itemGrid: { ele: HTMLElement; span: number }[][] = []; + + // First, determine the column span for each item and populate the grid matrix. + let currentRow = 0; + for (const child of listEle.children) { + if (!(child instanceof HTMLElement)) { + continue; + } + + // This uses a data attribute to detect which elements to measure that overflow. + const contents = child.querySelectorAll('[data-contents]'); + + const childStyles = getComputedStyle(child); + const padding = + parseFloat(childStyles.paddingLeft) + + parseFloat(childStyles.paddingRight); + + const contentWidth = + Math.max( + ...Array.from(contents).map((content) => content.scrollWidth), + ) + padding; + + const contentSpan = Math.ceil(contentWidth / colWidth); + const maxColSpan = Math.min(contentSpan, columnCount); + + const curRow = itemGrid[currentRow] ?? []; + const availableCols = + columnCount - curRow.reduce((carry, curr) => carry + curr.span, 0); + // Move to next row if current item doesn't fit. + if (maxColSpan > availableCols) { + currentRow++; + } + + itemGrid[currentRow] = (itemGrid[currentRow] ?? []).concat({ + ele: child, + span: maxColSpan, + }); + } + + // Next, iterate through the grid matrix and set the column spans and row breaks. + for (const row of itemGrid) { + let remainingRowSpan = columnCount; + for (let i = 0; i < row.length; i++) { + const item = row[i]; + if (!item) { + break; + } + const { ele, span } = item; + if (i < row.length - 1) { + ele.dataset.cols = span.toString(); + remainingRowSpan -= span; + } else { + // Last item in the row takes up remaining space to fill the row. + ele.dataset.cols = remainingRowSpan.toString(); + break; + } + } + } + }, []); + + const observer = useResizeObserver(handleRecalculate); + + const wrapperRefCallback = useCallback( + (element: HTMLDListElement | null) => { + if (element) { + listRef.current = element; + observer.observe(element); + } + }, + [observer], + ); + + return { wrapperRef: wrapperRefCallback }; } -function showTitleOnLength(value: string | null, maxLength: number) { - if (value && value.length > maxLength) { - return value; - } - return undefined; +function useFieldOverflow() { + const [isLabelOverflowing, setIsLabelOverflowing] = useState(false); + const [isValueOverflowing, setIsValueOverflowing] = useState(false); + + const wrapperRef = useRef(null); + + const handleRecalculate = useCallback(() => { + const wrapperEle = wrapperRef.current; + if (!wrapperEle) return; + + const wrapperStyles = getComputedStyle(wrapperEle); + const maxWidth = + wrapperEle.offsetWidth - + (parseFloat(wrapperStyles.paddingLeft) + + parseFloat(wrapperStyles.paddingRight)); + + const label = wrapperEle.querySelector( + 'dt > [data-contents]', + ); + const value = wrapperEle.querySelector( + 'dd > [data-contents]', + ); + + setIsLabelOverflowing(label ? label.scrollWidth > maxWidth : false); + setIsValueOverflowing(value ? value.scrollWidth > maxWidth : false); + }, []); + + const observer = useResizeObserver(handleRecalculate); + + const wrapperRefCallback = useCallback( + (element: HTMLElement | null) => { + if (element) { + wrapperRef.current = element; + observer.observe(element); + } + }, + [observer], + ); + + return { + isLabelOverflowing, + isValueOverflowing, + wrapperRef: wrapperRefCallback, + }; } 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 5d7a5fa650..1d52b1f5ab 100644 --- a/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss +++ b/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss @@ -214,64 +214,90 @@ svg.badgeIcon { } .fieldList { + --cols: 4; + + position: relative; display: grid; - grid-template-columns: 160px 1fr; - column-gap: 12px; + grid-template-columns: repeat(var(--cols), 1fr); + gap: 12px; margin: 16px 0; - border-top: 0.5px solid var(--color-border-primary); @container (width < 420px) { - grid-template-columns: 100px 1fr; + --cols: 2; } } -.fieldRow { - display: grid; - grid-column: 1 / -1; - align-items: start; - grid-template-columns: subgrid; - padding: 8px; - border-bottom: 0.5px solid var(--color-border-primary); +.fieldItem { + --col-span: 1; - > :is(dt, dd) { - &:not(.fieldShowAll) { - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - line-clamp: 2; - overflow: hidden; - text-overflow: ellipsis; + grid-column: span var(--col-span); + position: relative; + + @for $col from 2 through 4 { + &[data-cols='#{$col}'] { + --col-span: #{$col}; } } - > dt { - color: var(--color-text-secondary); + dt { + font-weight: normal; } - > dd { - display: flex; - align-items: center; - gap: 4px; + dd { + font-weight: 500; } - a { - color: inherit; - text-decoration: none; + :is(dt, dd) { + text-overflow: initial; - &:hover, - &:focus { - text-decoration: underline; + // Override the MiniCard link styles + a { + color: inherit; + font-weight: inherit; + + &:hover, + &:focus { + color: inherit; + text-decoration: underline; + } } } } .fieldVerified { background-color: var(--color-bg-success-softer); + + dt { + padding-right: 24px; + } } .fieldVerifiedIcon { width: 16px; height: 16px; + position: absolute; + top: 8px; + right: 8px; +} + +.fieldOverflowButton { + --default-bg-color: var(--color-bg-secondary-solid); + --hover-bg-color: color-mix( + in oklab, + var(--color-bg-brand-base), + var(--default-bg-color) var(--overlay-strength-brand) + ); + + position: absolute; + right: 8px; + padding: 0 2px; + transition: background-color 0.2s ease-in-out; + border: 2px solid var(--color-bg-primary); + + > svg { + width: 16px; + height: 12px; + } } .fieldNumbersWrapper { diff --git a/app/javascript/mastodon/features/account_timeline/components/tabs.tsx b/app/javascript/mastodon/features/account_timeline/components/tabs.tsx index b718839ed8..eeb48c1c53 100644 --- a/app/javascript/mastodon/features/account_timeline/components/tabs.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/tabs.tsx @@ -5,23 +5,15 @@ 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/account_timeline/hooks/useFieldHtml.tsx b/app/javascript/mastodon/features/account_timeline/hooks/useFieldHtml.tsx new file mode 100644 index 0000000000..8ab991e85b --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/hooks/useFieldHtml.tsx @@ -0,0 +1,38 @@ +import type { Key } from 'react'; +import { useCallback } from 'react'; + +import htmlConfig from '@/config/html-tags.json'; +import type { OnElementHandler } from '@/mastodon/utils/html'; + +export function useFieldHtml( + hasCustomEmoji: boolean, + onElement?: OnElementHandler, +): OnElementHandler { + return 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 (hasCustomEmoji) { + return ( + + {children} + + ); + } + return onElement?.(element, props, children, extra); + } + return undefined; + }, + [onElement, hasCustomEmoji], + ); +} + +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; +} diff --git a/app/javascript/mastodon/features/account_timeline/modals/field_modal.tsx b/app/javascript/mastodon/features/account_timeline/modals/field_modal.tsx new file mode 100644 index 0000000000..33e2e22891 --- /dev/null +++ b/app/javascript/mastodon/features/account_timeline/modals/field_modal.tsx @@ -0,0 +1,44 @@ +import type { FC } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { EmojiHTML } from '@/mastodon/components/emoji/html'; + +import type { AccountField } from '../common'; +import { useFieldHtml } from '../hooks/useFieldHtml'; + +import classes from './styles.module.css'; + +export const AccountFieldModal: FC<{ + onClose: () => void; + field: AccountField; +}> = ({ onClose, field }) => { + const handleLabelElement = useFieldHtml(field.nameHasEmojis); + const handleValueElement = useFieldHtml(field.valueHasEmojis); + return ( +
+
+
+ + +
+
+
+
+ +
+
+
+ ); +}; diff --git a/app/javascript/mastodon/features/account_timeline/modals/note_modal.tsx b/app/javascript/mastodon/features/account_timeline/modals/note_modal.tsx index 0d736b3467..45fe4d7105 100644 --- a/app/javascript/mastodon/features/account_timeline/modals/note_modal.tsx +++ b/app/javascript/mastodon/features/account_timeline/modals/note_modal.tsx @@ -13,7 +13,7 @@ import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import { ConfirmationModal } from '../../ui/components/confirmation_modals'; -import classes from './modals.module.css'; +import classes from './styles.module.css'; const messages = defineMessages({ newTitle: { diff --git a/app/javascript/mastodon/features/account_timeline/modals/modals.module.css b/app/javascript/mastodon/features/account_timeline/modals/styles.module.css similarity index 80% rename from app/javascript/mastodon/features/account_timeline/modals/modals.module.css rename to app/javascript/mastodon/features/account_timeline/modals/styles.module.css index cee0bc498a..4740a42cb9 100644 --- a/app/javascript/mastodon/features/account_timeline/modals/modals.module.css +++ b/app/javascript/mastodon/features/account_timeline/modals/styles.module.css @@ -19,3 +19,9 @@ outline: var(--outline-focus-default); outline-offset: 2px; } + +.fieldValue { + color: var(--color-text-primary); + font-weight: 600; + margin-top: 4px; +} diff --git a/app/javascript/mastodon/features/ui/components/modal_root.jsx b/app/javascript/mastodon/features/ui/components/modal_root.jsx index 163a04fdd6..7cacfab800 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.jsx +++ b/app/javascript/mastodon/features/ui/components/modal_root.jsx @@ -92,6 +92,7 @@ export const MODAL_COMPONENTS = { 'ANNUAL_REPORT': AnnualReportModal, 'COMPOSE_PRIVACY': () => Promise.resolve({ default: VisibilityModal }), 'ACCOUNT_NOTE': () => import('@/mastodon/features/account_timeline/modals/note_modal').then(module => ({ default: module.AccountNoteModal })), + 'ACCOUNT_FIELD_OVERFLOW': () => import('@/mastodon/features/account_timeline/modals/field_modal').then(module => ({ default: module.AccountFieldModal })), 'ACCOUNT_EDIT_NAME': () => import('@/mastodon/features/account_edit/components/name_modal').then(module => ({ default: module.NameModal })), 'ACCOUNT_EDIT_BIO': () => import('@/mastodon/features/account_edit/components/bio_modal').then(module => ({ default: module.BioModal })), }; diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index 9e61158f14..17822929a7 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -22,7 +22,7 @@ import { identityContextPropShape, withIdentity } from 'mastodon/identity_contex import { layoutFromWindow } from 'mastodon/is_mobile'; import { WithRouterPropTypes } from 'mastodon/utils/react_router'; import { checkAnnualReport } from '@/mastodon/reducers/slices/annual_report'; -import { isClientFeatureEnabled, isServerFeatureEnabled } from '@/mastodon/utils/environment'; +import { isClientFeatureEnabled } from '@/mastodon/utils/environment'; import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose'; import { clearHeight } from '../../actions/height_cache'; @@ -80,7 +80,6 @@ import { PrivacyPolicy, TermsOfService, AccountFeatured, - AccountAbout, AccountEdit, AccountEditFeaturedTags, Quotes, @@ -166,36 +165,6 @@ class SwitchingColumnsArea extends PureComponent { } const profileRedesignRoutes = []; - if (isServerFeatureEnabled('profile_redesign')) { - 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 { - profileRedesignRoutes.push( - , - // If the redesign is not enabled but someone shares an /about link, redirect to the root. - , - - ); - } - if (isClientFeatureEnabled('profile_editing')) { profileRedesignRoutes.push( , @@ -257,6 +226,7 @@ class SwitchingColumnsArea extends PureComponent { {...profileRedesignRoutes} + diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index ef9125d06f..d6c0f70c70 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -93,11 +93,6 @@ export function AccountFeatured() { return import('../../account_featured'); } -export function AccountAbout() { - return import('../../account_about') - .then((module) => ({ default: module.AccountAbout })); -} - export function AccountEdit() { return import('../../account_edit') .then((module) => ({ default: module.AccountEdit })); diff --git a/app/javascript/mastodon/hooks/useObserver.ts b/app/javascript/mastodon/hooks/useObserver.ts new file mode 100644 index 0000000000..4b979f6d32 --- /dev/null +++ b/app/javascript/mastodon/hooks/useObserver.ts @@ -0,0 +1,29 @@ +import { useEffect, useRef } from 'react'; + +export function useResizeObserver(callback: ResizeObserverCallback) { + const observerRef = useRef(null); + observerRef.current ??= new ResizeObserver(callback); + + useEffect(() => { + const observer = observerRef.current; + return () => { + observer?.disconnect(); + }; + }, []); + + return observerRef.current; +} + +export function useMutationObserver(callback: MutationCallback) { + const observerRef = useRef(null); + observerRef.current ??= new MutationObserver(callback); + + useEffect(() => { + const observer = observerRef.current; + return () => { + observer?.disconnect(); + }; + }, []); + + return observerRef.current; +} diff --git a/app/javascript/mastodon/hooks/useOverflow.ts b/app/javascript/mastodon/hooks/useOverflow.ts index b306fb4871..b85222cf56 100644 --- a/app/javascript/mastodon/hooks/useOverflow.ts +++ b/app/javascript/mastodon/hooks/useOverflow.ts @@ -1,6 +1,8 @@ import type { MutableRefObject, RefCallback } from 'react'; import { useState, useRef, useCallback, useEffect } from 'react'; +import { useMutationObserver, useResizeObserver } from './useObserver'; + /** * Hook to manage overflow of items in a container with a "more" button. * @@ -182,48 +184,30 @@ export function useOverflowObservers({ // This is the item container element. const listRef = useRef(null); - // Set up observers to watch for size and content changes. - const resizeObserverRef = useRef(null); - const mutationObserverRef = useRef(null); - - // Helper to get or create the resize observer. - const resizeObserver = useCallback(() => { - const observer = (resizeObserverRef.current ??= new ResizeObserver( - onRecalculate, - )); - return observer; - }, [onRecalculate]); + const resizeObserver = useResizeObserver(onRecalculate); // Iterate through children and observe them for size changes. const handleChildrenChange = useCallback(() => { const listEle = listRef.current; - const observer = resizeObserver(); - if (listEle) { for (const child of listEle.children) { if (child instanceof HTMLElement) { - observer.observe(child); + resizeObserver.observe(child); } } } onRecalculate(); }, [onRecalculate, resizeObserver]); - // Helper to get or create the mutation observer. - const mutationObserver = useCallback(() => { - const observer = (mutationObserverRef.current ??= new MutationObserver( - handleChildrenChange, - )); - return observer; - }, [handleChildrenChange]); + const mutationObserver = useMutationObserver(handleChildrenChange); // Set up observers. const handleObserve = useCallback(() => { if (wrapperRef.current) { - resizeObserver().observe(wrapperRef.current); + resizeObserver.observe(wrapperRef.current); } if (listRef.current) { - mutationObserver().observe(listRef.current, { childList: true }); + mutationObserver.observe(listRef.current, { childList: true }); handleChildrenChange(); } }, [handleChildrenChange, mutationObserver, resizeObserver]); @@ -233,12 +217,12 @@ export function useOverflowObservers({ const wrapperRefCallback = useCallback( (node: HTMLElement | null) => { if (node) { - wrapperRef.current = node; + wrapperRef.current = node; // eslint-disable-line react-hooks/immutability -- https://github.com/facebook/react/issues/34955 handleObserve(); if (typeof onWrapperRef === 'function') { onWrapperRef(node); } else if (onWrapperRef && 'current' in onWrapperRef) { - onWrapperRef.current = node; + onWrapperRef.current = node; // eslint-disable-line react-hooks/immutability -- https://github.com/facebook/react/issues/34955 } } }, @@ -254,28 +238,13 @@ export function useOverflowObservers({ if (typeof onListRef === 'function') { onListRef(node); } else if (onListRef && 'current' in onListRef) { - onListRef.current = node; + onListRef.current = node; // eslint-disable-line react-hooks/immutability -- https://github.com/facebook/react/issues/34955 } } }, [handleObserve, onListRef], ); - useEffect(() => { - handleObserve(); - - return () => { - if (resizeObserverRef.current) { - resizeObserverRef.current.disconnect(); - resizeObserverRef.current = null; - } - if (mutationObserverRef.current) { - mutationObserverRef.current.disconnect(); - mutationObserverRef.current = null; - } - }; - }, [handleObserve]); - return { wrapperRefCallback, listRefCallback, diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index d3d9a2a4f3..e95ac60420 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -13,7 +13,6 @@ "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", @@ -49,6 +48,7 @@ "account.featured.hashtags": "Hashtags", "account.featured_tags.last_status_at": "Last post on {date}", "account.featured_tags.last_status_never": "No posts", + "account.field_overflow": "Show full content", "account.filters.all": "All activity", "account.filters.boosts_toggle": "Show boosts", "account.filters.posts_boosts": "Posts and boosts", @@ -499,8 +499,6 @@ "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 7aeece5d00..b516a48866 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -155,9 +155,7 @@ 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