diff --git a/app/javascript/mastodon/api_types/quotes.ts b/app/javascript/mastodon/api_types/quotes.ts index 981c047c136..58cce1e257a 100644 --- a/app/javascript/mastodon/api_types/quotes.ts +++ b/app/javascript/mastodon/api_types/quotes.ts @@ -2,6 +2,7 @@ import type { ApiStatusJSON } from './statuses'; export type ApiQuoteState = 'accepted' | 'pending' | 'revoked' | 'unauthorized'; export type ApiQuotePolicy = 'public' | 'followers' | 'nobody' | 'unknown'; +export type ApiUserQuotePolicy = 'automatic' | 'manual' | 'denied' | 'unknown'; interface ApiQuoteEmptyJSON { state: Exclude; @@ -25,7 +26,7 @@ export type ApiQuoteJSON = ApiQuoteAcceptedJSON | ApiQuoteEmptyJSON; export interface ApiQuotePolicyJSON { automatic: ApiQuotePolicy[]; manual: ApiQuotePolicy[]; - current_user: ApiQuotePolicy; + current_user: ApiUserQuotePolicy; } export function isQuotePolicy(policy: string): policy is ApiQuotePolicy { diff --git a/app/javascript/mastodon/components/dropdown_menu.tsx b/app/javascript/mastodon/components/dropdown_menu.tsx index d9c87e93a72..27af0ba6c02 100644 --- a/app/javascript/mastodon/components/dropdown_menu.tsx +++ b/app/javascript/mastodon/components/dropdown_menu.tsx @@ -41,13 +41,16 @@ import { IconButton } from './icon_button'; let id = 0; -type RenderItemFn = ( +export interface RenderItemFnHandlers { + onClick: React.MouseEventHandler; + onKeyUp: React.KeyboardEventHandler; +} + +export type RenderItemFn = ( item: Item, index: number, - handlers: { - onClick: (e: React.MouseEvent) => void; - onKeyUp: (e: React.KeyboardEvent) => void; - }, + handlers: RenderItemFnHandlers, + focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void, ) => React.ReactNode; type ItemClickFn = (item: Item, index: number) => void; @@ -173,7 +176,7 @@ export const DropdownMenu = ({ onItemClick(item, i); } else if (isActionItem(item)) { e.preventDefault(); - item.action(); + item.action(e); } }, [onClose, onItemClick, items], @@ -277,10 +280,15 @@ export const DropdownMenu = ({ })} > {items.map((option, i) => - renderItemMethod(option, i, { - onClick: handleItemClick, - onKeyUp: handleItemKeyUp, - }), + renderItemMethod( + option, + i, + { + onClick: handleItemClick, + onKeyUp: handleItemKeyUp, + }, + i === 0 ? handleFocusedItemRef : undefined, + ), )} )} @@ -307,7 +315,9 @@ interface DropdownProps { forceDropdown?: boolean; renderItem?: RenderItemFn; renderHeader?: RenderHeaderFn; - onOpen?: () => void; + onOpen?: // Must use a union type for the full function as a union with void is not allowed. + | ((event: React.MouseEvent | React.KeyboardEvent) => void) + | ((event: React.MouseEvent | React.KeyboardEvent) => boolean); onItemClick?: ItemClickFn; } @@ -376,7 +386,7 @@ export const Dropdown = ({ onItemClick(item, i); } else if (isActionItem(item)) { e.preventDefault(); - item.action(); + item.action(e); } }, [handleClose, onItemClick, items], @@ -389,7 +399,10 @@ export const Dropdown = ({ if (open) { handleClose(); } else { - onOpen?.(); + const allow = onOpen?.(e); + if (allow === false) { + return; + } if (prefetchAccountId) { dispatch(fetchRelationships([prefetchAccountId])); diff --git a/app/javascript/mastodon/components/status/reblog_button.stories.tsx b/app/javascript/mastodon/components/status/reblog_button.stories.tsx new file mode 100644 index 00000000000..887646bdf9a --- /dev/null +++ b/app/javascript/mastodon/components/status/reblog_button.stories.tsx @@ -0,0 +1,89 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import type { StatusVisibility } from '@/mastodon/api_types/statuses'; +import { statusFactoryState } from '@/testing/factories'; + +import { LegacyReblogButton, StatusReblogButton } from './reblog_button'; + +interface StoryProps { + visibility: StatusVisibility; + quoteAllowed: boolean; + alreadyBoosted: boolean; + reblogCount: number; +} + +const meta = { + title: 'Components/Status/ReblogButton', + args: { + visibility: 'public', + quoteAllowed: true, + alreadyBoosted: false, + reblogCount: 0, + }, + argTypes: { + visibility: { + name: 'Visibility', + control: { type: 'select' }, + options: ['public', 'unlisted', 'private', 'direct'], + }, + reblogCount: { + name: 'Boost Count', + description: 'More than 0 will show the counter', + }, + quoteAllowed: { + name: 'Quotes allowed', + }, + alreadyBoosted: { + name: 'Already boosted', + }, + }, + render: (args) => ( + 0} + /> + ), +} satisfies Meta; + +export default meta; + +function argsToStatus({ + reblogCount, + visibility, + quoteAllowed, + alreadyBoosted, +}: StoryProps) { + return statusFactoryState({ + reblogs_count: reblogCount, + visibility, + reblogged: alreadyBoosted, + quote_approval: { + automatic: [], + manual: [], + current_user: quoteAllowed ? 'automatic' : 'denied', + }, + }); +} + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Mine: Story = { + parameters: { + state: { + meta: { + me: '1', + }, + }, + }, +}; + +export const Legacy: Story = { + render: (args) => ( + 0} + /> + ), +}; diff --git a/app/javascript/mastodon/components/status/reblog_button.tsx b/app/javascript/mastodon/components/status/reblog_button.tsx new file mode 100644 index 00000000000..936d5506e70 --- /dev/null +++ b/app/javascript/mastodon/components/status/reblog_button.tsx @@ -0,0 +1,373 @@ +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.svg?react'; +import FormatQuoteOff from '@/material-icons/400-24px/format_quote_off.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: 'Author has disabled quoting on this post', + }, + quote_private: { + id: 'status.quote_private', + defaultMessage: 'Private posts cannot be quoted', + }, + reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, + 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', + }, +}); + +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, isQuoteAllowed } = + statusState; + const { iconComponent } = useMemo( + () => reblogIconText(statusState), + [statusState], + ); + const disabled = !isQuoteAllowed && !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, + isQuoteAllowed: + status.getIn(['quote_approval', 'current_user']) === 'automatic' && + (isPublic || isMineAndPrivate), + }; + }, +); +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, + isQuoteAllowed, + isPublic, +}: StatusState): IconText { + const iconText: IconText = { + title: messages.quote, + iconComponent: FormatQuote, + }; + + if (!isQuoteAllowed || (!isPublic && !isMine)) { + iconText.meta = !isQuoteAllowed + ? messages.quote_cannot + : messages.quote_private; + iconText.iconComponent = FormatQuoteOff; + iconText.disabled = true; + } + return iconText; +} diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx index 69ca9817a2c..143407193b3 100644 --- a/app/javascript/mastodon/components/status_action_bar.jsx +++ b/app/javascript/mastodon/components/status_action_bar.jsx @@ -2,7 +2,6 @@ import PropTypes from 'prop-types'; import { defineMessages, injectIntl } from 'react-intl'; -import classNames from 'classnames'; import { withRouter } from 'react-router-dom'; import ImmutablePropTypes from 'react-immutable-proptypes'; @@ -12,15 +11,10 @@ import { connect } from 'react-redux'; import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react'; import BookmarkBorderIcon from '@/material-icons/400-24px/bookmark.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; -import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react'; import StarIcon from '@/material-icons/400-24px/star-fill.svg?react'; import StarBorderIcon from '@/material-icons/400-24px/star.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 { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions'; import { WithRouterPropTypes } from 'mastodon/utils/react_router'; @@ -30,6 +24,7 @@ import { me } from '../initial_state'; import { IconButton } from './icon_button'; import { isFeatureEnabled } from '../utils/environment'; +import { ReblogButton } from './status/reblog_button'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -43,10 +38,6 @@ const messages = defineMessages({ share: { id: 'status.share', defaultMessage: 'Share' }, more: { id: 'status.more', defaultMessage: 'More' }, replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, - reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, - reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' }, - cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, - cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, favourite: { id: 'status.favourite', defaultMessage: 'Favorite' }, removeFavourite: { id: 'status.remove_favourite', defaultMessage: 'Remove from favorites' }, bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, @@ -85,10 +76,9 @@ class StatusActionBar extends ImmutablePureComponent { identity: identityContextPropShape, status: ImmutablePropTypes.map.isRequired, relationship: ImmutablePropTypes.record, - quotedAccountId: ImmutablePropTypes.string, + quotedAccountId: PropTypes.string, onReply: PropTypes.func, onFavourite: PropTypes.func, - onReblog: PropTypes.func, onDelete: PropTypes.func, onRevokeQuote: PropTypes.func, onQuotePolicyChange: PropTypes.func, @@ -152,16 +142,6 @@ class StatusActionBar extends ImmutablePureComponent { } }; - handleReblogClick = e => { - const { signedIn } = this.props.identity; - - if (signedIn) { - this.props.onReblog(this.props.status, e); - } else { - this.props.onInteractionModal('reblog', this.props.status); - } - }; - handleBookmarkClick = () => { this.props.onBookmark(this.props.status); }; @@ -377,25 +357,6 @@ class StatusActionBar extends ImmutablePureComponent { replyTitle = intl.formatMessage(messages.replyAll); } - const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private'; - - let reblogTitle, reblogIconComponent; - - if (status.get('reblogged')) { - reblogTitle = intl.formatMessage(messages.cancel_reblog_private); - reblogIconComponent = publicStatus ? RepeatActiveIcon : RepeatPrivateActiveIcon; - } else if (publicStatus) { - reblogTitle = intl.formatMessage(messages.reblog); - reblogIconComponent = RepeatIcon; - } else if (reblogPrivate) { - reblogTitle = intl.formatMessage(messages.reblog_private); - reblogIconComponent = RepeatPrivateIcon; - } else { - reblogTitle = intl.formatMessage(messages.cannot_reblog); - reblogIconComponent = RepeatDisabledIcon; - } - - 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']); @@ -406,7 +367,7 @@ class StatusActionBar extends ImmutablePureComponent {
    - +
    diff --git a/app/javascript/mastodon/features/status/components/action_bar.jsx b/app/javascript/mastodon/features/status/components/action_bar.jsx index 15f193510d7..f5dea36cc6b 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.jsx +++ b/app/javascript/mastodon/features/status/components/action_bar.jsx @@ -3,23 +3,16 @@ import { PureComponent } from 'react'; import { defineMessages, injectIntl } from 'react-intl'; -import classNames from 'classnames'; - import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react'; import BookmarkBorderIcon from '@/material-icons/400-24px/bookmark.svg?react'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; -import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react'; import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react'; import StarIcon from '@/material-icons/400-24px/star-fill.svg?react'; import StarBorderIcon from '@/material-icons/400-24px/star.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 { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/permissions'; @@ -27,6 +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'; const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, @@ -35,10 +29,6 @@ const messages = defineMessages({ direct: { id: 'status.direct', defaultMessage: 'Privately mention @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, reply: { id: 'status.reply', defaultMessage: 'Reply' }, - reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, - reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' }, - cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, - cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, favourite: { id: 'status.favourite', defaultMessage: 'Favorite' }, removeFavourite: { id: 'status.remove_favourite', defaultMessage: 'Remove from favorites' }, bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' }, @@ -313,31 +303,15 @@ class ActionBar extends PureComponent { replyIconComponent = ReplyAllIcon; } - const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private'; - - let reblogTitle, reblogIconComponent; - - if (status.get('reblogged')) { - reblogTitle = intl.formatMessage(messages.cancel_reblog_private); - reblogIconComponent = publicStatus ? RepeatActiveIcon : RepeatPrivateActiveIcon; - } else if (publicStatus) { - reblogTitle = intl.formatMessage(messages.reblog); - reblogIconComponent = RepeatIcon; - } else if (reblogPrivate) { - reblogTitle = intl.formatMessage(messages.reblog_private); - reblogIconComponent = RepeatPrivateIcon; - } else { - reblogTitle = intl.formatMessage(messages.cannot_reblog); - reblogIconComponent = RepeatDisabledIcon; - } - const bookmarkTitle = intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark); const favouriteTitle = intl.formatMessage(status.get('favourited') ? messages.removeFavourite : messages.favourite); return (
    -
    +
    + +
    diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 6362d0b4628..11904a0b370 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -849,9 +849,11 @@ "status.admin_account": "Open moderation interface for @{name}", "status.admin_domain": "Open moderation interface for {domain}", "status.admin_status": "Open this post in the moderation interface", + "status.all_disabled": "Boosts and quotes are disabled", "status.block": "Block @{name}", "status.bookmark": "Bookmark", "status.cancel_reblog_private": "Unboost", + "status.cannot_quote": "Author has disabled quoting on this post", "status.cannot_reblog": "This post cannot be boosted", "status.context.load_new_replies": "New replies available", "status.context.loading": "Checking for more replies", @@ -880,6 +882,7 @@ "status.mute_conversation": "Mute conversation", "status.open": "Expand this post", "status.pin": "Pin on profile", + "status.quote": "Quote", "status.quote.cancel": "Cancel quote", "status.quote_error.filtered": "Hidden due to one of your filters", "status.quote_error.not_available": "Post unavailable", @@ -888,6 +891,7 @@ "status.quote_error.pending_approval_popout.title": "Pending quote? Remain calm", "status.quote_policy_change": "Change who can quote", "status.quote_post_author": "Quoted a post by @{name}", + "status.quote_private": "Private posts cannot be quoted", "status.read_more": "Read more", "status.reblog": "Boost", "status.reblog_private": "Boost with original visibility", diff --git a/app/javascript/mastodon/models/dropdown_menu.ts b/app/javascript/mastodon/models/dropdown_menu.ts index c02f2050238..963b5071c95 100644 --- a/app/javascript/mastodon/models/dropdown_menu.ts +++ b/app/javascript/mastodon/models/dropdown_menu.ts @@ -1,10 +1,12 @@ +import type { KeyboardEvent, MouseEvent, TouchEvent } from 'react'; + interface BaseMenuItem { text: string; dangerous?: boolean; } export interface ActionMenuItem extends BaseMenuItem { - action: () => void; + action: (event: MouseEvent | KeyboardEvent | TouchEvent) => void; } export interface LinkMenuItem extends BaseMenuItem { diff --git a/app/javascript/material-icons/400-24px/format_quote_off.svg b/app/javascript/material-icons/400-24px/format_quote_off.svg new file mode 100644 index 00000000000..e7b70bf793d --- /dev/null +++ b/app/javascript/material-icons/400-24px/format_quote_off.svg @@ -0,0 +1 @@ + diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index e89859f058e..bb6eb8fca28 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -2856,10 +2856,43 @@ a.account__display-name { &:focus, &:hover, &:active { - background: var(--dropdown-border-color); - outline: 0; + &:not(:disabled) { + background: var(--dropdown-border-color); + outline: 0; + } } } + + button:disabled { + color: $dark-text-color; + cursor: default; + } +} + +.reblog-button { + &__item { + width: 280px; + + button { + display: flex; + align-items: center; + gap: 8px; + white-space: inherit; + } + + div { + display: flex; + flex-direction: column; + } + + &.active:not(.disabled) { + color: $highlight-text-color; + } + } + + &__meta { + font-weight: 400; + } } .inline-account { diff --git a/app/javascript/testing/factories.ts b/app/javascript/testing/factories.ts index cd5f72a06f0..c379d55c0de 100644 --- a/app/javascript/testing/factories.ts +++ b/app/javascript/testing/factories.ts @@ -1,9 +1,13 @@ +import { Map as ImmutableMap } from 'immutable'; + import type { ApiRelationshipJSON } from '@/mastodon/api_types/relationships'; +import type { ApiStatusJSON } from '@/mastodon/api_types/statuses'; import type { CustomEmojiData, UnicodeEmojiData, } from '@/mastodon/features/emoji/types'; import { createAccountFromServerJSON } from '@/mastodon/models/account'; +import type { Status } from '@/mastodon/models/status'; import type { ApiAccountJSON } from 'mastodon/api_types/accounts'; type FactoryOptions = { @@ -51,6 +55,36 @@ export const accountFactoryState = ( options: FactoryOptions = {}, ) => createAccountFromServerJSON(accountFactory(options)); +export const statusFactory: FactoryFunction = ({ + id, + ...data +} = {}) => ({ + id: id ?? '1', + created_at: '2023-01-01T00:00:00.000Z', + sensitive: false, + visibility: 'public', + language: 'en', + uri: 'https://example.com/status/1', + url: 'https://example.com/status/1', + replies_count: 0, + reblogs_count: 0, + favorites_count: 0, + account: accountFactory(), + media_attachments: [], + mentions: [], + tags: [], + emojis: [], + content: '

    This is a test status.

    ', + ...data, +}); + +export const statusFactoryState = ( + options: FactoryOptions = {}, +) => + ImmutableMap( + statusFactory(options) as unknown as Record, + ) as unknown as Status; + export const relationshipsFactory: FactoryFunction = ({ id, ...data