diff --git a/Gemfile.lock b/Gemfile.lock index 0f6f85585c..f95bdc00eb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -791,10 +791,10 @@ GEM rubocop-i18n (3.2.3) lint_roller (~> 1.1) rubocop (>= 1.72.1) - rubocop-performance (1.25.0) + rubocop-performance (1.26.0) lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) - rubocop-ast (>= 1.38.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) rubocop-rails (2.33.3) activesupport (>= 4.2.0) lint_roller (~> 1.1) @@ -817,7 +817,7 @@ GEM ruby-vips (2.2.5) ffi (~> 1.12) logger - rubyzip (3.0.2) + rubyzip (3.1.0) rufus-scheduler (3.9.2) fugit (~> 1.1, >= 1.11.1) safety_net_attestation (0.4.0) diff --git a/app/controllers/activitypub/contexts_controller.rb b/app/controllers/activitypub/contexts_controller.rb new file mode 100644 index 0000000000..4daa75552e --- /dev/null +++ b/app/controllers/activitypub/contexts_controller.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +class ActivityPub::ContextsController < ActivityPub::BaseController + vary_by -> { 'Signature' if authorized_fetch_mode? } + + before_action :require_account_signature!, if: :authorized_fetch_mode? + before_action :set_conversation + before_action :set_items + + DESCENDANTS_LIMIT = 60 + + def show + expires_in 3.minutes, public: public_fetch_mode? + render_with_cache json: context_presenter, serializer: ActivityPub::ContextSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + end + + def items + expires_in 3.minutes, public: public_fetch_mode? + render_with_cache json: items_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + end + + private + + def account_required? + false + end + + def set_conversation + account_id, status_id = params[:id].split('-') + @conversation = Conversation.local.find_by(parent_account_id: account_id, parent_status_id: status_id) + end + + def set_items + @items = @conversation.statuses.distributable_visibility.paginate_by_min_id(DESCENDANTS_LIMIT, params[:min_id]) + end + + def context_presenter + first_page = ActivityPub::CollectionPresenter.new( + id: items_context_url(@conversation, page_params), + type: :unordered, + part_of: items_context_url(@conversation), + next: next_page, + items: @items.map { |status| status.local? ? ActivityPub::TagManager.instance.uri_for(status) : status.uri } + ) + + ActivityPub::ContextPresenter.from_conversation(@conversation).tap do |presenter| + presenter.first = first_page + end + end + + def items_collection_presenter + page = ActivityPub::CollectionPresenter.new( + id: items_context_url(@conversation, page_params), + type: :unordered, + part_of: items_context_url(@conversation), + next: next_page, + items: @items.map { |status| status.local? ? ActivityPub::TagManager.instance.uri_for(status) : status.uri } + ) + + return page if page_requested? + + ActivityPub::CollectionPresenter.new( + id: items_context_url(@conversation), + type: :unordered, + first: page + ) + end + + def page_requested? + truthy_param?(:page) + end + + def next_page + return nil if @items.size < DESCENDANTS_LIMIT + + items_context_url(@conversation, page: true, min_id: @items.last.id) + end + + def page_params + params.permit(:page, :min_id) + end +end diff --git a/app/controllers/settings/preferences/posting_defaults_controller.rb b/app/controllers/settings/preferences/posting_defaults_controller.rb index 53fee0e5ca..dcff94fc71 100644 --- a/app/controllers/settings/preferences/posting_defaults_controller.rb +++ b/app/controllers/settings/preferences/posting_defaults_controller.rb @@ -6,4 +6,10 @@ class Settings::Preferences::PostingDefaultsController < Settings::Preferences:: def after_update_redirect_path settings_preferences_posting_defaults_path end + + def user_params + super.tap do |params| + params[:settings_attributes][:default_quote_policy] = 'nobody' if params[:settings_attributes][:default_privacy] == 'private' + end + end end diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx index 0970fc585e..db72be6515 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -145,6 +145,10 @@ function loaded() { ); }); + updateDefaultQuotePrivacyFromPrivacy( + document.querySelector('#user_settings_attributes_default_privacy'), + ); + const reactComponents = document.querySelectorAll('[data-component]'); if (reactComponents.length > 0) { @@ -364,6 +368,34 @@ Rails.delegate( }, ); +const updateDefaultQuotePrivacyFromPrivacy = ( + privacySelect: EventTarget | null, +) => { + if (!(privacySelect instanceof HTMLSelectElement) || !privacySelect.form) + return; + + const select = privacySelect.form.querySelector( + 'select#user_settings_attributes_default_quote_policy', + ); + if (!select) return; + + if (privacySelect.value === 'private') { + select.value = 'nobody'; + setInputDisabled(select, true); + } else { + setInputDisabled(select, false); + } +}; + +Rails.delegate( + document, + '#user_settings_attributes_default_privacy', + 'change', + ({ target }) => { + updateDefaultQuotePrivacyFromPrivacy(target); + }, +); + // Empty the honeypot fields in JS in case something like an extension // automatically filled them. Rails.delegate(document, '#registration_new_user,#new_user', 'submit', () => { diff --git a/app/javascript/mastodon/actions/compose_typed.ts b/app/javascript/mastodon/actions/compose_typed.ts index 3c6a264993..7f70a1bd48 100644 --- a/app/javascript/mastodon/actions/compose_typed.ts +++ b/app/javascript/mastodon/actions/compose_typed.ts @@ -16,6 +16,7 @@ import type { Status } from '../models/status'; import { showAlert } from './alerts'; import { focusCompose } from './compose'; +import { openModal } from './modal'; const messages = defineMessages({ quoteErrorUpload: { @@ -110,8 +111,16 @@ export const quoteCompose = createAppThunk( export const quoteComposeByStatus = createAppThunk( (status: Status, { dispatch, getState }) => { - const composeState = getState().compose; + const state = getState(); + const composeState = state.compose; const mediaAttachments = composeState.get('media_attachments'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const wasQuietPostHintModalDismissed: boolean = + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + state.settings.getIn( + ['dismissed_banners', 'quote/quiet_post_hint'], + false, + ); if (composeState.get('poll')) { dispatch(showAlert({ message: messages.quoteErrorPoll })); @@ -131,6 +140,16 @@ export const quoteComposeByStatus = createAppThunk( status.getIn(['quote_approval', 'current_user']) !== 'manual' ) { dispatch(showAlert({ message: messages.quoteErrorUnauthorized })); + } else if ( + status.get('visibility') === 'unlisted' && + !wasQuietPostHintModalDismissed + ) { + dispatch( + openModal({ + modalType: 'CONFIRM_QUIET_QUOTE', + modalProps: { status }, + }), + ); } else { dispatch(quoteCompose(status)); } diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index 330da74000..d5e59b8b05 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -21,6 +21,15 @@ export function normalizeFilterResult(result) { return normalResult; } +function stripQuoteFallback(text) { + const wrapper = document.createElement('div'); + wrapper.innerHTML = text; + + wrapper.querySelector('.quote-inline')?.remove(); + + return wrapper.innerHTML; +} + export function normalizeStatus(status, normalOldStatus) { const normalStatus = { ...status }; @@ -86,6 +95,11 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap); normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive; + // Remove quote fallback link from the DOM so it doesn't mess with paragraph margins + if (normalStatus.quote) { + normalStatus.contentHtml = stripQuoteFallback(normalStatus.contentHtml); + } + if (normalStatus.url && !(normalStatus.url.startsWith('http://') || normalStatus.url.startsWith('https://'))) { normalStatus.url = null; } @@ -125,6 +139,11 @@ export function normalizeStatusTranslation(translation, status) { spoiler_text: translation.spoiler_text, }; + // Remove quote fallback link from the DOM so it doesn't mess with paragraph margins + if (status.get('quote')) { + normalTranslation.contentHtml = stripQuoteFallback(normalTranslation.contentHtml); + } + return normalTranslation; } diff --git a/app/javascript/mastodon/components/dropdown/index.tsx b/app/javascript/mastodon/components/dropdown/index.tsx index 1e442f8159..b6a04b9027 100644 --- a/app/javascript/mastodon/components/dropdown/index.tsx +++ b/app/javascript/mastodon/components/dropdown/index.tsx @@ -1,22 +1,28 @@ import { useCallback, useId, useMemo, useRef, useState } from 'react'; import type { ComponentPropsWithoutRef, FC } from 'react'; -import { FormattedMessage } from 'react-intl'; +import { useIntl } from 'react-intl'; import type { MessageDescriptor } from 'react-intl'; import classNames from 'classnames'; import Overlay from 'react-overlays/Overlay'; +import UnfoldMoreIcon from '@/material-icons/400-24px/unfold_more.svg?react'; + import type { SelectItem } from '../dropdown_selector'; import { DropdownSelector } from '../dropdown_selector'; +import { Icon } from '../icon'; + +import { matchWidth } from './utils'; interface DropdownProps { - title: string; disabled?: boolean; items: SelectItem[]; onChange: (value: string) => void; current: string; + labelId: string; + descriptionId?: string; emptyText?: MessageDescriptor; classPrefix: string; } @@ -24,39 +30,59 @@ interface DropdownProps { export const Dropdown: FC< DropdownProps & Omit, keyof DropdownProps> > = ({ - title, disabled, items, current, onChange, + labelId, + descriptionId, classPrefix, className, + id, ...buttonProps }) => { + const intl = useIntl(); const buttonRef = useRef(null); - const accessibilityId = useId(); + const uniqueId = useId(); + const buttonId = id ?? `${uniqueId}-button`; + const listboxId = `${uniqueId}-listbox`; const [open, setOpen] = useState(false); + const handleToggle = useCallback(() => { if (!disabled) { - setOpen((prevOpen) => !prevOpen); + setOpen((prevOpen) => { + buttonRef.current?.focus(); + return !prevOpen; + }); } }, [disabled]); + const handleClose = useCallback(() => { setOpen(false); + buttonRef.current?.focus(); }, []); + const currentText = useMemo( - () => items.find((i) => i.value === current)?.text, - [current, items], + () => + items.find((i) => i.value === current)?.text ?? + intl.formatMessage({ + id: 'dropdown.empty', + defaultMessage: 'Select an option', + }), + [current, intl, items], ); + return ( <> {({ props, placement }) => ( @@ -96,7 +123,7 @@ export const Dropdown: FC< `${classPrefix}__dropdown`, placement, )} - id={accessibilityId} + id={listboxId} > = { + name: 'sameWidth', + enabled: true, + phase: 'beforeWrite', + requires: ['computeStyles'], + fn: ({ state }) => { + if (state.styles.popper) { + state.styles.popper.width = `${state.rects.reference.width}px`; + } + }, + effect: ({ state }) => { + const reference = state.elements.reference as HTMLElement; + state.elements.popper.style.width = `${reference.offsetWidth}px`; + }, +}; diff --git a/app/javascript/mastodon/components/dropdown_menu.tsx b/app/javascript/mastodon/components/dropdown_menu.tsx index 27af0ba6c0..8e765d1a65 100644 --- a/app/javascript/mastodon/components/dropdown_menu.tsx +++ b/app/javascript/mastodon/components/dropdown_menu.tsx @@ -36,6 +36,7 @@ import { import type { MenuItem } from 'mastodon/models/dropdown_menu'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; +import { Icon } from './icon'; import type { IconProp } from './icon'; import { IconButton } from './icon_button'; @@ -68,6 +69,27 @@ interface DropdownMenuProps { onItemClick?: ItemClickFn; } +export const DropdownMenuItemContent: React.FC<{ item: MenuItem }> = ({ + item, +}) => { + if (item === null) { + return null; + } + + const { text, description, icon } = item; + return ( + <> + {icon && } + + {text} + {Boolean(description) && ( + {description} + )} + + + ); +}; + export const DropdownMenu = ({ items, loading, @@ -200,7 +222,7 @@ export const DropdownMenu = ({ return
  • ; } - const { text, dangerous } = option; + const { text, highlighted, disabled, dangerous } = option; let element: React.ReactElement; @@ -211,8 +233,9 @@ export const DropdownMenu = ({ onClick={handleItemClick} onKeyUp={handleItemKeyUp} data-index={i} + disabled={disabled} > - {text} + ); } else if (isExternalLinkItem(option)) { @@ -227,7 +250,7 @@ export const DropdownMenu = ({ onKeyUp={handleItemKeyUp} data-index={i} > - {text} + ); } else { @@ -239,7 +262,7 @@ export const DropdownMenu = ({ onKeyUp={handleItemKeyUp} data-index={i} > - {text} + ); } @@ -247,6 +270,7 @@ export const DropdownMenu = ({ return (
  • = ({ onClose, onChange, }) => { - const nodeRef = useRef(null); + const listRef = useRef(null); const focusedItemRef = useRef(null); const [currentValue, setCurrentValue] = useState(value); - const handleDocumentClick = useCallback( - (e: MouseEvent | TouchEvent) => { - if ( - nodeRef.current && - e.target instanceof Node && - !nodeRef.current.contains(e.target) - ) { - onClose(); - e.stopPropagation(); - } - }, - [nodeRef, onClose], - ); - const handleClick = useCallback( ( e: React.MouseEvent | React.KeyboardEvent, @@ -88,30 +74,30 @@ export const DropdownSelector: React.FC = ({ break; case 'ArrowDown': element = - nodeRef.current?.children[index + 1] ?? - nodeRef.current?.firstElementChild; + listRef.current?.children[index + 1] ?? + listRef.current?.firstElementChild; break; case 'ArrowUp': element = - nodeRef.current?.children[index - 1] ?? - nodeRef.current?.lastElementChild; + listRef.current?.children[index - 1] ?? + listRef.current?.lastElementChild; break; case 'Tab': if (e.shiftKey) { element = - nodeRef.current?.children[index - 1] ?? - nodeRef.current?.lastElementChild; + listRef.current?.children[index - 1] ?? + listRef.current?.lastElementChild; } else { element = - nodeRef.current?.children[index + 1] ?? - nodeRef.current?.firstElementChild; + listRef.current?.children[index + 1] ?? + listRef.current?.firstElementChild; } break; case 'Home': - element = nodeRef.current?.firstElementChild; + element = listRef.current?.firstElementChild; break; case 'End': - element = nodeRef.current?.lastElementChild; + element = listRef.current?.lastElementChild; break; } @@ -123,12 +109,24 @@ export const DropdownSelector: React.FC = ({ e.stopPropagation(); } }, - [nodeRef, items, onClose, handleClick, setCurrentValue], + [items, onClose, handleClick, setCurrentValue], ); useEffect(() => { + const handleDocumentClick = (e: MouseEvent | TouchEvent) => { + if ( + listRef.current && + e.target instanceof Node && + !listRef.current.contains(e.target) + ) { + onClose(); + e.stopPropagation(); + } + }; + document.addEventListener('click', handleDocumentClick, { capture: true }); document.addEventListener('touchend', handleDocumentClick, listenerOptions); + focusedItemRef.current?.focus({ preventScroll: true }); return () => { @@ -141,10 +139,10 @@ export const DropdownSelector: React.FC = ({ listenerOptions, ); }; - }, [handleDocumentClick]); + }, [onClose]); return ( -
      +
        {items.map((item) => (
      • ( - 0} /> diff --git a/app/javascript/mastodon/components/status/boost_button.tsx b/app/javascript/mastodon/components/status/boost_button.tsx new file mode 100644 index 0000000000..b34988de47 --- /dev/null +++ b/app/javascript/mastodon/components/status/boost_button.tsx @@ -0,0 +1,253 @@ +import { useCallback, useMemo } from 'react'; +import type { FC, KeyboardEvent, MouseEvent, MouseEventHandler } from 'react'; + +import { useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import { quoteComposeById } from '@/mastodon/actions/compose_typed'; +import { toggleReblog } from '@/mastodon/actions/interactions'; +import { openModal } from '@/mastodon/actions/modal'; +import type { ActionMenuItem } from '@/mastodon/models/dropdown_menu'; +import type { Status } from '@/mastodon/models/status'; +import { useAppDispatch, useAppSelector } from '@/mastodon/store'; +import { isFeatureEnabled } from '@/mastodon/utils/environment'; +import type { SomeRequired } from '@/mastodon/utils/types'; + +import type { RenderItemFn, RenderItemFnHandlers } from '../dropdown_menu'; +import { Dropdown, DropdownMenuItemContent } from '../dropdown_menu'; +import { IconButton } from '../icon_button'; + +import { + boostItemState, + messages, + quoteItemState, + selectStatusState, +} from './boost_button_utils'; + +const renderMenuItem: RenderItemFn = ( + item, + index, + handlers, + focusRefCallback, +) => ( + +); + +interface ReblogButtonProps { + status: Status; + counters?: boolean; +} + +type ActionMenuItemWithIcon = SomeRequired; + +export const StatusBoostButton: FC = ({ + status, + counters, +}) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const statusState = useAppSelector((state) => + selectStatusState(state, status), + ); + const { + isLoggedIn, + isReblogged, + isReblogAllowed, + isQuoteAutomaticallyAccepted, + isQuoteManuallyAccepted, + } = statusState; + + const isMenuDisabled = + !isQuoteAutomaticallyAccepted && + !isQuoteManuallyAccepted && + !isReblogAllowed; + + const statusId = status.get('id') as string; + const wasBoosted = !!status.get('reblogged'); + + const items = useMemo(() => { + const boostItem = boostItemState(statusState); + const quoteItem = quoteItemState(statusState); + return [ + { + text: intl.formatMessage(boostItem.title), + description: boostItem.meta + ? intl.formatMessage(boostItem.meta) + : undefined, + icon: boostItem.iconComponent, + highlighted: wasBoosted, + disabled: boostItem.disabled, + action: (event) => { + if (isLoggedIn) { + dispatch(toggleReblog(statusId, event.shiftKey)); + } + }, + }, + { + text: intl.formatMessage(quoteItem.title), + description: quoteItem.meta + ? intl.formatMessage(quoteItem.meta) + : undefined, + icon: quoteItem.iconComponent, + disabled: quoteItem.disabled, + action: () => { + if (isLoggedIn) { + dispatch(quoteComposeById(statusId)); + } + }, + }, + ] satisfies [ActionMenuItemWithIcon, ActionMenuItemWithIcon]; + }, [dispatch, intl, isLoggedIn, statusId, statusState, wasBoosted]); + + const boostIcon = items[0].icon; + + const handleDropdownOpen = useCallback( + (event: MouseEvent | KeyboardEvent) => { + if (!isLoggedIn) { + dispatch( + openModal({ + modalType: 'INTERACTION', + modalProps: { + type: 'reblog', + accountId: status.getIn(['account', 'id']), + url: status.get('uri'), + }, + }), + ); + } else if (event.shiftKey) { + dispatch(toggleReblog(status.get('id'), true)); + return false; + } + return true; + }, + [dispatch, isLoggedIn, status], + ); + + return ( + + + + ); +}; + +interface ReblogMenuItemProps { + item: ActionMenuItem; + index: number; + handlers: RenderItemFnHandlers; + focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void; +} + +const ReblogMenuItem: FC = ({ + index, + item, + handlers, + focusRefCallback, +}) => { + const { text, highlighted, disabled } = item; + + return ( +
      • + +
      • + ); +}; + +// Legacy helpers + +// Switch between the legacy and new reblog button based on feature flag. +export const BoostButton: FC = (props) => { + if (isFeatureEnabled('outgoing_quotes')) { + return ; + } + return ; +}; + +export const LegacyReblogButton: FC = ({ + status, + counters, +}) => { + const intl = useIntl(); + const statusState = useAppSelector((state) => + selectStatusState(state, status), + ); + + const { title, meta, iconComponent, disabled } = useMemo( + () => boostItemState(statusState), + [statusState], + ); + + const dispatch = useAppDispatch(); + const handleClick: MouseEventHandler = useCallback( + (event) => { + if (statusState.isLoggedIn) { + dispatch(toggleReblog(status.get('id') as string, event.shiftKey)); + } else { + dispatch( + openModal({ + modalType: 'INTERACTION', + modalProps: { + type: 'reblog', + accountId: status.getIn(['account', 'id']), + url: status.get('uri'), + }, + }), + ); + } + }, + [dispatch, status, statusState.isLoggedIn], + ); + + return ( + + ); +}; diff --git a/app/javascript/mastodon/components/status/boost_button_utils.ts b/app/javascript/mastodon/components/status/boost_button_utils.ts new file mode 100644 index 0000000000..34fa26acea --- /dev/null +++ b/app/javascript/mastodon/components/status/boost_button_utils.ts @@ -0,0 +1,161 @@ +import { defineMessages } from 'react-intl'; +import type { MessageDescriptor } from 'react-intl'; + +import type { Status, StatusVisibility } from '@/mastodon/models/status'; +import { createAppSelector } from '@/mastodon/store'; +import FormatQuote from '@/material-icons/400-24px/format_quote-fill.svg?react'; +import FormatQuoteOff from '@/material-icons/400-24px/format_quote_off-fill.svg?react'; +import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; +import RepeatActiveIcon from '@/svg-icons/repeat_active.svg?react'; +import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react'; +import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react'; +import RepeatPrivateActiveIcon from '@/svg-icons/repeat_private_active.svg?react'; + +import type { IconProp } from '../icon'; + +export const messages = defineMessages({ + all_disabled: { + id: 'status.all_disabled', + defaultMessage: 'Boosts and quotes are disabled', + }, + quote: { id: 'status.quote', defaultMessage: 'Quote' }, + quote_cannot: { + id: 'status.cannot_quote', + defaultMessage: 'Quotes are disabled on this post', + }, + quote_followers_only: { + id: 'status.quote_followers_only', + defaultMessage: 'Only followers can quote this post', + }, + quote_manual_review: { + id: 'status.quote_manual_review', + defaultMessage: 'Author will manually review', + }, + quote_private: { + id: 'status.quote_private', + defaultMessage: 'Private posts cannot be quoted', + }, + reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, + reblog_or_quote: { + id: 'status.reblog_or_quote', + defaultMessage: 'Boost or quote', + }, + reblog_cancel: { + id: 'status.cancel_reblog_private', + defaultMessage: 'Unboost', + }, + reblog_private: { + id: 'status.reblog_private', + defaultMessage: 'Share again with your followers', + }, + reblog_cannot: { + id: 'status.cannot_reblog', + defaultMessage: 'This post cannot be boosted', + }, + request_quote: { + id: 'status.request_quote', + defaultMessage: 'Request to quote', + }, +}); + +export const selectStatusState = createAppSelector( + [ + (state) => state.meta.get('me') as string | undefined, + (_, status: Status) => status, + ], + (userId, status) => { + const isPublic = ['public', 'unlisted'].includes( + status.get('visibility') as StatusVisibility, + ); + const isMineAndPrivate = + userId === status.getIn(['account', 'id']) && + status.get('visibility') === 'private'; + return { + isLoggedIn: !!userId, + isPublic, + isMine: userId === status.getIn(['account', 'id']), + isPrivateReblog: + userId === status.getIn(['account', 'id']) && + status.get('visibility') === 'private', + isReblogged: !!status.get('reblogged'), + isReblogAllowed: isPublic || isMineAndPrivate, + isQuoteAutomaticallyAccepted: + status.getIn(['quote_approval', 'current_user']) === 'automatic' && + (isPublic || isMineAndPrivate), + isQuoteManuallyAccepted: + status.getIn(['quote_approval', 'current_user']) === 'manual' && + (isPublic || isMineAndPrivate), + isQuoteFollowersOnly: + status.getIn(['quote_approval', 'automatic', 0]) === 'followers' || + status.getIn(['quote_approval', 'manual', 0]) === 'followers', + }; + }, +); + +export type StatusState = ReturnType; + +export interface MenuItemState { + title: MessageDescriptor; + meta?: MessageDescriptor; + iconComponent: IconProp; + disabled?: boolean; +} + +export function boostItemState({ + isPublic, + isPrivateReblog, + isReblogged, +}: StatusState): MenuItemState { + if (isReblogged) { + return { + title: messages.reblog_cancel, + iconComponent: isPublic ? RepeatActiveIcon : RepeatPrivateActiveIcon, + }; + } + const iconText: MenuItemState = { + title: messages.reblog, + iconComponent: RepeatIcon, + }; + + if (isPrivateReblog) { + iconText.meta = messages.reblog_private; + iconText.iconComponent = RepeatPrivateIcon; + } else if (!isPublic) { + iconText.meta = messages.reblog_cannot; + iconText.iconComponent = RepeatDisabledIcon; + iconText.disabled = true; + } + return iconText; +} + +export function quoteItemState({ + isMine, + isQuoteAutomaticallyAccepted, + isQuoteManuallyAccepted, + isQuoteFollowersOnly, + isPublic, +}: StatusState): MenuItemState { + const iconText: MenuItemState = { + title: messages.quote, + iconComponent: FormatQuote, + }; + + if (!isPublic && !isMine) { + iconText.disabled = true; + iconText.iconComponent = FormatQuoteOff; + iconText.meta = messages.quote_private; + } else if (isQuoteAutomaticallyAccepted) { + iconText.title = messages.quote; + } else if (isQuoteManuallyAccepted) { + iconText.title = messages.request_quote; + iconText.meta = messages.quote_manual_review; + } else { + iconText.disabled = true; + iconText.iconComponent = FormatQuoteOff; + iconText.meta = isQuoteFollowersOnly + ? messages.quote_followers_only + : messages.quote_cannot; + } + + return iconText; +} diff --git a/app/javascript/mastodon/components/status/reblog_button.tsx b/app/javascript/mastodon/components/status/reblog_button.tsx deleted file mode 100644 index 079ca5d7c8..0000000000 --- a/app/javascript/mastodon/components/status/reblog_button.tsx +++ /dev/null @@ -1,425 +0,0 @@ -import { useCallback, useMemo } from 'react'; -import type { - FC, - KeyboardEvent, - MouseEvent, - MouseEventHandler, - SVGProps, -} from 'react'; - -import type { MessageDescriptor } from 'react-intl'; -import { defineMessages, useIntl } from 'react-intl'; - -import classNames from 'classnames'; - -import { quoteComposeById } from '@/mastodon/actions/compose_typed'; -import { toggleReblog } from '@/mastodon/actions/interactions'; -import { openModal } from '@/mastodon/actions/modal'; -import type { ActionMenuItem } from '@/mastodon/models/dropdown_menu'; -import type { Status, StatusVisibility } from '@/mastodon/models/status'; -import { - createAppSelector, - useAppDispatch, - useAppSelector, -} from '@/mastodon/store'; -import { isFeatureEnabled } from '@/mastodon/utils/environment'; -import FormatQuote from '@/material-icons/400-24px/format_quote-fill.svg?react'; -import FormatQuoteOff from '@/material-icons/400-24px/format_quote_off-fill.svg?react'; -import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; -import RepeatActiveIcon from '@/svg-icons/repeat_active.svg?react'; -import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react'; -import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react'; -import RepeatPrivateActiveIcon from '@/svg-icons/repeat_private_active.svg?react'; - -import type { RenderItemFn, RenderItemFnHandlers } from '../dropdown_menu'; -import { Dropdown } from '../dropdown_menu'; -import { Icon } from '../icon'; -import { IconButton } from '../icon_button'; - -const messages = defineMessages({ - all_disabled: { - id: 'status.all_disabled', - defaultMessage: 'Boosts and quotes are disabled', - }, - quote: { id: 'status.quote', defaultMessage: 'Quote' }, - quote_cannot: { - id: 'status.cannot_quote', - defaultMessage: 'Quotes are disabled on this post', - }, - quote_followers_only: { - id: 'status.quote_followers_only', - defaultMessage: 'Only followers can quote this post', - }, - quote_manual_review: { - id: 'status.quote_manual_review', - defaultMessage: 'Author will manually review', - }, - quote_private: { - id: 'status.quote_private', - defaultMessage: 'Private posts cannot be quoted', - }, - reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, - reblog_or_quote: { - id: 'status.reblog_or_quote', - defaultMessage: 'Boost or quote', - }, - reblog_cancel: { - id: 'status.cancel_reblog_private', - defaultMessage: 'Unboost', - }, - reblog_private: { - id: 'status.reblog_private', - defaultMessage: 'Share again with your followers', - }, - reblog_cannot: { - id: 'status.cannot_reblog', - defaultMessage: 'This post cannot be boosted', - }, - request_quote: { - id: 'status.request_quote', - defaultMessage: 'Request to quote', - }, -}); - -interface ReblogButtonProps { - status: Status; - counters?: boolean; -} - -export const StatusReblogButton: FC = ({ - status, - counters, -}) => { - const intl = useIntl(); - - const statusState = useAppSelector((state) => - selectStatusState(state, status), - ); - const { - isLoggedIn, - isReblogged, - isReblogAllowed, - isQuoteAutomaticallyAccepted, - isQuoteManuallyAccepted, - } = statusState; - const { iconComponent } = useMemo( - () => reblogIconText(statusState), - [statusState], - ); - const disabled = - !isQuoteAutomaticallyAccepted && - !isQuoteManuallyAccepted && - !isReblogAllowed; - - const dispatch = useAppDispatch(); - const statusId = status.get('id') as string; - const items: ActionMenuItem[] = useMemo( - () => [ - { - text: 'reblog', - action: (event) => { - if (isLoggedIn) { - dispatch(toggleReblog(statusId, event.shiftKey)); - } - }, - }, - { - text: 'quote', - action: () => { - if (isLoggedIn) { - dispatch(quoteComposeById(statusId)); - } - }, - }, - ], - [dispatch, isLoggedIn, statusId], - ); - - const handleDropdownOpen = useCallback( - (event: MouseEvent | KeyboardEvent) => { - if (!isLoggedIn) { - dispatch( - openModal({ - modalType: 'INTERACTION', - modalProps: { - type: 'reblog', - accountId: status.getIn(['account', 'id']), - url: status.get('uri'), - }, - }), - ); - } else if (event.shiftKey) { - dispatch(toggleReblog(status.get('id'), true)); - return false; - } - return true; - }, - [dispatch, isLoggedIn, status], - ); - - const renderMenuItem: RenderItemFn = useCallback( - (item, index, handlers, focusRefCallback) => ( - - ), - [status], - ); - - return ( - - - - ); -}; - -interface ReblogMenuItemProps { - status: Status; - item: ActionMenuItem; - index: number; - handlers: RenderItemFnHandlers; - focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void; -} - -const ReblogMenuItem: FC = ({ - status, - index, - item: { text }, - handlers, - focusRefCallback, -}) => { - const intl = useIntl(); - const statusState = useAppSelector((state) => - selectStatusState(state, status), - ); - const { title, meta, iconComponent, disabled } = useMemo( - () => - text === 'quote' - ? quoteIconText(statusState) - : reblogIconText(statusState), - [statusState, text], - ); - const active = useMemo( - () => text === 'reblog' && !!status.get('reblogged'), - [status, text], - ); - - return ( -
      • - -
      • - ); -}; - -// Legacy helpers - -// Switch between the legacy and new reblog button based on feature flag. -export const ReblogButton: FC = (props) => { - if (isFeatureEnabled('outgoing_quotes')) { - return ; - } - return ; -}; - -export const LegacyReblogButton: FC = ({ - status, - counters, -}) => { - const intl = useIntl(); - const statusState = useAppSelector((state) => - selectStatusState(state, status), - ); - - const { title, meta, iconComponent, disabled } = useMemo( - () => reblogIconText(statusState), - [statusState], - ); - - const dispatch = useAppDispatch(); - const handleClick: MouseEventHandler = useCallback( - (event) => { - if (statusState.isLoggedIn) { - dispatch(toggleReblog(status.get('id') as string, event.shiftKey)); - } else { - dispatch( - openModal({ - modalType: 'INTERACTION', - modalProps: { - type: 'reblog', - accountId: status.getIn(['account', 'id']), - url: status.get('uri'), - }, - }), - ); - } - }, - [dispatch, status, statusState.isLoggedIn], - ); - - return ( - - ); -}; - -// Helpers for copy and state for status. -const selectStatusState = createAppSelector( - [ - (state) => state.meta.get('me') as string | undefined, - (_, status: Status) => status, - ], - (userId, status) => { - const isPublic = ['public', 'unlisted'].includes( - status.get('visibility') as StatusVisibility, - ); - const isMineAndPrivate = - userId === status.getIn(['account', 'id']) && - status.get('visibility') === 'private'; - return { - isLoggedIn: !!userId, - isPublic, - isMine: userId === status.getIn(['account', 'id']), - isPrivateReblog: - userId === status.getIn(['account', 'id']) && - status.get('visibility') === 'private', - isReblogged: !!status.get('reblogged'), - isReblogAllowed: isPublic || isMineAndPrivate, - isQuoteAutomaticallyAccepted: - status.getIn(['quote_approval', 'current_user']) === 'automatic' && - (isPublic || isMineAndPrivate), - isQuoteManuallyAccepted: - status.getIn(['quote_approval', 'current_user']) === 'manual' && - (isPublic || isMineAndPrivate), - isQuoteFollowersOnly: - status.getIn(['quote_approval', 'automatic', 0]) === 'followers' || - status.getIn(['quote_approval', 'manual', 0]) === 'followers', - }; - }, -); -type StatusState = ReturnType; - -interface IconText { - title: MessageDescriptor; - meta?: MessageDescriptor; - iconComponent: FC>; - disabled?: boolean; -} - -function reblogIconText({ - isPublic, - isPrivateReblog, - isReblogged, -}: StatusState): IconText { - if (isReblogged) { - return { - title: messages.reblog_cancel, - iconComponent: isPublic ? RepeatActiveIcon : RepeatPrivateActiveIcon, - }; - } - const iconText: IconText = { - title: messages.reblog, - iconComponent: RepeatIcon, - }; - - if (isPrivateReblog) { - iconText.meta = messages.reblog_private; - iconText.iconComponent = RepeatPrivateIcon; - } else if (!isPublic) { - iconText.meta = messages.reblog_cannot; - iconText.iconComponent = RepeatDisabledIcon; - iconText.disabled = true; - } - return iconText; -} - -function quoteIconText({ - isMine, - isQuoteAutomaticallyAccepted, - isQuoteManuallyAccepted, - isQuoteFollowersOnly, - isPublic, -}: StatusState): IconText { - const iconText: IconText = { - title: messages.quote, - iconComponent: FormatQuote, - }; - - if (!isPublic && !isMine) { - iconText.disabled = true; - iconText.iconComponent = FormatQuoteOff; - iconText.meta = messages.quote_private; - } else if (isQuoteAutomaticallyAccepted) { - iconText.title = messages.quote; - } else if (isQuoteManuallyAccepted) { - iconText.title = messages.request_quote; - iconText.meta = messages.quote_manual_review; - } else { - iconText.disabled = true; - iconText.iconComponent = FormatQuoteOff; - iconText.meta = isQuoteFollowersOnly - ? messages.quote_followers_only - : messages.quote_cannot; - } - - return iconText; -} diff --git a/app/javascript/mastodon/components/status_action_bar/index.jsx b/app/javascript/mastodon/components/status_action_bar/index.jsx index 0969240610..3aff359c10 100644 --- a/app/javascript/mastodon/components/status_action_bar/index.jsx +++ b/app/javascript/mastodon/components/status_action_bar/index.jsx @@ -24,7 +24,7 @@ import { me } from '../../initial_state'; import { IconButton } from '../icon_button'; import { isFeatureEnabled } from '../../utils/environment'; -import { ReblogButton } from '../status/reblog_button'; +import { BoostButton } from '../status/boost_button'; import { RemoveQuoteHint } from './remove_quote_hint'; const messages = defineMessages({ @@ -372,7 +372,7 @@ class StatusActionBar extends ImmutablePureComponent {
        - +
        diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index 38d24921c5..5f0f7079ae 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -138,16 +138,6 @@ class StatusContent extends PureComponent { onCollapsedToggle(collapsed); } - - // Remove quote fallback link from the DOM so it doesn't - // mess with paragraph margins - if (!!status.get('quote')) { - const inlineQuote = node.querySelector('.quote-inline'); - - if (inlineQuote) { - inlineQuote.remove(); - } - } } handleMouseEnter = ({ currentTarget }) => { diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx b/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx index 258291ae49..1c9434502c 100644 --- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx +++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.jsx @@ -18,7 +18,7 @@ export const messages = defineMessages({ public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, public_long: { id: 'privacy.public.long', defaultMessage: 'Anyone on and off Mastodon' }, unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' }, - unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Fewer algorithmic fanfares' }, + unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Hidden from Mastodon search results, trending, and public timelines' }, private_short: { id: 'privacy.private.short', defaultMessage: 'Followers' }, private_long: { id: 'privacy.private.long', defaultMessage: 'Only your followers' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' }, diff --git a/app/javascript/mastodon/features/compose/components/visibility_button.tsx b/app/javascript/mastodon/features/compose/components/visibility_button.tsx index 203a569bcc..fadb896b5e 100644 --- a/app/javascript/mastodon/features/compose/components/visibility_button.tsx +++ b/app/javascript/mastodon/features/compose/components/visibility_button.tsx @@ -79,10 +79,12 @@ const visibilityOptions = { const PrivacyModalButton: FC = ({ disabled = false }) => { const intl = useIntl(); - const { visibility, quotePolicy } = useAppSelector((state) => ({ - visibility: state.compose.get('privacy') as StatusVisibility, - quotePolicy: state.compose.get('quote_policy') as ApiQuotePolicy, - })); + const quotePolicy = useAppSelector( + (state) => state.compose.get('quote_policy') as ApiQuotePolicy, + ); + const visibility = useAppSelector( + (state) => state.compose.get('privacy') as StatusVisibility, + ); const { icon, iconComponent } = useMemo(() => { const option = visibilityOptions[visibility]; diff --git a/app/javascript/mastodon/features/emoji/emoji_html.tsx b/app/javascript/mastodon/features/emoji/emoji_html.tsx index 0bd1000922..e143c9fc16 100644 --- a/app/javascript/mastodon/features/emoji/emoji_html.tsx +++ b/app/javascript/mastodon/features/emoji/emoji_html.tsx @@ -1,5 +1,7 @@ import type { ComponentPropsWithoutRef, ElementType } from 'react'; +import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; + import { useEmojify } from './hooks'; import type { CustomEmojiMapArg } from './types'; @@ -13,7 +15,7 @@ type EmojiHTMLProps = Omit< shallow?: boolean; }; -export const EmojiHTML = ({ +export const ModernEmojiHTML = ({ extraEmojis, htmlString, as: Wrapper = 'div', // Rename for syntax highlighting @@ -34,3 +36,14 @@ export const EmojiHTML = ({ ); }; + +export const EmojiHTML = ( + props: EmojiHTMLProps, +) => { + if (isModernEmojiEnabled()) { + return ; + } + const { as: asElement, htmlString, extraEmojis, ...rest } = props; + const Wrapper = asElement ?? 'div'; + return ; +}; diff --git a/app/javascript/mastodon/features/notifications/components/select_with_label.tsx b/app/javascript/mastodon/features/notifications/components/select_with_label.tsx index b25f8e66be..0b755702d9 100644 --- a/app/javascript/mastodon/features/notifications/components/select_with_label.tsx +++ b/app/javascript/mastodon/features/notifications/components/select_with_label.tsx @@ -143,16 +143,14 @@ export const SelectWithLabel: React.FC> = ({
        -
        - -
        +
        ); diff --git a/app/javascript/mastodon/features/status/components/action_bar.jsx b/app/javascript/mastodon/features/status/components/action_bar.jsx index f5dea36cc6..fa9d6497ae 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.jsx +++ b/app/javascript/mastodon/features/status/components/action_bar.jsx @@ -20,7 +20,7 @@ import { IconButton } from '../../../components/icon_button'; import { Dropdown } from 'mastodon/components/dropdown_menu'; import { me } from '../../../initial_state'; import { isFeatureEnabled } from '@/mastodon/utils/environment'; -import { ReblogButton } from '@/mastodon/components/status/reblog_button'; +import { BoostButton } from '@/mastodon/components/status/boost_button'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -310,7 +310,7 @@ class ActionBar extends PureComponent {
        - +
        diff --git a/app/javascript/mastodon/features/ui/components/actions_modal.tsx b/app/javascript/mastodon/features/ui/components/actions_modal.tsx index da42b86392..2577b21a17 100644 --- a/app/javascript/mastodon/features/ui/components/actions_modal.tsx +++ b/app/javascript/mastodon/features/ui/components/actions_modal.tsx @@ -1,6 +1,7 @@ import classNames from 'classnames'; import { Link } from 'react-router-dom'; +import { DropdownMenuItemContent } from 'mastodon/components/dropdown_menu'; import type { MenuItem } from 'mastodon/models/dropdown_menu'; import { isActionItem, @@ -18,14 +19,14 @@ export const ActionsModal: React.FC<{ return
      • ; } - const { text, dangerous } = option; + const { text, highlighted, disabled, dangerous } = option; let element: React.ReactElement; if (isActionItem(option)) { element = ( - ); } else if (isExternalLinkItem(option)) { @@ -38,21 +39,22 @@ export const ActionsModal: React.FC<{ onClick={onClick} data-index={i} > - {text} + ); } else { element = ( - {text} + ); } return (
      • diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx b/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx index 7a0bfe6a94..19ffe2bae5 100644 --- a/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx +++ b/app/javascript/mastodon/features/ui/components/confirmation_modals/confirmation_modal.tsx @@ -43,10 +43,6 @@ export const ConfirmationModal: React.FC< onSecondary?.(); }, [onClose, onSecondary]); - const handleCancel = useCallback(() => { - onClose(); - }, [onClose]); - return (
        @@ -58,7 +54,7 @@ export const ConfirmationModal: React.FC<
        -