diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 68ed0ac6c8..0cc9c8d8fc 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,6 +1,6 @@ # This configuration was generated by # `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp` -# using RuboCop version 1.79.2. +# using RuboCop version 1.80.2. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index f25d0547e8..fcba923030 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -12,13 +12,14 @@ import { initialize, mswLoader } from 'msw-storybook-addon'; import { action } from 'storybook/actions'; import type { LocaleData } from '@/mastodon/locales'; -import { reducerWithInitialState, rootReducer } from '@/mastodon/reducers'; +import { reducerWithInitialState } from '@/mastodon/reducers'; import { defaultMiddleware } from '@/mastodon/store/store'; import { mockHandlers, unhandledRequestHandler } from '@/testing/api'; // If you want to run the dark theme during development, // you can change the below to `/application.scss` import '../app/javascript/styles/mastodon-light.scss'; +import './styles.css'; const localeFiles = import.meta.glob('@/mastodon/locales/*.json', { query: { as: 'json' }, @@ -49,12 +50,17 @@ const preview: Preview = { locale: 'en', }, decorators: [ - (Story, { parameters }) => { + (Story, { parameters, globals }) => { + const { locale } = globals as { locale: string }; const { state = {} } = parameters; - let reducer = rootReducer; - if (typeof state === 'object' && state) { - reducer = reducerWithInitialState(state as Record); - } + const reducer = reducerWithInitialState( + { + meta: { + locale, + }, + }, + state as Record, + ); const store = configureStore({ reducer, middleware(getDefaultMiddleware) { diff --git a/.storybook/styles.css b/.storybook/styles.css new file mode 100644 index 0000000000..ac29890895 --- /dev/null +++ b/.storybook/styles.css @@ -0,0 +1,8 @@ +a { + color: inherit; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} diff --git a/Gemfile.lock b/Gemfile.lock index 93ed9eb601..f95bdc00eb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -771,7 +771,7 @@ GEM rspec-mocks (~> 3.0) sidekiq (>= 5, < 9) rspec-support (3.13.4) - rubocop (1.79.2) + rubocop (1.80.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -791,17 +791,17 @@ 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) rack (>= 1.1) rubocop (>= 1.75.0, < 2.0) rubocop-ast (>= 1.44.0, < 2.0) - rubocop-rspec (3.6.0) + rubocop-rspec (3.7.0) lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) rubocop-rspec_rails (2.31.0) @@ -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) @@ -903,7 +903,7 @@ GEM unf (0.1.4) unf_ext unf_ext (0.0.9.1) - unicode-display_width (3.1.4) + unicode-display_width (3.1.5) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) uri (1.0.3) 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/admin/export_domain_allows_controller.rb b/app/controllers/admin/export_domain_allows_controller.rb index ca88c6525e..d1a2ea5bbf 100644 --- a/app/controllers/admin/export_domain_allows_controller.rb +++ b/app/controllers/admin/export_domain_allows_controller.rb @@ -49,8 +49,8 @@ module Admin def export_data CSV.generate(headers: export_headers, write_headers: true) do |content| - DomainAllow.allowed_domains.each do |instance| - content << [instance.domain] + DomainAllow.allowed_domains.each do |domain| + content << [domain] end 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/flavours/glitch/actions/compose_typed.ts b/app/javascript/flavours/glitch/actions/compose_typed.ts index 434f14ecf6..f0219f7da7 100644 --- a/app/javascript/flavours/glitch/actions/compose_typed.ts +++ b/app/javascript/flavours/glitch/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/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js index ece72dd1e4..e9972aa90c 100644 --- a/app/javascript/flavours/glitch/actions/importer/normalizer.js +++ b/app/javascript/flavours/glitch/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, settings) { const normalStatus = { ...status }; @@ -78,6 +87,11 @@ export function normalizeStatus(status, normalOldStatus, settings) { normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap); normalStatus.hidden = (spoilerText.length > 0 || normalStatus.sensitive) && autoHideCW(settings, spoilerText); + // 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; } @@ -117,6 +131,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/flavours/glitch/components/alt_text_badge.tsx b/app/javascript/flavours/glitch/components/alt_text_badge.tsx index 293d398b3c..9b3748b2ca 100644 --- a/app/javascript/flavours/glitch/components/alt_text_badge.tsx +++ b/app/javascript/flavours/glitch/components/alt_text_badge.tsx @@ -13,9 +13,9 @@ import { useSelectableClick } from 'flavours/glitch/hooks/useSelectableClick'; const offset = [0, 4] as OffsetValue; const popperConfig = { strategy: 'fixed' } as UsePopperOptions; -export const AltTextBadge: React.FC<{ - description: string; -}> = ({ description }) => { +export const AltTextBadge: React.FC<{ description: string }> = ({ + description, +}) => { const accessibilityId = useId(); const anchorRef = useRef(null); const [open, setOpen] = useState(false); @@ -56,7 +56,7 @@ export const AltTextBadge: React.FC<{ {({ props }) => (
, 'children'> { block?: boolean; secondary?: boolean; + plain?: boolean; compact?: boolean; dangerous?: boolean; loading?: boolean; @@ -35,6 +36,7 @@ export const Button: React.FC = ({ disabled, block, secondary, + plain, compact, dangerous, loading, @@ -62,6 +64,7 @@ export const Button: React.FC = ({
diff --git a/app/javascript/flavours/glitch/components/display_name/index.tsx b/app/javascript/flavours/glitch/components/display_name/index.tsx new file mode 100644 index 0000000000..5572ddf907 --- /dev/null +++ b/app/javascript/flavours/glitch/components/display_name/index.tsx @@ -0,0 +1,122 @@ +import type { ComponentPropsWithoutRef, FC } from 'react'; +import { useMemo } from 'react'; + +import classNames from 'classnames'; +import type { LinkProps } from 'react-router-dom'; +import { Link } from 'react-router-dom'; + +import { EmojiHTML } from '@/flavours/glitch/features/emoji/emoji_html'; +import type { Account } from '@/flavours/glitch/models/account'; +import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment'; + +import { Skeleton } from '../skeleton'; + +interface Props { + account?: Account; + localDomain?: string; + simple?: boolean; + noDomain?: boolean; +} + +export const DisplayName: FC> = ({ + account, + localDomain, + simple = false, + noDomain = false, + className, + ...props +}) => { + const username = useMemo(() => { + if (!account || noDomain) { + return null; + } + let acct = account.get('acct'); + + if (!acct.includes('@') && localDomain) { + acct = `${acct}@${localDomain}`; + } + return `@${acct}`; + }, [account, localDomain, noDomain]); + + if (!account) { + if (simple) { + return null; + } + return ( + + + + + + + {!noDomain && ( + +   + + + )} + + ); + } + const accountName = isModernEmojiEnabled() + ? account.get('display_name') + : account.get('display_name_html'); + if (simple) { + return ( + + + + ); + } + + return ( + + + + + {username && ( +  {username} + )} + + ); +}; + +export const LinkedDisplayName: FC< + Props & { asProps?: ComponentPropsWithoutRef<'span'> } & Partial +> = ({ + account, + asProps = {}, + className, + localDomain, + simple, + noDomain, + ...linkProps +}) => { + const displayProps = { + account, + className, + localDomain, + simple, + noDomain, + ...asProps, + }; + if (!account) { + return ; + } + + return ( + + + + ); +}; diff --git a/app/javascript/flavours/glitch/components/dropdown/index.tsx b/app/javascript/flavours/glitch/components/dropdown/index.tsx index 1e442f8159..b6a04b9027 100644 --- a/app/javascript/flavours/glitch/components/dropdown/index.tsx +++ b/app/javascript/flavours/glitch/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/flavours/glitch/components/dropdown_menu.tsx b/app/javascript/flavours/glitch/components/dropdown_menu.tsx index ab3e27e990..592c3697b5 100644 --- a/app/javascript/flavours/glitch/components/dropdown_menu.tsx +++ b/app/javascript/flavours/glitch/components/dropdown_menu.tsx @@ -36,6 +36,7 @@ import { import type { MenuItem } from 'flavours/glitch/models/dropdown_menu'; import { useAppDispatch, useAppSelector } from 'flavours/glitch/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/flavours/glitch/components/status/boost_button.tsx b/app/javascript/flavours/glitch/components/status/boost_button.tsx new file mode 100644 index 0000000000..16373c1a8e --- /dev/null +++ b/app/javascript/flavours/glitch/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 '@/flavours/glitch/actions/compose_typed'; +import { toggleReblog } from '@/flavours/glitch/actions/interactions'; +import { openModal } from '@/flavours/glitch/actions/modal'; +import type { ActionMenuItem } from '@/flavours/glitch/models/dropdown_menu'; +import type { Status } from '@/flavours/glitch/models/status'; +import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store'; +import { isFeatureEnabled } from '@/flavours/glitch/utils/environment'; +import type { SomeRequired } from '@/flavours/glitch/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/flavours/glitch/components/status/boost_button_utils.ts b/app/javascript/flavours/glitch/components/status/boost_button_utils.ts new file mode 100644 index 0000000000..c752915f00 --- /dev/null +++ b/app/javascript/flavours/glitch/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 '@/flavours/glitch/models/status'; +import { createAppSelector } from '@/flavours/glitch/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/flavours/glitch/components/status/reblog_button.tsx b/app/javascript/flavours/glitch/components/status/reblog_button.tsx deleted file mode 100644 index 63f6cfb062..0000000000 --- a/app/javascript/flavours/glitch/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 '@/flavours/glitch/actions/compose_typed'; -import { toggleReblog } from '@/flavours/glitch/actions/interactions'; -import { openModal } from '@/flavours/glitch/actions/modal'; -import type { ActionMenuItem } from '@/flavours/glitch/models/dropdown_menu'; -import type { Status, StatusVisibility } from '@/flavours/glitch/models/status'; -import { - createAppSelector, - useAppDispatch, - useAppSelector, -} from '@/flavours/glitch/store'; -import { isFeatureEnabled } from '@/flavours/glitch/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: 'Boost with original visibility', - }, - 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/flavours/glitch/components/status_action_bar.jsx b/app/javascript/flavours/glitch/components/status_action_bar/index.jsx similarity index 91% rename from app/javascript/flavours/glitch/components/status_action_bar.jsx rename to app/javascript/flavours/glitch/components/status_action_bar/index.jsx index 2cbce0053d..812b36ee34 100644 --- a/app/javascript/flavours/glitch/components/status_action_bar.jsx +++ b/app/javascript/flavours/glitch/components/status_action_bar/index.jsx @@ -22,12 +22,13 @@ import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; import { Dropdown } from 'flavours/glitch/components/dropdown_menu'; -import { me } from '../initial_state'; +import { me } from '../../initial_state'; -import { IconButton } from './icon_button'; -import { RelativeTimestamp } from './relative_timestamp'; -import { isFeatureEnabled } from '../utils/environment'; -import { ReblogButton } from './status/reblog_button'; +import { IconButton } from '../icon_button'; +import { RelativeTimestamp } from '../relative_timestamp'; +import { isFeatureEnabled } from '../../utils/environment'; +import { BoostButton } from '../status/boost_button'; +import { RemoveQuoteHint } from './remove_quote_hint'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -76,6 +77,7 @@ class StatusActionBar extends ImmutablePureComponent { identity: identityContextPropShape, status: ImmutablePropTypes.map.isRequired, quotedAccountId: PropTypes.string, + contextType: PropTypes.string, onReply: PropTypes.func, onFavourite: PropTypes.func, onDelete: PropTypes.func, @@ -213,7 +215,7 @@ class StatusActionBar extends ImmutablePureComponent { }; render () { - const { status, quotedAccountId, intl, withDismiss, withCounters, showReplyCount, scrollKey } = this.props; + const { status, quotedAccountId, contextType, intl, withDismiss, withCounters, showReplyCount, scrollKey } = this.props; const { signedIn, permissions } = this.props.identity; const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); @@ -221,6 +223,7 @@ class StatusActionBar extends ImmutablePureComponent { const mutingConversation = status.get('muted'); const writtenByMe = status.getIn(['account', 'id']) === me; const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']); + const isQuotingMe = quotedAccountId === me; let menu = []; let reblogIcon = 'retweet'; @@ -271,7 +274,7 @@ class StatusActionBar extends ImmutablePureComponent { menu.push(null); } - if (quotedAccountId === me) { + if (isQuotingMe) { menu.push({ text: intl.formatMessage(messages.revokeQuote, { name: status.getIn(['account', 'username']) }), action: this.handleRevokeQuoteClick, dangerous: true }); } @@ -320,6 +323,8 @@ class StatusActionBar extends ImmutablePureComponent { const bookmarkTitle = intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark); const favouriteTitle = intl.formatMessage(status.get('favourited') ? messages.removeFavourite : messages.favourite); + const shouldShowQuoteRemovalHint = isQuotingMe && contextType === 'notifications'; + return (
        @@ -334,7 +339,7 @@ class StatusActionBar extends ImmutablePureComponent { />
        - +
        @@ -345,18 +350,24 @@ class StatusActionBar extends ImmutablePureComponent { {filterButton} -
        - -
        + + {(dismissQuoteHint) => ( + { + dismissQuoteHint(); + return true; + }} + /> + )} +
        diff --git a/app/javascript/flavours/glitch/components/status_action_bar/remove_quote_hint.tsx b/app/javascript/flavours/glitch/components/status_action_bar/remove_quote_hint.tsx new file mode 100644 index 0000000000..6046dad035 --- /dev/null +++ b/app/javascript/flavours/glitch/components/status_action_bar/remove_quote_hint.tsx @@ -0,0 +1,90 @@ +import { useRef } from 'react'; + +import { FormattedMessage, useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import Overlay from 'react-overlays/Overlay'; + +import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; + +import { Button } from '../button'; +import { useDismissableBannerState } from '../dismissable_banner'; +import { Icon } from '../icon'; + +const DISMISSABLE_BANNER_ID = 'notifications/remove_quote_hint'; + +export const RemoveQuoteHint: React.FC<{ + canShowHint: boolean; + className?: string; + children: (dismiss: () => void) => React.ReactNode; +}> = ({ canShowHint, className, children }) => { + const anchorRef = useRef(null); + const intl = useIntl(); + + const { isVisible, dismiss } = useDismissableBannerState({ + id: DISMISSABLE_BANNER_ID, + }); + + return ( +
        + {children(dismiss)} + {isVisible && canShowHint && ( + + {({ props, placement }) => ( +
        +

        + +

        + + ), + }} + > + {(text) =>

        {text}

        } +
        + + {(text) => ( + + )} + +
        + )} +
        + )} +
        + ); +}; diff --git a/app/javascript/flavours/glitch/components/status_content.jsx b/app/javascript/flavours/glitch/components/status_content.jsx index df5710248d..f1b8520122 100644 --- a/app/javascript/flavours/glitch/components/status_content.jsx +++ b/app/javascript/flavours/glitch/components/status_content.jsx @@ -235,16 +235,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/flavours/glitch/entrypoints/public.tsx b/app/javascript/flavours/glitch/entrypoints/public.tsx index 90c4ef8c87..75c06e7cd0 100644 --- a/app/javascript/flavours/glitch/entrypoints/public.tsx +++ b/app/javascript/flavours/glitch/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/flavours/glitch/features/compose/components/privacy_dropdown.jsx b/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.jsx index 85006d211a..d481b030a8 100644 --- a/app/javascript/flavours/glitch/features/compose/components/privacy_dropdown.jsx +++ b/app/javascript/flavours/glitch/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/flavours/glitch/features/compose/components/visibility_button.tsx b/app/javascript/flavours/glitch/features/compose/components/visibility_button.tsx index d73f54b982..78d77ca536 100644 --- a/app/javascript/flavours/glitch/features/compose/components/visibility_button.tsx +++ b/app/javascript/flavours/glitch/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/flavours/glitch/features/emoji/emoji_html.tsx b/app/javascript/flavours/glitch/features/emoji/emoji_html.tsx index 7527fea044..f620193ab7 100644 --- a/app/javascript/flavours/glitch/features/emoji/emoji_html.tsx +++ b/app/javascript/flavours/glitch/features/emoji/emoji_html.tsx @@ -12,16 +12,21 @@ type EmojiHTMLProps = Omit< htmlString: string; extraEmojis?: CustomEmojiMapArg; as?: Element; + shallow?: boolean; }; -export const ModernEmojiHTML = ({ +export const ModernEmojiHTML = ({ extraEmojis, htmlString, - as: asElement, // Rename for syntax highlighting + as: Wrapper = 'div', // Rename for syntax highlighting + shallow, ...props -}: EmojiHTMLProps) => { - const Wrapper = asElement ?? 'div'; - const emojifiedHtml = useEmojify(htmlString, extraEmojis); +}: EmojiHTMLProps) => { + const emojifiedHtml = useEmojify({ + text: htmlString, + extraEmojis, + deep: !shallow, + }); if (emojifiedHtml === null) { return null; diff --git a/app/javascript/flavours/glitch/features/emoji/hooks.ts b/app/javascript/flavours/glitch/features/emoji/hooks.ts index a985c3e97f..9c9eeb7d17 100644 --- a/app/javascript/flavours/glitch/features/emoji/hooks.ts +++ b/app/javascript/flavours/glitch/features/emoji/hooks.ts @@ -8,6 +8,7 @@ import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment'; import { toSupportedLocale } from './locale'; import { determineEmojiMode } from './mode'; +import { emojifyElement, emojifyText } from './render'; import type { CustomEmojiMapArg, EmojiAppState, @@ -15,7 +16,17 @@ import type { } from './types'; import { stringHasAnyEmoji } from './utils'; -export function useEmojify(text: string, extraEmojis?: CustomEmojiMapArg) { +interface UseEmojifyOptions { + text: string; + extraEmojis?: CustomEmojiMapArg; + deep?: boolean; +} + +export function useEmojify({ + text, + extraEmojis, + deep = true, +}: UseEmojifyOptions) { const [emojifiedText, setEmojifiedText] = useState(null); const appState = useEmojiAppState(); @@ -36,17 +47,23 @@ export function useEmojify(text: string, extraEmojis?: CustomEmojiMapArg) { const emojify = useCallback( async (input: string) => { - const wrapper = document.createElement('div'); - wrapper.innerHTML = input; - const { emojifyElement } = await import('./render'); - const result = await emojifyElement(wrapper, appState, extra); + let result: string | null = null; + if (deep) { + const wrapper = document.createElement('div'); + wrapper.innerHTML = input; + if (await emojifyElement(wrapper, appState, extra)) { + result = wrapper.innerHTML; + } + } else { + result = await emojifyText(text, appState, extra); + } if (result) { - setEmojifiedText(result.innerHTML); + setEmojifiedText(result); } else { setEmojifiedText(input); } }, - [appState, extra], + [appState, deep, extra, text], ); useLayoutEffect(() => { if (isModernEmojiEnabled() && !!text.trim() && stringHasAnyEmoji(text)) { diff --git a/app/javascript/flavours/glitch/features/notifications/components/select_with_label.tsx b/app/javascript/flavours/glitch/features/notifications/components/select_with_label.tsx index 3e3891c2e5..d75fe82ba1 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/select_with_label.tsx +++ b/app/javascript/flavours/glitch/features/notifications/components/select_with_label.tsx @@ -143,16 +143,14 @@ export const SelectWithLabel: React.FC> = ({
        -
        - -
        +
        ); diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.tsx b/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.tsx index 9edb28e021..4dadcf361d 100644 --- a/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.tsx +++ b/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.tsx @@ -36,7 +36,7 @@ const messages = defineMessages({ reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, reblog_private: { id: 'status.reblog_private', - defaultMessage: 'Boost with original visibility', + defaultMessage: 'Share again with your followers', }, cancel_reblog_private: { id: 'status.cancel_reblog_private', diff --git a/app/javascript/flavours/glitch/features/status/components/action_bar.jsx b/app/javascript/flavours/glitch/features/status/components/action_bar.jsx index 74d33cc9cb..28af52cd00 100644 --- a/app/javascript/flavours/glitch/features/status/components/action_bar.jsx +++ b/app/javascript/flavours/glitch/features/status/components/action_bar.jsx @@ -21,7 +21,7 @@ import { IconButton } from '../../../components/icon_button'; import { Dropdown } from 'flavours/glitch/components/dropdown_menu'; import { me } from '../../../initial_state'; import { isFeatureEnabled } from '@/flavours/glitch/utils/environment'; -import { ReblogButton } from '@/flavours/glitch/components/status/reblog_button'; +import { BoostButton } from '@/flavours/glitch/components/status/boost_button'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -254,7 +254,7 @@ class ActionBar extends PureComponent {
        - +
        diff --git a/app/javascript/flavours/glitch/features/ui/components/actions_modal.tsx b/app/javascript/flavours/glitch/features/ui/components/actions_modal.tsx index 45005bff1c..8d5dabaed9 100644 --- a/app/javascript/flavours/glitch/features/ui/components/actions_modal.tsx +++ b/app/javascript/flavours/glitch/features/ui/components/actions_modal.tsx @@ -1,6 +1,7 @@ import classNames from 'classnames'; import { Link } from 'react-router-dom'; +import { DropdownMenuItemContent } from 'flavours/glitch/components/dropdown_menu'; import type { MenuItem } from 'flavours/glitch/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/flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal.tsx b/app/javascript/flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal.tsx index c1ee819f83..7db45e310d 100644 --- a/app/javascript/flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal.tsx +++ b/app/javascript/flavours/glitch/features/ui/components/confirmation_modals/confirmation_modal.tsx @@ -51,10 +51,6 @@ export const ConfirmationModal: React.FC< onSecondary?.(); }, [onClose, onSecondary]); - const handleCancel = useCallback(() => { - onClose(); - }, [onClose]); - return (
        @@ -66,7 +62,7 @@ export const ConfirmationModal: React.FC<
        -
        diff --git a/app/javascript/mastodon/components/display_name/display_name.stories.tsx b/app/javascript/mastodon/components/display_name/display_name.stories.tsx new file mode 100644 index 0000000000..ccd7dcbb91 --- /dev/null +++ b/app/javascript/mastodon/components/display_name/display_name.stories.tsx @@ -0,0 +1,81 @@ +import type { ComponentProps } from 'react'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { accountFactoryState } from '@/testing/factories'; + +import { DisplayName, LinkedDisplayName } from './index'; + +type PageProps = Omit, 'account'> & { + name: string; + username: string; + loading: boolean; +}; + +const meta = { + title: 'Components/DisplayName', + args: { + username: 'mastodon@mastodon.social', + name: 'Test User 🧪', + loading: false, + simple: false, + noDomain: false, + localDomain: 'mastodon.social', + }, + tags: [], + render({ name, username, loading, ...args }) { + const account = !loading + ? accountFactoryState({ + display_name: name, + acct: username, + }) + : undefined; + return ; + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Primary: Story = { + args: {}, +}; + +export const Loading: Story = { + args: { + loading: true, + }, +}; + +export const NoDomain: Story = { + args: { + noDomain: true, + }, +}; + +export const Simple: Story = { + args: { + simple: true, + }, +}; + +export const LocalUser: Story = { + args: { + username: 'localuser', + name: 'Local User', + localDomain: '', + }, +}; + +export const Linked: Story = { + render({ name, username, loading, ...args }) { + const account = !loading + ? accountFactoryState({ + display_name: name, + acct: username, + }) + : undefined; + return ; + }, +}; diff --git a/app/javascript/mastodon/components/display_name/index.tsx b/app/javascript/mastodon/components/display_name/index.tsx new file mode 100644 index 0000000000..6bd4addded --- /dev/null +++ b/app/javascript/mastodon/components/display_name/index.tsx @@ -0,0 +1,122 @@ +import type { ComponentPropsWithoutRef, FC } from 'react'; +import { useMemo } from 'react'; + +import classNames from 'classnames'; +import type { LinkProps } from 'react-router-dom'; +import { Link } from 'react-router-dom'; + +import { EmojiHTML } from '@/mastodon/features/emoji/emoji_html'; +import type { Account } from '@/mastodon/models/account'; +import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; + +import { Skeleton } from '../skeleton'; + +interface Props { + account?: Account; + localDomain?: string; + simple?: boolean; + noDomain?: boolean; +} + +export const DisplayName: FC> = ({ + account, + localDomain, + simple = false, + noDomain = false, + className, + ...props +}) => { + const username = useMemo(() => { + if (!account || noDomain) { + return null; + } + let acct = account.get('acct'); + + if (!acct.includes('@') && localDomain) { + acct = `${acct}@${localDomain}`; + } + return `@${acct}`; + }, [account, localDomain, noDomain]); + + if (!account) { + if (simple) { + return null; + } + return ( + + + + + + + {!noDomain && ( + +   + + + )} + + ); + } + const accountName = isModernEmojiEnabled() + ? account.get('display_name') + : account.get('display_name_html'); + if (simple) { + return ( + + + + ); + } + + return ( + + + + + {username && ( +  {username} + )} + + ); +}; + +export const LinkedDisplayName: FC< + Props & { asProps?: ComponentPropsWithoutRef<'span'> } & Partial +> = ({ + account, + asProps = {}, + className, + localDomain, + simple, + noDomain, + ...linkProps +}) => { + const displayProps = { + account, + className, + localDomain, + simple, + noDomain, + ...asProps, + }; + if (!account) { + return ; + } + + return ( + + + + ); +}; 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 afc208e032..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: 'Boost with original visibility', - }, - 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.jsx b/app/javascript/mastodon/components/status_action_bar/index.jsx similarity index 92% rename from app/javascript/mastodon/components/status_action_bar.jsx rename to app/javascript/mastodon/components/status_action_bar/index.jsx index 143407193b..3aff359c10 100644 --- a/app/javascript/mastodon/components/status_action_bar.jsx +++ b/app/javascript/mastodon/components/status_action_bar/index.jsx @@ -20,11 +20,12 @@ import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/ import { WithRouterPropTypes } from 'mastodon/utils/react_router'; import { Dropdown } from 'mastodon/components/dropdown_menu'; -import { me } from '../initial_state'; +import { me } from '../../initial_state'; -import { IconButton } from './icon_button'; -import { isFeatureEnabled } from '../utils/environment'; -import { ReblogButton } from './status/reblog_button'; +import { IconButton } from '../icon_button'; +import { isFeatureEnabled } from '../../utils/environment'; +import { BoostButton } from '../status/boost_button'; +import { RemoveQuoteHint } from './remove_quote_hint'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -77,6 +78,7 @@ class StatusActionBar extends ImmutablePureComponent { status: ImmutablePropTypes.map.isRequired, relationship: ImmutablePropTypes.record, quotedAccountId: PropTypes.string, + contextType: PropTypes.string, onReply: PropTypes.func, onFavourite: PropTypes.func, onDelete: PropTypes.func, @@ -240,7 +242,7 @@ class StatusActionBar extends ImmutablePureComponent { }; render () { - const { status, relationship, quotedAccountId, intl, withDismiss, withCounters, scrollKey } = this.props; + const { status, relationship, quotedAccountId, contextType, intl, withDismiss, withCounters, scrollKey } = this.props; const { signedIn, permissions } = this.props.identity; const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); @@ -249,6 +251,7 @@ class StatusActionBar extends ImmutablePureComponent { const account = status.get('account'); const writtenByMe = status.getIn(['account', 'id']) === me; const isRemote = status.getIn(['account', 'username']) !== status.getIn(['account', 'acct']); + const isQuotingMe = quotedAccountId === me; let menu = []; @@ -293,7 +296,7 @@ class StatusActionBar extends ImmutablePureComponent { menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.handleDirectClick }); menu.push(null); - if (quotedAccountId === me) { + if (isQuotingMe) { menu.push({ text: intl.formatMessage(messages.revokeQuote, { name: account.get('username') }), action: this.handleRevokeQuoteClick, dangerous: true }); } @@ -360,6 +363,8 @@ class StatusActionBar extends ImmutablePureComponent { const bookmarkTitle = intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark); const favouriteTitle = intl.formatMessage(status.get('favourited') ? messages.removeFavourite : messages.favourite); const isReply = status.get('in_reply_to_account_id') === status.getIn(['account', 'id']); + + const shouldShowQuoteRemovalHint = isQuotingMe && contextType === 'notifications'; return (
            @@ -367,7 +372,7 @@ class StatusActionBar extends ImmutablePureComponent {
            - +
            @@ -375,17 +380,23 @@ class StatusActionBar extends ImmutablePureComponent {
            -
            - -
            + + {(dismissQuoteHint) => ( + { + dismissQuoteHint(); + return true; + }} + /> + )} +
            ); } diff --git a/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.tsx b/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.tsx new file mode 100644 index 0000000000..6046dad035 --- /dev/null +++ b/app/javascript/mastodon/components/status_action_bar/remove_quote_hint.tsx @@ -0,0 +1,90 @@ +import { useRef } from 'react'; + +import { FormattedMessage, useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import Overlay from 'react-overlays/Overlay'; + +import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; + +import { Button } from '../button'; +import { useDismissableBannerState } from '../dismissable_banner'; +import { Icon } from '../icon'; + +const DISMISSABLE_BANNER_ID = 'notifications/remove_quote_hint'; + +export const RemoveQuoteHint: React.FC<{ + canShowHint: boolean; + className?: string; + children: (dismiss: () => void) => React.ReactNode; +}> = ({ canShowHint, className, children }) => { + const anchorRef = useRef(null); + const intl = useIntl(); + + const { isVisible, dismiss } = useDismissableBannerState({ + id: DISMISSABLE_BANNER_ID, + }); + + return ( +
            + {children(dismiss)} + {isVisible && canShowHint && ( + + {({ props, placement }) => ( +
            +

            + +

            + + ), + }} + > + {(text) =>

            {text}

            } +
            + + {(text) => ( + + )} + +
            + )} +
            + )} +
            + ); +}; 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 60a27306dd..e143c9fc16 100644 --- a/app/javascript/mastodon/features/emoji/emoji_html.tsx +++ b/app/javascript/mastodon/features/emoji/emoji_html.tsx @@ -12,16 +12,21 @@ type EmojiHTMLProps = Omit< htmlString: string; extraEmojis?: CustomEmojiMapArg; as?: Element; + shallow?: boolean; }; -export const ModernEmojiHTML = ({ +export const ModernEmojiHTML = ({ extraEmojis, htmlString, - as: asElement, // Rename for syntax highlighting + as: Wrapper = 'div', // Rename for syntax highlighting + shallow, ...props -}: EmojiHTMLProps) => { - const Wrapper = asElement ?? 'div'; - const emojifiedHtml = useEmojify(htmlString, extraEmojis); +}: EmojiHTMLProps) => { + const emojifiedHtml = useEmojify({ + text: htmlString, + extraEmojis, + deep: !shallow, + }); if (emojifiedHtml === null) { return null; diff --git a/app/javascript/mastodon/features/emoji/hooks.ts b/app/javascript/mastodon/features/emoji/hooks.ts index 3f397f4ef0..7e91486780 100644 --- a/app/javascript/mastodon/features/emoji/hooks.ts +++ b/app/javascript/mastodon/features/emoji/hooks.ts @@ -8,6 +8,7 @@ import { isModernEmojiEnabled } from '@/mastodon/utils/environment'; import { toSupportedLocale } from './locale'; import { determineEmojiMode } from './mode'; +import { emojifyElement, emojifyText } from './render'; import type { CustomEmojiMapArg, EmojiAppState, @@ -15,7 +16,17 @@ import type { } from './types'; import { stringHasAnyEmoji } from './utils'; -export function useEmojify(text: string, extraEmojis?: CustomEmojiMapArg) { +interface UseEmojifyOptions { + text: string; + extraEmojis?: CustomEmojiMapArg; + deep?: boolean; +} + +export function useEmojify({ + text, + extraEmojis, + deep = true, +}: UseEmojifyOptions) { const [emojifiedText, setEmojifiedText] = useState(null); const appState = useEmojiAppState(); @@ -36,17 +47,23 @@ export function useEmojify(text: string, extraEmojis?: CustomEmojiMapArg) { const emojify = useCallback( async (input: string) => { - const wrapper = document.createElement('div'); - wrapper.innerHTML = input; - const { emojifyElement } = await import('./render'); - const result = await emojifyElement(wrapper, appState, extra); + let result: string | null = null; + if (deep) { + const wrapper = document.createElement('div'); + wrapper.innerHTML = input; + if (await emojifyElement(wrapper, appState, extra)) { + result = wrapper.innerHTML; + } + } else { + result = await emojifyText(text, appState, extra); + } if (result) { - setEmojifiedText(result.innerHTML); + setEmojifiedText(result); } else { setEmojifiedText(input); } }, - [appState, extra], + [appState, deep, extra, text], ); useLayoutEffect(() => { if (isModernEmojiEnabled() && !!text.trim() && stringHasAnyEmoji(text)) { 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/picture_in_picture/components/footer.tsx b/app/javascript/mastodon/features/picture_in_picture/components/footer.tsx index 919a41cbae..ddcc386ad8 100644 --- a/app/javascript/mastodon/features/picture_in_picture/components/footer.tsx +++ b/app/javascript/mastodon/features/picture_in_picture/components/footer.tsx @@ -33,7 +33,7 @@ const messages = defineMessages({ reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, reblog_private: { id: 'status.reblog_private', - defaultMessage: 'Boost with original visibility', + defaultMessage: 'Share again with your followers', }, cancel_reblog_private: { id: 'status.cancel_reblog_private', 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<
        -