Fix missing icons and subtitle in mobile boost/quote menu (#36038)
This commit is contained in:
@@ -36,6 +36,7 @@ import {
|
|||||||
import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
|
import { Icon } from './icon';
|
||||||
import type { IconProp } from './icon';
|
import type { IconProp } from './icon';
|
||||||
import { IconButton } from './icon_button';
|
import { IconButton } from './icon_button';
|
||||||
|
|
||||||
@@ -68,6 +69,27 @@ interface DropdownMenuProps<Item = MenuItem> {
|
|||||||
onItemClick?: ItemClickFn<Item>;
|
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,>({
|
export const DropdownMenu = <Item = MenuItem,>({
|
||||||
items,
|
items,
|
||||||
loading,
|
loading,
|
||||||
@@ -200,7 +222,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
|||||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { text, dangerous } = option;
|
const { text, highlighted, disabled, dangerous } = option;
|
||||||
|
|
||||||
let element: React.ReactElement;
|
let element: React.ReactElement;
|
||||||
|
|
||||||
@@ -211,8 +233,9 @@ export const DropdownMenu = <Item = MenuItem,>({
|
|||||||
onClick={handleItemClick}
|
onClick={handleItemClick}
|
||||||
onKeyUp={handleItemKeyUp}
|
onKeyUp={handleItemKeyUp}
|
||||||
data-index={i}
|
data-index={i}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{text}
|
<DropdownMenuItemContent item={option} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
} else if (isExternalLinkItem(option)) {
|
} else if (isExternalLinkItem(option)) {
|
||||||
@@ -227,7 +250,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
|||||||
onKeyUp={handleItemKeyUp}
|
onKeyUp={handleItemKeyUp}
|
||||||
data-index={i}
|
data-index={i}
|
||||||
>
|
>
|
||||||
{text}
|
<DropdownMenuItemContent item={option} />
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -239,7 +262,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
|||||||
onKeyUp={handleItemKeyUp}
|
onKeyUp={handleItemKeyUp}
|
||||||
data-index={i}
|
data-index={i}
|
||||||
>
|
>
|
||||||
{text}
|
<DropdownMenuItemContent item={option} />
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -247,6 +270,7 @@ export const DropdownMenu = <Item = MenuItem,>({
|
|||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
className={classNames('dropdown-menu__item', {
|
className={classNames('dropdown-menu__item', {
|
||||||
|
'dropdown-menu__item--highlighted': highlighted,
|
||||||
'dropdown-menu__item--dangerous': dangerous,
|
'dropdown-menu__item--dangerous': dangerous,
|
||||||
})}
|
})}
|
||||||
key={`${text}-${i}`}
|
key={`${text}-${i}`}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
|
|||||||
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
|
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
|
||||||
import { statusFactoryState } from '@/testing/factories';
|
import { statusFactoryState } from '@/testing/factories';
|
||||||
|
|
||||||
import { LegacyReblogButton, StatusReblogButton } from './reblog_button';
|
import { LegacyReblogButton, StatusBoostButton } from './boost_button';
|
||||||
|
|
||||||
interface StoryProps {
|
interface StoryProps {
|
||||||
visibility: StatusVisibility;
|
visibility: StatusVisibility;
|
||||||
@@ -13,7 +13,7 @@ interface StoryProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Components/Status/ReblogButton',
|
title: 'Components/Status/BoostButton',
|
||||||
args: {
|
args: {
|
||||||
visibility: 'public',
|
visibility: 'public',
|
||||||
quoteAllowed: true,
|
quoteAllowed: true,
|
||||||
@@ -38,7 +38,7 @@ const meta = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
render: (args) => (
|
render: (args) => (
|
||||||
<StatusReblogButton
|
<StatusBoostButton
|
||||||
status={argsToStatus(args)}
|
status={argsToStatus(args)}
|
||||||
counters={args.reblogCount > 0}
|
counters={args.reblogCount > 0}
|
||||||
/>
|
/>
|
||||||
253
app/javascript/mastodon/components/status/boost_button.tsx
Normal file
253
app/javascript/mastodon/components/status/boost_button.tsx
Normal 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
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
161
app/javascript/mastodon/components/status/boost_button_utils.ts
Normal file
161
app/javascript/mastodon/components/status/boost_button_utils.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,7 @@ import { me } from '../../initial_state';
|
|||||||
|
|
||||||
import { IconButton } from '../icon_button';
|
import { IconButton } from '../icon_button';
|
||||||
import { isFeatureEnabled } from '../../utils/environment';
|
import { isFeatureEnabled } from '../../utils/environment';
|
||||||
import { ReblogButton } from '../status/reblog_button';
|
import { BoostButton } from '../status/boost_button';
|
||||||
import { RemoveQuoteHint } from './remove_quote_hint';
|
import { RemoveQuoteHint } from './remove_quote_hint';
|
||||||
|
|
||||||
const messages = defineMessages({
|
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')} />
|
<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>
|
||||||
<div className='status__action-bar__button-wrapper'>
|
<div className='status__action-bar__button-wrapper'>
|
||||||
<ReblogButton status={status} counters={withCounters} />
|
<BoostButton status={status} counters={withCounters} />
|
||||||
</div>
|
</div>
|
||||||
<div className='status__action-bar__button-wrapper'>
|
<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} />
|
<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} />
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { IconButton } from '../../../components/icon_button';
|
|||||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||||
import { me } from '../../../initial_state';
|
import { me } from '../../../initial_state';
|
||||||
import { isFeatureEnabled } from '@/mastodon/utils/environment';
|
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({
|
const messages = defineMessages({
|
||||||
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
delete: { id: 'status.delete', defaultMessage: 'Delete' },
|
||||||
@@ -310,7 +310,7 @@ class ActionBar extends PureComponent {
|
|||||||
<div className='detailed-status__action-bar'>
|
<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'><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'>
|
<div className='detailed-status__button'>
|
||||||
<ReblogButton status={status} />
|
<BoostButton status={status} />
|
||||||
</div>
|
</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='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>
|
<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>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { DropdownMenuItemContent } from 'mastodon/components/dropdown_menu';
|
||||||
import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
import type { MenuItem } from 'mastodon/models/dropdown_menu';
|
||||||
import {
|
import {
|
||||||
isActionItem,
|
isActionItem,
|
||||||
@@ -18,14 +19,14 @@ export const ActionsModal: React.FC<{
|
|||||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { text, dangerous } = option;
|
const { text, highlighted, disabled, dangerous } = option;
|
||||||
|
|
||||||
let element: React.ReactElement;
|
let element: React.ReactElement;
|
||||||
|
|
||||||
if (isActionItem(option)) {
|
if (isActionItem(option)) {
|
||||||
element = (
|
element = (
|
||||||
<button onClick={onClick} data-index={i}>
|
<button onClick={onClick} data-index={i} disabled={disabled}>
|
||||||
{text}
|
<DropdownMenuItemContent item={option} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
} else if (isExternalLinkItem(option)) {
|
} else if (isExternalLinkItem(option)) {
|
||||||
@@ -38,21 +39,22 @@ export const ActionsModal: React.FC<{
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
data-index={i}
|
data-index={i}
|
||||||
>
|
>
|
||||||
{text}
|
<DropdownMenuItemContent item={option} />
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
element = (
|
element = (
|
||||||
<Link to={option.to} onClick={onClick} data-index={i}>
|
<Link to={option.to} onClick={onClick} data-index={i}>
|
||||||
{text}
|
<DropdownMenuItemContent item={option} />
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
className={classNames({
|
className={classNames('dropdown-menu__item', {
|
||||||
'dropdown-menu__item--dangerous': dangerous,
|
'dropdown-menu__item--dangerous': dangerous,
|
||||||
|
'dropdown-menu__item--highlighted': highlighted,
|
||||||
})}
|
})}
|
||||||
key={`${text}-${i}`}
|
key={`${text}-${i}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import type { KeyboardEvent, MouseEvent, TouchEvent } from 'react';
|
import type { KeyboardEvent, MouseEvent, TouchEvent } from 'react';
|
||||||
|
|
||||||
|
import type { IconProp } from '../components/icon';
|
||||||
|
|
||||||
interface BaseMenuItem {
|
interface BaseMenuItem {
|
||||||
text: string;
|
text: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: IconProp;
|
||||||
|
highlighted?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
dangerous?: boolean;
|
dangerous?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
16
app/javascript/mastodon/utils/types.ts
Normal file
16
app/javascript/mastodon/utils/types.ts
Normal 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>>;
|
||||||
@@ -2874,10 +2874,26 @@ a.account__display-name {
|
|||||||
color: $error-value-color;
|
color: $error-value-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--highlighted {
|
||||||
|
color: $highlight-text-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-subtitle {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
a,
|
a,
|
||||||
button {
|
button {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
display: block;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: inherit;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -2886,9 +2902,6 @@ a.account__display-name {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-align: inherit;
|
text-align: inherit;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|
||||||
@@ -2908,30 +2921,8 @@ a.account__display-name {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.reblog-button {
|
.reblog-menu-item {
|
||||||
&__item {
|
|
||||||
max-width: 360px;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-account {
|
.inline-account {
|
||||||
|
|||||||
Reference in New Issue
Block a user