import { useCallback, useMemo, useRef, useState } from 'react'; import type { FC } from 'react'; import { defineMessage, useIntl } from 'react-intl'; import classNames from 'classnames'; import IconVerified from '@/images/icons/icon_verified.svg?react'; import { openModal } from '@/mastodon/actions/modal'; import { useFieldHtml } from '@/mastodon/features/account_timeline/hooks/useFieldHtml'; import { cleanExtraEmojis } from '@/mastodon/features/emoji/normalize'; import { useAccount } from '@/mastodon/hooks/useAccount'; import { useResizeObserver } from '@/mastodon/hooks/useObserver'; import type { AccountFieldShape } from '@/mastodon/models/account'; import { useAppDispatch } from '@/mastodon/store'; import MoreIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import { CustomEmojiProvider } from '../emoji/context'; import type { EmojiHTMLProps } from '../emoji/html'; import { EmojiHTML } from '../emoji/html'; import { Icon } from '../icon'; import { IconButton } from '../icon_button'; import { MiniCard } from '../mini_card'; import { useElementHandledLink } from '../status/handled_link'; import classes from './styles.module.scss'; 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', }; export interface AccountField extends AccountFieldShape { nameHasEmojis: boolean; value_plain: string; valueHasEmojis: boolean; } export const AccountHeaderFields: FC<{ accountId: string }> = ({ accountId, }) => { const account = useAccount(accountId); const emojis = useMemo( () => cleanExtraEmojis(account?.emojis), [account?.emojis], ); const accountFields = account?.fields; const fields: AccountField[] = useMemo(() => { const fields = accountFields?.toJS(); if (!fields) { return []; } 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}:`), ), })); }, [accountFields, emojis]); const htmlHandlers = useElementHandledLink({ hashtagAccountId: account?.id, }); const { wrapperRef } = useColumnWrap(); if (fields.length === 0) { return null; } return (
{fields.map((field, key) => ( ))}
); }; const FieldCard: FC<{ htmlHandlers: ReturnType; field: AccountField; }> = ({ htmlHandlers, field }) => { const intl = useIntl(); const { name_emojified, nameHasEmojis, value_emojified, 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 ( } value={ } ref={wrapperRef} > {verified_at && ( )} ); }; type FieldHTMLProps = { text: string; textHasCustomEmoji: boolean; isOverflowing?: boolean; onOverflowClick?: () => void; } & Omit; const FieldHTML: FC = ({ className, text, textHasCustomEmoji, isOverflowing, onOverflowClick, onElement, ...props }) => { const intl = useIntl(); const handleElement = useFieldHtml(textHasCustomEmoji, onElement); const html = ( ); if (!isOverflowing) { return html; } return ( <> {html} ); }; function useColumnWrap() { const listRef = useRef(null); const handleRecalculate = useCallback(() => { const listEle = listRef.current; if (!listEle) { return; } // 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; const halfColSpan = columnCount / 2; // 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 if ( row.length > 1 && row.length === halfColSpan && span === 1 && remainingRowSpan > 1 ) { // Special case for 2 items in a row where both items only need 1 column. ele.dataset.cols = halfColSpan.toString(); if (row[0]) { row[0].ele.dataset.cols = halfColSpan.toString(); } } 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); handleRecalculate(); } }, [handleRecalculate, observer], ); return { wrapperRef: wrapperRefCallback }; } 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, }; }