2
0

Fix missing icons and subtitle in mobile boost/quote menu (#36038)

This commit is contained in:
diondiondion
2025-09-08 10:50:46 +02:00
committed by GitHub
parent 65b4a0a6f1
commit a5fbe2f5c1
11 changed files with 498 additions and 470 deletions

View File

@@ -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<Item = MenuItem> {
onItemClick?: ItemClickFn<Item>;
}
export const DropdownMenuItemContent: React.FC<{ item: MenuItem }> = ({
item,
}) => {
if (item === null) {
return null;
}
const { text, description, icon } = item;
return (
<>
{icon && <Icon icon={icon} id={`${text}-icon`} />}
<span className='dropdown-menu__item-content'>
{text}
{Boolean(description) && (
<span className='dropdown-menu__item-subtitle'>{description}</span>
)}
</span>
</>
);
};
export const DropdownMenu = <Item = MenuItem,>({
items,
loading,
@@ -200,7 +222,7 @@ export const DropdownMenu = <Item = MenuItem,>({
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
const { text, dangerous } = option;
const { text, highlighted, disabled, dangerous } = option;
let element: React.ReactElement;
@@ -211,8 +233,9 @@ export const DropdownMenu = <Item = MenuItem,>({
onClick={handleItemClick}
onKeyUp={handleItemKeyUp}
data-index={i}
disabled={disabled}
>
{text}
<DropdownMenuItemContent item={option} />
</button>
);
} else if (isExternalLinkItem(option)) {
@@ -227,7 +250,7 @@ export const DropdownMenu = <Item = MenuItem,>({
onKeyUp={handleItemKeyUp}
data-index={i}
>
{text}
<DropdownMenuItemContent item={option} />
</a>
);
} else {
@@ -239,7 +262,7 @@ export const DropdownMenu = <Item = MenuItem,>({
onKeyUp={handleItemKeyUp}
data-index={i}
>
{text}
<DropdownMenuItemContent item={option} />
</Link>
);
}
@@ -247,6 +270,7 @@ export const DropdownMenu = <Item = MenuItem,>({
return (
<li
className={classNames('dropdown-menu__item', {
'dropdown-menu__item--highlighted': highlighted,
'dropdown-menu__item--dangerous': dangerous,
})}
key={`${text}-${i}`}

View File

@@ -3,7 +3,7 @@ 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';
import { LegacyReblogButton, StatusBoostButton } from './boost_button';
interface StoryProps {
visibility: StatusVisibility;
@@ -13,7 +13,7 @@ interface StoryProps {
}
const meta = {
title: 'Components/Status/ReblogButton',
title: 'Components/Status/BoostButton',
args: {
visibility: 'public',
quoteAllowed: true,
@@ -38,7 +38,7 @@ const meta = {
},
},
render: (args) => (
<StatusReblogButton
<StatusBoostButton
status={argsToStatus(args)}
counters={args.reblogCount > 0}
/>

View File

@@ -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<ActionMenuItem> = (
item,
index,
handlers,
focusRefCallback,
) => (
<ReblogMenuItem
index={index}
item={item}
handlers={handlers}
key={`${item.text}-${index}`}
focusRefCallback={focusRefCallback}
/>
);
interface ReblogButtonProps {
status: Status;
counters?: boolean;
}
type ActionMenuItemWithIcon = SomeRequired<ActionMenuItem, 'icon'>;
export const StatusBoostButton: FC<ReblogButtonProps> = ({
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 (
<Dropdown
items={items}
renderItem={renderMenuItem}
onOpen={handleDropdownOpen}
disabled={isMenuDisabled}
>
<IconButton
title={intl.formatMessage(
isMenuDisabled ? messages.all_disabled : messages.reblog_or_quote,
)}
icon='retweet'
iconComponent={boostIcon}
counter={
counters
? (status.get('reblogs_count') as number) +
(status.get('quotes_count') as number)
: undefined
}
active={isReblogged}
/>
</Dropdown>
);
};
interface ReblogMenuItemProps {
item: ActionMenuItem;
index: number;
handlers: RenderItemFnHandlers;
focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void;
}
const ReblogMenuItem: FC<ReblogMenuItemProps> = ({
index,
item,
handlers,
focusRefCallback,
}) => {
const { text, highlighted, disabled } = item;
return (
<li
className={classNames('dropdown-menu__item reblog-menu-item', {
'dropdown-menu__item--highlighted': highlighted,
})}
key={`${text}-${index}`}
>
<button
{...handlers}
ref={focusRefCallback}
disabled={disabled}
data-index={index}
>
<DropdownMenuItemContent item={item} />
</button>
</li>
);
};
// Legacy helpers
// Switch between the legacy and new reblog button based on feature flag.
export const BoostButton: FC<ReblogButtonProps> = (props) => {
if (isFeatureEnabled('outgoing_quotes')) {
return <StatusBoostButton {...props} />;
}
return <LegacyReblogButton {...props} />;
};
export const LegacyReblogButton: FC<ReblogButtonProps> = ({
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 (
<IconButton
disabled={disabled}
active={!!status.get('reblogged')}
title={intl.formatMessage(meta ?? title)}
icon='retweet'
iconComponent={iconComponent}
onClick={!disabled ? handleClick : undefined}
counter={
counters
? (status.get('reblogs_count') as number) +
(status.get('quotes_count') as number)
: undefined
}
/>
);
};

View File

@@ -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<typeof selectStatusState>;
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;
}

View File

@@ -1,425 +0,0 @@
import { useCallback, useMemo } from 'react';
import type {
FC,
KeyboardEvent,
MouseEvent,
MouseEventHandler,
SVGProps,
} from 'react';
import type { MessageDescriptor } from 'react-intl';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import { quoteComposeById } from '@/mastodon/actions/compose_typed';
import { toggleReblog } from '@/mastodon/actions/interactions';
import { openModal } from '@/mastodon/actions/modal';
import type { ActionMenuItem } from '@/mastodon/models/dropdown_menu';
import type { Status, StatusVisibility } from '@/mastodon/models/status';
import {
createAppSelector,
useAppDispatch,
useAppSelector,
} from '@/mastodon/store';
import { isFeatureEnabled } from '@/mastodon/utils/environment';
import FormatQuote from '@/material-icons/400-24px/format_quote-fill.svg?react';
import FormatQuoteOff from '@/material-icons/400-24px/format_quote_off-fill.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import RepeatActiveIcon from '@/svg-icons/repeat_active.svg?react';
import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react';
import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react';
import RepeatPrivateActiveIcon from '@/svg-icons/repeat_private_active.svg?react';
import type { RenderItemFn, RenderItemFnHandlers } from '../dropdown_menu';
import { Dropdown } from '../dropdown_menu';
import { Icon } from '../icon';
import { IconButton } from '../icon_button';
const messages = defineMessages({
all_disabled: {
id: 'status.all_disabled',
defaultMessage: 'Boosts and quotes are disabled',
},
quote: { id: 'status.quote', defaultMessage: 'Quote' },
quote_cannot: {
id: 'status.cannot_quote',
defaultMessage: 'Quotes are disabled on this post',
},
quote_followers_only: {
id: 'status.quote_followers_only',
defaultMessage: 'Only followers can quote this post',
},
quote_manual_review: {
id: 'status.quote_manual_review',
defaultMessage: 'Author will manually review',
},
quote_private: {
id: 'status.quote_private',
defaultMessage: 'Private posts cannot be quoted',
},
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_or_quote: {
id: 'status.reblog_or_quote',
defaultMessage: 'Boost or quote',
},
reblog_cancel: {
id: 'status.cancel_reblog_private',
defaultMessage: 'Unboost',
},
reblog_private: {
id: 'status.reblog_private',
defaultMessage: 'Share again with your followers',
},
reblog_cannot: {
id: 'status.cannot_reblog',
defaultMessage: 'This post cannot be boosted',
},
request_quote: {
id: 'status.request_quote',
defaultMessage: 'Request to quote',
},
});
interface ReblogButtonProps {
status: Status;
counters?: boolean;
}
export const StatusReblogButton: FC<ReblogButtonProps> = ({
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<ActionMenuItem> = useCallback(
(item, index, handlers, focusRefCallback) => (
<ReblogMenuItem
status={status}
index={index}
item={item}
handlers={handlers}
key={`${item.text}-${index}`}
focusRefCallback={focusRefCallback}
/>
),
[status],
);
return (
<Dropdown
items={items}
renderItem={renderMenuItem}
onOpen={handleDropdownOpen}
disabled={disabled}
>
<IconButton
title={intl.formatMessage(
!disabled ? messages.reblog_or_quote : messages.all_disabled,
)}
icon='retweet'
iconComponent={iconComponent}
counter={
counters
? (status.get('reblogs_count') as number) +
(status.get('quotes_count') as number)
: undefined
}
active={isReblogged}
/>
</Dropdown>
);
};
interface ReblogMenuItemProps {
status: Status;
item: ActionMenuItem;
index: number;
handlers: RenderItemFnHandlers;
focusRefCallback?: (c: HTMLAnchorElement | HTMLButtonElement | null) => void;
}
const ReblogMenuItem: FC<ReblogMenuItemProps> = ({
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 (
<li
className={classNames('dropdown-menu__item reblog-button__item', {
disabled,
active,
})}
key={`${text}-${index}`}
>
<button
{...handlers}
title={intl.formatMessage(title)}
ref={focusRefCallback}
disabled={disabled}
data-index={index}
>
<Icon
id={text === 'quote' ? 'quote' : 'retweet'}
icon={iconComponent}
/>
<div>
{intl.formatMessage(title)}
{meta && (
<span className='reblog-button__meta'>
{intl.formatMessage(meta)}
</span>
)}
</div>
</button>
</li>
);
};
// Legacy helpers
// Switch between the legacy and new reblog button based on feature flag.
export const ReblogButton: FC<ReblogButtonProps> = (props) => {
if (isFeatureEnabled('outgoing_quotes')) {
return <StatusReblogButton {...props} />;
}
return <LegacyReblogButton {...props} />;
};
export const LegacyReblogButton: FC<ReblogButtonProps> = ({
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 (
<IconButton
disabled={disabled}
active={!!status.get('reblogged')}
title={intl.formatMessage(meta ?? title)}
icon='retweet'
iconComponent={iconComponent}
onClick={!disabled ? handleClick : undefined}
counter={
counters
? (status.get('reblogs_count') as number) +
(status.get('quotes_count') as number)
: undefined
}
/>
);
};
// 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<typeof selectStatusState>;
interface IconText {
title: MessageDescriptor;
meta?: MessageDescriptor;
iconComponent: FC<SVGProps<SVGSVGElement>>;
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;
}

View File

@@ -24,7 +24,7 @@ import { me } from '../../initial_state';
import { IconButton } from '../icon_button';
import { isFeatureEnabled } from '../../utils/environment';
import { ReblogButton } from '../status/reblog_button';
import { BoostButton } from '../status/boost_button';
import { RemoveQuoteHint } from './remove_quote_hint';
const messages = defineMessages({
@@ -372,7 +372,7 @@ class StatusActionBar extends ImmutablePureComponent {
<IconButton className='status__action-bar__button' title={replyTitle} icon={isReply ? 'reply' : replyIcon} iconComponent={isReply ? ReplyIcon : replyIconComponent} onClick={this.handleReplyClick} counter={status.get('replies_count')} />
</div>
<div className='status__action-bar__button-wrapper'>
<ReblogButton status={status} counters={withCounters} />
<BoostButton status={status} counters={withCounters} />
</div>
<div className='status__action-bar__button-wrapper'>
<IconButton className='status__action-bar__button star-icon' animate active={status.get('favourited')} title={favouriteTitle} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />

View File

@@ -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 {
<div className='detailed-status__action-bar'>
<div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} iconComponent={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? ReplyIcon : replyIconComponent} onClick={this.handleReplyClick} /></div>
<div className='detailed-status__button'>
<ReblogButton status={status} />
<BoostButton status={status} />
</div>
<div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={favouriteTitle} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} /></div>
<div className='detailed-status__button'><IconButton className='bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={bookmarkTitle} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} /></div>

View File

@@ -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 <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
const { text, dangerous } = option;
const { text, highlighted, disabled, dangerous } = option;
let element: React.ReactElement;
if (isActionItem(option)) {
element = (
<button onClick={onClick} data-index={i}>
{text}
<button onClick={onClick} data-index={i} disabled={disabled}>
<DropdownMenuItemContent item={option} />
</button>
);
} else if (isExternalLinkItem(option)) {
@@ -38,21 +39,22 @@ export const ActionsModal: React.FC<{
onClick={onClick}
data-index={i}
>
{text}
<DropdownMenuItemContent item={option} />
</a>
);
} else {
element = (
<Link to={option.to} onClick={onClick} data-index={i}>
{text}
<DropdownMenuItemContent item={option} />
</Link>
);
}
return (
<li
className={classNames({
className={classNames('dropdown-menu__item', {
'dropdown-menu__item--dangerous': dangerous,
'dropdown-menu__item--highlighted': highlighted,
})}
key={`${text}-${i}`}
>

View File

@@ -1,7 +1,13 @@
import type { KeyboardEvent, MouseEvent, TouchEvent } from 'react';
import type { IconProp } from '../components/icon';
interface BaseMenuItem {
text: string;
description?: string;
icon?: IconProp;
highlighted?: boolean;
disabled?: boolean;
dangerous?: boolean;
}

View File

@@ -0,0 +1,16 @@
/**
* Extend an existing type and make some of its properties required or optional.
* @example
* interface Person {
* name: string;
* age?: number;
* likesIceCream?: boolean;
* }
*
* type PersonWithSomeRequired = SomeRequired<Person, 'age' | 'likesIceCream' >;
* type PersonWithSomeOptional = SomeOptional<Person, 'name' >;
*/
export type SomeRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
export type SomeOptional<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>> &
Partial<Pick<T, K>>;

View File

@@ -2874,10 +2874,26 @@ a.account__display-name {
color: $error-value-color;
}
&--highlighted {
color: $highlight-text-color;
}
&-content {
display: flex;
flex-direction: column;
}
&-subtitle {
font-weight: 400;
}
a,
button {
font: inherit;
display: block;
display: flex;
align-items: center;
gap: 8px;
white-space: inherit;
width: 100%;
padding: 10px 14px;
border: 0;
@@ -2886,9 +2902,6 @@ a.account__display-name {
box-sizing: border-box;
text-decoration: none;
color: inherit;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: inherit;
border-radius: 4px;
@@ -2908,30 +2921,8 @@ a.account__display-name {
}
}
.reblog-button {
&__item {
max-width: 360px;
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;
}
.reblog-menu-item {
max-width: 360px;
}
.inline-account {