diff --git a/.github/workflows/lint-css.yml b/.github/workflows/lint-css.yml index 178342ed34..60778f11a0 100644 --- a/.github/workflows/lint-css.yml +++ b/.github/workflows/lint-css.yml @@ -9,7 +9,6 @@ on: - 'package.json' - 'yarn.lock' - '.nvmrc' - - '.prettier*' - 'stylelint.config.js' - '**/*.css' - '**/*.scss' @@ -21,7 +20,6 @@ on: - 'package.json' - 'yarn.lock' - '.nvmrc' - - '.prettier*' - 'stylelint.config.js' - '**/*.css' - '**/*.scss' diff --git a/.github/workflows/lint-js.yml b/.github/workflows/lint-js.yml index 7036c5e569..f16e01d4d2 100644 --- a/.github/workflows/lint-js.yml +++ b/.github/workflows/lint-js.yml @@ -10,7 +10,6 @@ on: - 'yarn.lock' - 'tsconfig.json' - '.nvmrc' - - '.prettier*' - 'eslint.config.mjs' - '**/*.js' - '**/*.jsx' @@ -24,7 +23,6 @@ on: - 'yarn.lock' - 'tsconfig.json' - '.nvmrc' - - '.prettier*' - 'eslint.config.mjs' - '**/*.js' - '**/*.jsx' diff --git a/app/controllers/admin/reports/actions_controller.rb b/app/controllers/admin/reports/actions_controller.rb index fb7b6878ba..abfec42e75 100644 --- a/app/controllers/admin/reports/actions_controller.rb +++ b/app/controllers/admin/reports/actions_controller.rb @@ -13,7 +13,7 @@ class Admin::Reports::ActionsController < Admin::BaseController case action_from_button when 'delete', 'mark_as_sensitive' - Admin::StatusBatchAction.new(status_batch_action_params).save! + Admin::ModerationAction.new(moderation_action_params).save! when 'silence', 'suspend' Admin::AccountAction.new(account_action_params).save! else @@ -25,9 +25,8 @@ class Admin::Reports::ActionsController < Admin::BaseController private - def status_batch_action_params + def moderation_action_params shared_params - .merge(status_ids: @report.status_ids) end def account_action_params diff --git a/app/controllers/admin/statuses_controller.rb b/app/controllers/admin/statuses_controller.rb index aeadb35e7a..7e75d841b5 100644 --- a/app/controllers/admin/statuses_controller.rb +++ b/app/controllers/admin/statuses_controller.rb @@ -78,8 +78,6 @@ module Admin 'report' elsif params[:remove_from_report] 'remove_from_report' - elsif params[:delete] - 'delete' end end end diff --git a/app/helpers/admin/action_logs_helper.rb b/app/helpers/admin/action_logs_helper.rb index 4a55a36ecd..76edb965a6 100644 --- a/app/helpers/admin/action_logs_helper.rb +++ b/app/helpers/admin/action_logs_helper.rb @@ -19,7 +19,7 @@ module Admin::ActionLogsHelper link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id) when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain' log.human_identifier.present? ? link_to(log.human_identifier, admin_instance_path(log.human_identifier)) : I18n.t('admin.action_logs.unavailable_instance') - when 'Status' + when 'Status', 'Collection' link_to log.human_identifier, log.permalink when 'AccountWarning' link_to log.human_identifier, disputes_strike_path(log.target_id) diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx index b2a1473dfb..f891410a3c 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -182,15 +182,25 @@ function loaded() { ({ target }) => { if (!(target instanceof HTMLInputElement)) return; - if (target.value && target.value.length > 0) { + const checkedUsername = target.value; + if (checkedUsername && checkedUsername.length > 0) { axios - .get('/api/v1/accounts/lookup', { params: { acct: target.value } }) + .get('/api/v1/accounts/lookup', { + params: { acct: checkedUsername }, + }) .then(() => { - target.setCustomValidity(formatMessage(messages.usernameTaken)); + // Only update the validity if the result is for the currently-typed username + if (checkedUsername === target.value) { + target.setCustomValidity(formatMessage(messages.usernameTaken)); + } + return true; }) .catch(() => { - target.setCustomValidity(''); + // Only update the validity if the result is for the currently-typed username + if (checkedUsername === target.value) { + target.setCustomValidity(''); + } }); } else { target.setCustomValidity(''); 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_featured/index.tsx b/app/javascript/mastodon/features/account_featured/index.tsx index 45f2ccb1d7..db01c5b272 100644 --- a/app/javascript/mastodon/features/account_featured/index.tsx +++ b/app/javascript/mastodon/features/account_featured/index.tsx @@ -17,8 +17,15 @@ import BundleColumnError from 'mastodon/features/ui/components/bundle_column_err import Column from 'mastodon/features/ui/components/column'; import { useAccountId } from 'mastodon/hooks/useAccountId'; import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility'; +import { + fetchAccountCollections, + selectAccountCollections, +} from 'mastodon/reducers/slices/collections'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; +import { CollectionListItem } from '../collections/detail/collection_list_item'; +import { areCollectionsEnabled } from '../collections/utils'; + import { EmptyMessage } from './components/empty_message'; import { FeaturedTag } from './components/featured_tag'; import type { TagMap } from './components/featured_tag'; @@ -42,6 +49,9 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ if (accountId) { void dispatch(fetchFeaturedTags({ accountId })); void dispatch(fetchEndorsedAccounts({ accountId })); + if (areCollectionsEnabled()) { + void dispatch(fetchAccountCollections({ accountId })); + } } }, [accountId, dispatch]); @@ -64,6 +74,14 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ ImmutableList(), ) as ImmutableList, ); + const { collections, status } = useAppSelector((state) => + selectAccountCollections(state, accountId ?? null), + ); + const publicCollections = collections.filter( + // This filter only applies when viewing your own profile, where the endpoint + // returns all collections, but we hide unlisted ones here to avoid confusion + (item) => item.discoverable, + ); if (accountId === null) { return ; @@ -101,6 +119,25 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({ {accountId && ( )} + {publicCollections.length > 0 && status === 'idle' && ( + <> +

+ +

+
+ {publicCollections.map((item, index) => ( + + ))} +
+ + )} {!featuredTags.isEmpty() && ( <>

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..aad78f1e08 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: 4px; 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/collections/detail/collection_list_item.module.scss b/app/javascript/mastodon/features/collections/detail/collection_list_item.module.scss index 74eac9f3e1..9e771dbaa0 100644 --- a/app/javascript/mastodon/features/collections/detail/collection_list_item.module.scss +++ b/app/javascript/mastodon/features/collections/detail/collection_list_item.module.scss @@ -2,15 +2,17 @@ display: flex; align-items: center; gap: 16px; - margin-inline: 10px; - padding-inline-end: 5px; - border-bottom: 1px solid var(--color-border-primary); + padding-inline: 16px; + + &:not(.wrapperWithoutBorder) { + border-bottom: 1px solid var(--color-border-primary); + } } .content { position: relative; flex-grow: 1; - padding: 15px 5px; + padding-block: 15px; } .link { diff --git a/app/javascript/mastodon/features/collections/detail/collection_list_item.tsx b/app/javascript/mastodon/features/collections/detail/collection_list_item.tsx index fe5aa50047..1a7e18b521 100644 --- a/app/javascript/mastodon/features/collections/detail/collection_list_item.tsx +++ b/app/javascript/mastodon/features/collections/detail/collection_list_item.tsx @@ -67,13 +67,18 @@ export const CollectionMetaData: React.FC<{ export const CollectionListItem: React.FC<{ collection: ApiCollectionJSON; -}> = ({ collection }) => { + withoutBorder?: boolean; +}> = ({ collection, withoutBorder }) => { const { id, name } = collection; const linkId = useId(); return (
diff --git a/app/javascript/mastodon/features/collections/detail/collection_menu.tsx b/app/javascript/mastodon/features/collections/detail/collection_menu.tsx index 2f5a577a55..b236c1cadb 100644 --- a/app/javascript/mastodon/features/collections/detail/collection_menu.tsx +++ b/app/javascript/mastodon/features/collections/detail/collection_menu.tsx @@ -2,11 +2,16 @@ import { useCallback, useMemo } from 'react'; import { defineMessages, useIntl } from 'react-intl'; +import { matchPath } from 'react-router'; + +import { useAccount } from '@/mastodon/hooks/useAccount'; import MoreVertIcon from '@/material-icons/400-24px/more_vert.svg?react'; import { openModal } from 'mastodon/actions/modal'; import type { ApiCollectionJSON } from 'mastodon/api_types/collections'; import { Dropdown } from 'mastodon/components/dropdown_menu'; import { IconButton } from 'mastodon/components/icon_button'; +import { me } from 'mastodon/initial_state'; +import type { MenuItem } from 'mastodon/models/dropdown_menu'; import { useAppDispatch } from 'mastodon/store'; import { messages as editorMessages } from '../editor'; @@ -16,10 +21,18 @@ const messages = defineMessages({ id: 'collections.view_collection', defaultMessage: 'View collection', }, + viewOtherCollections: { + id: 'collections.view_other_collections_by_user', + defaultMessage: 'View other collections by this user', + }, delete: { id: 'collections.delete_collection', defaultMessage: 'Delete collection', }, + report: { + id: 'collections.report_collection', + defaultMessage: 'Report this collection', + }, more: { id: 'status.more', defaultMessage: 'More' }, }); @@ -31,9 +44,11 @@ export const CollectionMenu: React.FC<{ const dispatch = useAppDispatch(); const intl = useIntl(); - const { id, name } = collection; + const { id, name, account_id } = collection; + const isOwnCollection = account_id === me; + const ownerAccount = useAccount(account_id); - const handleDeleteClick = useCallback(() => { + const openDeleteConfirmation = useCallback(() => { dispatch( openModal({ modalType: 'CONFIRM_DELETE_COLLECTION', @@ -45,34 +60,83 @@ export const CollectionMenu: React.FC<{ ); }, [dispatch, id, name]); - const menu = useMemo(() => { - const commonItems = [ - { - text: intl.formatMessage(editorMessages.manageAccounts), - to: `/collections/${id}/edit`, - }, - { - text: intl.formatMessage(editorMessages.editDetails), - to: `/collections/${id}/edit/details`, - }, - null, - { - text: intl.formatMessage(messages.delete), - action: handleDeleteClick, - dangerous: true, - }, - ]; + const openReportModal = useCallback(() => { + dispatch( + openModal({ + modalType: 'REPORT_COLLECTION', + modalProps: { + collection, + }, + }), + ); + }, [collection, dispatch]); - if (context === 'list') { - return [ - { text: intl.formatMessage(messages.view), to: `/collections/${id}` }, + const menu = useMemo(() => { + if (isOwnCollection) { + const commonItems: MenuItem[] = [ + { + text: intl.formatMessage(editorMessages.manageAccounts), + to: `/collections/${id}/edit`, + }, + { + text: intl.formatMessage(editorMessages.editDetails), + to: `/collections/${id}/edit/details`, + }, null, - ...commonItems, + { + text: intl.formatMessage(messages.delete), + action: openDeleteConfirmation, + dangerous: true, + }, ]; + + if (context === 'list') { + return [ + { text: intl.formatMessage(messages.view), to: `/collections/${id}` }, + null, + ...commonItems, + ]; + } else { + return commonItems; + } + } else if (ownerAccount) { + const items: MenuItem[] = [ + { + text: intl.formatMessage(messages.report), + action: openReportModal, + }, + ]; + const featuredCollectionsPath = `/@${ownerAccount.acct}/featured`; + // Don't show menu link to featured collections while on that very page + if ( + !matchPath(location.pathname, { + path: featuredCollectionsPath, + exact: true, + }) + ) { + items.unshift( + ...[ + { + text: intl.formatMessage(messages.viewOtherCollections), + to: featuredCollectionsPath, + }, + null, + ], + ); + } + return items; } else { - return commonItems; + return []; } - }, [intl, id, handleDeleteClick, context]); + }, [ + isOwnCollection, + intl, + id, + openDeleteConfirmation, + context, + ownerAccount, + openReportModal, + ]); return ( diff --git a/app/javascript/mastodon/features/collections/detail/index.tsx b/app/javascript/mastodon/features/collections/detail/index.tsx index db0838fc31..d5b14da859 100644 --- a/app/javascript/mastodon/features/collections/detail/index.tsx +++ b/app/javascript/mastodon/features/collections/detail/index.tsx @@ -5,6 +5,7 @@ import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { Helmet } from 'react-helmet'; import { useParams } from 'react-router'; +import { useRelationship } from '@/mastodon/hooks/useRelationship'; import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; import ShareIcon from '@/material-icons/400-24px/share.svg?react'; import { showAlert } from 'mastodon/actions/alerts'; @@ -79,7 +80,7 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({ collection, }) => { const intl = useIntl(); - const { name, description, tag } = collection; + const { name, description, tag, account_id } = collection; const dispatch = useAppDispatch(); const handleShare = useCallback(() => { @@ -114,7 +115,7 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({ {description &&

{description}

} @@ -123,6 +124,28 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({ ); }; +const CollectionAccountItem: React.FC<{ + accountId: string | undefined; + collectionOwnerId: string; +}> = ({ accountId, collectionOwnerId }) => { + const relationship = useRelationship(accountId); + + if (!accountId) { + return null; + } + + // When viewing your own collection, only show the Follow button + // for accounts you're not following (anymore). + // Otherwise, always show the follow button in its various states. + const withoutButton = + accountId === me || + !relationship || + (collectionOwnerId === me && + (relationship.following || relationship.requested)); + + return ; +}; + export const CollectionDetailPage: React.FC<{ multiColumn?: boolean; }> = ({ multiColumn }) => { @@ -163,11 +186,13 @@ export const CollectionDetailPage: React.FC<{ collection ? : null } > - {collection?.items.map(({ account_id }) => - account_id ? ( - - ) : null, - )} + {collection?.items.map(({ account_id }) => ( + + ))} diff --git a/app/javascript/mastodon/features/collections/index.tsx b/app/javascript/mastodon/features/collections/index.tsx index 607d7fe4f3..24819cf755 100644 --- a/app/javascript/mastodon/features/collections/index.tsx +++ b/app/javascript/mastodon/features/collections/index.tsx @@ -14,7 +14,7 @@ import { Icon } from 'mastodon/components/icon'; import ScrollableList from 'mastodon/components/scrollable_list'; import { fetchAccountCollections, - selectMyCollections, + selectAccountCollections, } from 'mastodon/reducers/slices/collections'; import { useAppSelector, useAppDispatch } from 'mastodon/store'; @@ -31,7 +31,9 @@ export const Collections: React.FC<{ const dispatch = useAppDispatch(); const intl = useIntl(); const me = useAppSelector((state) => state.meta.get('me') as string); - const { collections, status } = useAppSelector(selectMyCollections); + const { collections, status } = useAppSelector((state) => + selectAccountCollections(state, me), + ); useEffect(() => { void dispatch(fetchAccountCollections({ accountId: me })); diff --git a/app/javascript/mastodon/features/report/comment.jsx b/app/javascript/mastodon/features/report/comment.jsx deleted file mode 100644 index b80c14fcb9..0000000000 --- a/app/javascript/mastodon/features/report/comment.jsx +++ /dev/null @@ -1,121 +0,0 @@ -import PropTypes from 'prop-types'; -import { useCallback, useEffect, useRef } from 'react'; - -import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; - -import { createSelector } from '@reduxjs/toolkit'; -import { OrderedSet, List as ImmutableList } from 'immutable'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { shallowEqual } from 'react-redux'; - -import Toggle from 'react-toggle'; - -import { fetchAccount } from 'mastodon/actions/accounts'; -import { Button } from 'mastodon/components/button'; -import { useAppDispatch, useAppSelector } from 'mastodon/store'; - -const messages = defineMessages({ - placeholder: { id: 'report.placeholder', defaultMessage: 'Type or paste additional comments' }, -}); - -const selectRepliedToAccountIds = createSelector( - [ - (state) => state.get('statuses'), - (_, statusIds) => statusIds, - ], - (statusesMap, statusIds) => statusIds.map((statusId) => statusesMap.getIn([statusId, 'in_reply_to_account_id'])), - { - resultEqualityCheck: shallowEqual, - } -); - -const Comment = ({ comment, domain, statusIds, isRemote, isSubmitting, selectedDomains, onSubmit, onChangeComment, onToggleDomain }) => { - const intl = useIntl(); - - const dispatch = useAppDispatch(); - const loadedRef = useRef(false); - - const handleClick = useCallback(() => onSubmit(), [onSubmit]); - const handleChange = useCallback((e) => onChangeComment(e.target.value), [onChangeComment]); - const handleToggleDomain = useCallback(e => onToggleDomain(e.target.value, e.target.checked), [onToggleDomain]); - - const handleKeyDown = useCallback((e) => { - if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) { - handleClick(); - } - }, [handleClick]); - - // Memoize accountIds since we don't want it to trigger `useEffect` on each render - const accountIds = useAppSelector((state) => domain ? selectRepliedToAccountIds(state, statusIds) : ImmutableList()); - - // While we could memoize `availableDomains`, it is pretty inexpensive to recompute - const accountsMap = useAppSelector((state) => state.get('accounts')); - const availableDomains = domain ? OrderedSet([domain]).union(accountIds.map((accountId) => accountsMap.getIn([accountId, 'acct'], '').split('@')[1]).filter(domain => !!domain)) : OrderedSet(); - - useEffect(() => { - if (loadedRef.current) { - return; - } - - loadedRef.current = true; - - // First, pre-select known domains - availableDomains.forEach((domain) => { - onToggleDomain(domain, true); - }); - - // Then, fetch missing replied-to accounts - const unknownAccounts = OrderedSet(accountIds.filter(accountId => accountId && !accountsMap.has(accountId))); - unknownAccounts.forEach((accountId) => { - dispatch(fetchAccount(accountId)); - }); - }); - - return ( - <> -

- -