Merge tag 'v4.3.0-rc.1'
This commit is contained in:
commit
26c9b9ba39
3459 changed files with 130932 additions and 69993 deletions
|
|
@ -9,7 +9,11 @@ exports[`<AutosuggestEmoji /> renders emoji with custom url 1`] = `
|
|||
className="emojione"
|
||||
src="http://example.com/emoji.png"
|
||||
/>
|
||||
:foobar:
|
||||
<div
|
||||
className="autosuggest-emoji__name"
|
||||
>
|
||||
:foobar:
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
@ -22,6 +26,10 @@ exports[`<AutosuggestEmoji /> renders native emoji 1`] = `
|
|||
className="emojione"
|
||||
src="/emoji/1f499.svg"
|
||||
/>
|
||||
:foobar:
|
||||
<div
|
||||
className="autosuggest-emoji__name"
|
||||
>
|
||||
:foobar:
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ exports[`<Avatar /> Autoplay renders a animated avatar 1`] = `
|
|||
}
|
||||
>
|
||||
<img
|
||||
alt="alice"
|
||||
alt=""
|
||||
src="/animated/alice.gif"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -32,7 +32,7 @@ exports[`<Avatar /> Still renders a still avatar 1`] = `
|
|||
}
|
||||
>
|
||||
<img
|
||||
alt="alice"
|
||||
alt=""
|
||||
src="/static/alice.jpg"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { render, fireEvent, screen } from '@testing-library/react';
|
||||
import renderer from 'react-test-renderer';
|
||||
|
||||
import Button from '../button';
|
||||
import { render, fireEvent, screen } from 'mastodon/test_helpers';
|
||||
|
||||
import { Button } from '../button';
|
||||
|
||||
describe('<Button />', () => {
|
||||
it('renders a button element', () => {
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ describe('computeHashtagBarForStatus', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('puts the hashtags in the bar if a status content has hashtags in the only line and has a media', () => {
|
||||
it('does not put the hashtags in the bar if a status content has hashtags in the only line and has a media', () => {
|
||||
const status = createStatus(
|
||||
'<p>This is my content! <a href="test">#hashtag</a></p>',
|
||||
['hashtag'],
|
||||
|
|
|
|||
|
|
@ -1,24 +1,25 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import { EmptyAccount } from 'mastodon/components/empty_account';
|
||||
import { ShortNumber } from 'mastodon/components/short_number';
|
||||
import { VerifiedBadge } from 'mastodon/components/verified_badge';
|
||||
|
||||
import DropdownMenuContainer from '../containers/dropdown_menu_container';
|
||||
import { me } from '../initial_state';
|
||||
|
||||
import { Avatar } from './avatar';
|
||||
import Button from './button';
|
||||
import { Button } from './button';
|
||||
import { FollowersCounter } from './counters';
|
||||
import { DisplayName } from './display_name';
|
||||
import { IconButton } from './icon_button';
|
||||
import { RelativeTimestamp } from './relative_timestamp';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
|
@ -31,160 +32,150 @@ const messages = defineMessages({
|
|||
unmute_notifications: { id: 'account.unmute_notifications_short', defaultMessage: 'Unmute notifications' },
|
||||
mute: { id: 'account.mute_short', defaultMessage: 'Mute' },
|
||||
block: { id: 'account.block_short', defaultMessage: 'Block' },
|
||||
more: { id: 'status.more', defaultMessage: 'More' },
|
||||
});
|
||||
|
||||
class Account extends ImmutablePureComponent {
|
||||
const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifications, hidden, minimal, defaultAction, withBio }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
static propTypes = {
|
||||
size: PropTypes.number,
|
||||
account: ImmutablePropTypes.map,
|
||||
onFollow: PropTypes.func.isRequired,
|
||||
onBlock: PropTypes.func.isRequired,
|
||||
onMute: PropTypes.func.isRequired,
|
||||
onMuteNotifications: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
hidden: PropTypes.bool,
|
||||
minimal: PropTypes.bool,
|
||||
actionIcon: PropTypes.string,
|
||||
actionTitle: PropTypes.string,
|
||||
defaultAction: PropTypes.string,
|
||||
onActionClick: PropTypes.func,
|
||||
withBio: PropTypes.bool,
|
||||
};
|
||||
const handleFollow = useCallback(() => {
|
||||
onFollow(account);
|
||||
}, [onFollow, account]);
|
||||
|
||||
static defaultProps = {
|
||||
size: 46,
|
||||
};
|
||||
const handleBlock = useCallback(() => {
|
||||
onBlock(account);
|
||||
}, [onBlock, account]);
|
||||
|
||||
handleFollow = () => {
|
||||
this.props.onFollow(this.props.account);
|
||||
};
|
||||
const handleMute = useCallback(() => {
|
||||
onMute(account);
|
||||
}, [onMute, account]);
|
||||
|
||||
handleBlock = () => {
|
||||
this.props.onBlock(this.props.account);
|
||||
};
|
||||
const handleMuteNotifications = useCallback(() => {
|
||||
onMuteNotifications(account, true);
|
||||
}, [onMuteNotifications, account]);
|
||||
|
||||
handleMute = () => {
|
||||
this.props.onMute(this.props.account);
|
||||
};
|
||||
const handleUnmuteNotifications = useCallback(() => {
|
||||
onMuteNotifications(account, false);
|
||||
}, [onMuteNotifications, account]);
|
||||
|
||||
handleMuteNotifications = () => {
|
||||
this.props.onMuteNotifications(this.props.account, true);
|
||||
};
|
||||
|
||||
handleUnmuteNotifications = () => {
|
||||
this.props.onMuteNotifications(this.props.account, false);
|
||||
};
|
||||
|
||||
handleAction = () => {
|
||||
this.props.onActionClick(this.props.account);
|
||||
};
|
||||
|
||||
render () {
|
||||
const { account, intl, hidden, withBio, onActionClick, actionIcon, actionTitle, defaultAction, size, minimal } = this.props;
|
||||
|
||||
if (!account) {
|
||||
return <EmptyAccount size={size} minimal={minimal} />;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
return (
|
||||
<>
|
||||
{account.get('display_name')}
|
||||
{account.get('username')}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
let buttons;
|
||||
|
||||
if (actionIcon && onActionClick) {
|
||||
buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />;
|
||||
} else if (!actionIcon && account.get('id') !== me && account.get('relationship', null) !== null) {
|
||||
const following = account.getIn(['relationship', 'following']);
|
||||
const requested = account.getIn(['relationship', 'requested']);
|
||||
const blocking = account.getIn(['relationship', 'blocking']);
|
||||
const muting = account.getIn(['relationship', 'muting']);
|
||||
|
||||
if (requested) {
|
||||
buttons = <Button text={intl.formatMessage(messages.cancel_follow_request)} onClick={this.handleFollow} />;
|
||||
} else if (blocking) {
|
||||
buttons = <Button text={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />;
|
||||
} else if (muting) {
|
||||
let hidingNotificationsButton;
|
||||
|
||||
if (account.getIn(['relationship', 'muting_notifications'])) {
|
||||
hidingNotificationsButton = <Button text={intl.formatMessage(messages.unmute_notifications)} onClick={this.handleUnmuteNotifications} />;
|
||||
} else {
|
||||
hidingNotificationsButton = <Button text={intl.formatMessage(messages.mute_notifications)} onClick={this.handleMuteNotifications} />;
|
||||
}
|
||||
|
||||
buttons = (
|
||||
<>
|
||||
<Button text={intl.formatMessage(messages.unmute)} onClick={this.handleMute} />
|
||||
{hidingNotificationsButton}
|
||||
</>
|
||||
);
|
||||
} else if (defaultAction === 'mute') {
|
||||
buttons = <Button title={intl.formatMessage(messages.mute)} onClick={this.handleMute} />;
|
||||
} else if (defaultAction === 'block') {
|
||||
buttons = <Button text={intl.formatMessage(messages.block)} onClick={this.handleBlock} />;
|
||||
} else if (!account.get('moved') || following) {
|
||||
buttons = <Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} />;
|
||||
}
|
||||
}
|
||||
|
||||
let muteTimeRemaining;
|
||||
|
||||
if (account.get('mute_expires_at')) {
|
||||
muteTimeRemaining = <>· <RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></>;
|
||||
}
|
||||
|
||||
let verification;
|
||||
|
||||
const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
|
||||
|
||||
if (firstVerifiedField) {
|
||||
verification = <VerifiedBadge link={firstVerifiedField.get('value')} />;
|
||||
}
|
||||
if (!account) {
|
||||
return <EmptyAccount size={size} minimal={minimal} />;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
return (
|
||||
<div className={classNames('account', { 'account--minimal': minimal })}>
|
||||
<div className='account__wrapper'>
|
||||
<Link key={account.get('id')} className='account__display-name' title={account.get('acct')} to={`/@${account.get('acct')}`}>
|
||||
<div className='account__avatar-wrapper'>
|
||||
<Avatar account={account} size={size} />
|
||||
</div>
|
||||
|
||||
<div className='account__contents'>
|
||||
<DisplayName account={account} />
|
||||
{!minimal && (
|
||||
<div className='account__details'>
|
||||
<ShortNumber value={account.get('followers_count')} renderer={FollowersCounter} /> {verification} {muteTimeRemaining}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{!minimal && (
|
||||
<div className='account__relationship'>
|
||||
{buttons}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{withBio && (account.get('note').length > 0 ? (
|
||||
<div
|
||||
className='account__note translate'
|
||||
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
|
||||
/>
|
||||
) : (
|
||||
<div className='account__note account__note--missing'><FormattedMessage id='account.no_bio' defaultMessage='No description provided.' /></div>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
{account.get('display_name')}
|
||||
{account.get('username')}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
let buttons;
|
||||
|
||||
export default injectIntl(Account);
|
||||
if (account.get('id') !== me && account.get('relationship', null) !== null) {
|
||||
const following = account.getIn(['relationship', 'following']);
|
||||
const requested = account.getIn(['relationship', 'requested']);
|
||||
const blocking = account.getIn(['relationship', 'blocking']);
|
||||
const muting = account.getIn(['relationship', 'muting']);
|
||||
|
||||
if (requested) {
|
||||
buttons = <Button text={intl.formatMessage(messages.cancel_follow_request)} onClick={handleFollow} />;
|
||||
} else if (blocking) {
|
||||
buttons = <Button text={intl.formatMessage(messages.unblock)} onClick={handleBlock} />;
|
||||
} else if (muting) {
|
||||
let menu;
|
||||
|
||||
if (account.getIn(['relationship', 'muting_notifications'])) {
|
||||
menu = [{ text: intl.formatMessage(messages.unmute_notifications), action: handleUnmuteNotifications }];
|
||||
} else {
|
||||
menu = [{ text: intl.formatMessage(messages.mute_notifications), action: handleMuteNotifications }];
|
||||
}
|
||||
|
||||
buttons = (
|
||||
<>
|
||||
<DropdownMenuContainer
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
|
||||
<Button text={intl.formatMessage(messages.unmute)} onClick={handleMute} />
|
||||
</>
|
||||
);
|
||||
} else if (defaultAction === 'mute') {
|
||||
buttons = <Button text={intl.formatMessage(messages.mute)} onClick={handleMute} />;
|
||||
} else if (defaultAction === 'block') {
|
||||
buttons = <Button text={intl.formatMessage(messages.block)} onClick={handleBlock} />;
|
||||
} else if (!account.get('suspended') && !account.get('moved') || following) {
|
||||
buttons = <Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={handleFollow} />;
|
||||
}
|
||||
}
|
||||
|
||||
let muteTimeRemaining;
|
||||
|
||||
if (account.get('mute_expires_at')) {
|
||||
muteTimeRemaining = <>· <RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></>;
|
||||
}
|
||||
|
||||
let verification;
|
||||
|
||||
const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
|
||||
|
||||
if (firstVerifiedField) {
|
||||
verification = <VerifiedBadge link={firstVerifiedField.get('value')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames('account', { 'account--minimal': minimal })}>
|
||||
<div className='account__wrapper'>
|
||||
<Link key={account.get('id')} className='account__display-name' title={account.get('acct')} to={`/@${account.get('acct')}`} data-hover-card-account={account.get('id')}>
|
||||
<div className='account__avatar-wrapper'>
|
||||
<Avatar account={account} size={size} />
|
||||
</div>
|
||||
|
||||
<div className='account__contents'>
|
||||
<DisplayName account={account} />
|
||||
{!minimal && (
|
||||
<div className='account__details'>
|
||||
<ShortNumber value={account.get('followers_count')} renderer={FollowersCounter} /> {verification} {muteTimeRemaining}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{!minimal && (
|
||||
<div className='account__relationship'>
|
||||
{buttons}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{withBio && (account.get('note').length > 0 ? (
|
||||
<div
|
||||
className='account__note translate'
|
||||
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
|
||||
/>
|
||||
) : (
|
||||
<div className='account__note account__note--missing'><FormattedMessage id='account.no_bio' defaultMessage='No description provided.' /></div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Account.propTypes = {
|
||||
size: PropTypes.number,
|
||||
account: ImmutablePropTypes.record,
|
||||
onFollow: PropTypes.func,
|
||||
onBlock: PropTypes.func,
|
||||
onMute: PropTypes.func,
|
||||
onMuteNotifications: PropTypes.func,
|
||||
hidden: PropTypes.bool,
|
||||
minimal: PropTypes.bool,
|
||||
defaultAction: PropTypes.string,
|
||||
withBio: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default Account;
|
||||
|
|
|
|||
20
app/javascript/mastodon/components/account_bio.tsx
Normal file
20
app/javascript/mastodon/components/account_bio.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { useLinks } from 'mastodon/../hooks/useLinks';
|
||||
|
||||
export const AccountBio: React.FC<{
|
||||
note: string;
|
||||
className: string;
|
||||
}> = ({ note, className }) => {
|
||||
const handleClick = useLinks();
|
||||
|
||||
if (note.length === 0 || note === '<p></p>') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${className} translate`}
|
||||
dangerouslySetInnerHTML={{ __html: note }}
|
||||
onClickCapture={handleClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
42
app/javascript/mastodon/components/account_fields.tsx
Normal file
42
app/javascript/mastodon/components/account_fields.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import classNames from 'classnames';
|
||||
|
||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
import { useLinks } from 'mastodon/../hooks/useLinks';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import type { Account } from 'mastodon/models/account';
|
||||
|
||||
export const AccountFields: React.FC<{
|
||||
fields: Account['fields'];
|
||||
limit: number;
|
||||
}> = ({ fields, limit = -1 }) => {
|
||||
const handleClick = useLinks();
|
||||
|
||||
if (fields.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account-fields' onClickCapture={handleClick}>
|
||||
{fields.take(limit).map((pair, i) => (
|
||||
<dl
|
||||
key={i}
|
||||
className={classNames({ verified: pair.get('verified_at') })}
|
||||
>
|
||||
<dt
|
||||
dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }}
|
||||
className='translate'
|
||||
/>
|
||||
|
||||
<dd className='translate' title={pair.get('value_plain') ?? ''}>
|
||||
{pair.get('verified_at') && (
|
||||
<Icon id='check' icon={CheckIcon} className='verified__mark' />
|
||||
)}
|
||||
<span
|
||||
dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }}
|
||||
/>
|
||||
</dd>
|
||||
</dl>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -48,7 +48,7 @@ export default class Counter extends PureComponent {
|
|||
componentDidMount () {
|
||||
const { measure, start_at, end_at, params } = this.props;
|
||||
|
||||
api().post('/api/v1/admin/measures', { keys: [measure], start_at, end_at, [measure]: params }).then(res => {
|
||||
api(false).post('/api/v1/admin/measures', { keys: [measure], start_at, end_at, [measure]: params }).then(res => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
data: res.data,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export default class Dimension extends PureComponent {
|
|||
componentDidMount () {
|
||||
const { start_at, end_at, dimension, limit, params } = this.props;
|
||||
|
||||
api().post('/api/v1/admin/dimensions', { keys: [dimension], start_at, end_at, limit, [dimension]: params }).then(res => {
|
||||
api(false).post('/api/v1/admin/dimensions', { keys: [dimension], start_at, end_at, limit, [dimension]: params }).then(res => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
data: res.data,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export default class ImpactReport extends PureComponent {
|
|||
include_subdomains: true,
|
||||
};
|
||||
|
||||
api().post('/api/v1/admin/measures', {
|
||||
api(false).post('/api/v1/admin/measures', {
|
||||
keys: ['instance_accounts', 'instance_follows', 'instance_followers'],
|
||||
start_at: null,
|
||||
end_at: null,
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ class ReportReasonSelector extends PureComponent {
|
|||
};
|
||||
|
||||
componentDidMount() {
|
||||
api().get('/api/v1/instance').then(res => {
|
||||
api(false).get('/api/v1/instance').then(res => {
|
||||
this.setState({
|
||||
rules: res.data.rules,
|
||||
});
|
||||
|
|
@ -122,7 +122,7 @@ class ReportReasonSelector extends PureComponent {
|
|||
return;
|
||||
}
|
||||
|
||||
api().put(`/api/v1/admin/reports/${id}`, {
|
||||
api(false).put(`/api/v1/admin/reports/${id}`, {
|
||||
category,
|
||||
rule_ids: category === 'violation' ? rule_ids : [],
|
||||
}).catch(err => {
|
||||
|
|
@ -153,7 +153,7 @@ class ReportReasonSelector extends PureComponent {
|
|||
<Category id='other' text={intl.formatMessage(messages.other)} selected={category === 'other'} onSelect={this.handleSelect} disabled={disabled} />
|
||||
<Category id='legal' text={intl.formatMessage(messages.legal)} selected={category === 'legal'} onSelect={this.handleSelect} disabled={disabled} />
|
||||
<Category id='spam' text={intl.formatMessage(messages.spam)} selected={category === 'spam'} onSelect={this.handleSelect} disabled={disabled} />
|
||||
<Category id='violation' text={intl.formatMessage(messages.violation)} selected={category === 'violation'} onSelect={this.handleSelect} disabled={disabled}>
|
||||
<Category id='violation' text={intl.formatMessage(messages.violation)} selected={category === 'violation'} onSelect={this.handleSelect} disabled={disabled || rules.length === 0}>
|
||||
{rules.map(rule => <Rule key={rule.id} id={rule.id} text={rule.text} selected={rule_ids.includes(rule.id)} onToggle={this.handleToggle} disabled={disabled} />)}
|
||||
</Category>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export default class Retention extends PureComponent {
|
|||
componentDidMount () {
|
||||
const { start_at, end_at, frequency } = this.props;
|
||||
|
||||
api().post('/api/v1/admin/retention', { start_at, end_at, frequency }).then(res => {
|
||||
api(false).post('/api/v1/admin/retention', { start_at, end_at, frequency }).then(res => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
data: res.data,
|
||||
|
|
@ -51,7 +51,7 @@ export default class Retention extends PureComponent {
|
|||
let content;
|
||||
|
||||
if (loading) {
|
||||
content = <FormattedMessage id='loading_indicator.label' defaultMessage='Loading...' />;
|
||||
content = <FormattedMessage id='loading_indicator.label' defaultMessage='Loading…' />;
|
||||
} else {
|
||||
content = (
|
||||
<table className='retention__table'>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { FormattedMessage } from 'react-intl';
|
|||
import classNames from 'classnames';
|
||||
|
||||
import api from 'mastodon/api';
|
||||
import Hashtag from 'mastodon/components/hashtag';
|
||||
import { Hashtag } from 'mastodon/components/hashtag';
|
||||
|
||||
export default class Trends extends PureComponent {
|
||||
|
||||
|
|
@ -22,7 +22,7 @@ export default class Trends extends PureComponent {
|
|||
componentDidMount () {
|
||||
const { limit } = this.props;
|
||||
|
||||
api().get('/api/v1/admin/trends/tags', { params: { limit } }).then(res => {
|
||||
api(false).get('/api/v1/admin/trends/tags', { params: { limit } }).then(res => {
|
||||
this.setState({
|
||||
loading: false,
|
||||
data: res.data,
|
||||
|
|
|
|||
67
app/javascript/mastodon/components/alt_text_badge.tsx
Normal file
67
app/javascript/mastodon/components/alt_text_badge.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { useState, useCallback, useRef } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import Overlay from 'react-overlays/Overlay';
|
||||
import type {
|
||||
OffsetValue,
|
||||
UsePopperOptions,
|
||||
} from 'react-overlays/esm/usePopper';
|
||||
|
||||
const offset = [0, 4] as OffsetValue;
|
||||
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
||||
|
||||
export const AltTextBadge: React.FC<{
|
||||
description: string;
|
||||
}> = ({ description }) => {
|
||||
const anchorRef = useRef<HTMLButtonElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setOpen((v) => !v);
|
||||
}, [setOpen]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, [setOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={anchorRef}
|
||||
className='media-gallery__alt__label'
|
||||
onClick={handleClick}
|
||||
>
|
||||
ALT
|
||||
</button>
|
||||
|
||||
<Overlay
|
||||
rootClose
|
||||
onHide={handleClose}
|
||||
show={open}
|
||||
target={anchorRef.current}
|
||||
placement='top-end'
|
||||
flip
|
||||
offset={offset}
|
||||
popperConfig={popperConfig}
|
||||
>
|
||||
{({ props }) => (
|
||||
<div {...props} className='hover-card-controller'>
|
||||
<div
|
||||
className='media-gallery__alt__popover dropdown-animation'
|
||||
role='tooltip'
|
||||
>
|
||||
<h4>
|
||||
<FormattedMessage
|
||||
id='alt_text_badge.title'
|
||||
defaultMessage='Alt text'
|
||||
/>
|
||||
</h4>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -48,8 +48,9 @@ export const AnimatedNumber: React.FC<Props> = ({ value }) => {
|
|||
<span
|
||||
key={key}
|
||||
style={{
|
||||
position: direction * style.y > 0 ? 'absolute' : 'static',
|
||||
transform: `translateY(${style.y * 100}%)`,
|
||||
position:
|
||||
direction * (style.y ?? 0) > 0 ? 'absolute' : 'static',
|
||||
transform: `translateY(${(style.y ?? 0) * 100}%)`,
|
||||
}}
|
||||
>
|
||||
<ShortNumber value={data as number} />
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import classNames from 'classnames';
|
|||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import LinkIcon from '@/material-icons/400-24px/link.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
const filename = url => url.split('/').pop().split('#')[0].split('?')[0];
|
||||
|
|
@ -25,7 +26,7 @@ export default class AttachmentList extends ImmutablePureComponent {
|
|||
<div className={classNames('attachment-list', { compact })}>
|
||||
{!compact && (
|
||||
<div className='attachment-list__icon'>
|
||||
<Icon id='link' />
|
||||
<Icon id='link' icon={LinkIcon} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -36,7 +37,7 @@ export default class AttachmentList extends ImmutablePureComponent {
|
|||
return (
|
||||
<li key={attachment.get('id')}>
|
||||
<a href={displayUrl} target='_blank' rel='noopener noreferrer'>
|
||||
{compact && <Icon id='link' />}
|
||||
{compact && <Icon id='link' icon={LinkIcon} />}
|
||||
{compact && ' ' }
|
||||
{displayUrl ? filename(displayUrl) : <FormattedMessage id='attachments_list.unprocessed' defaultMessage='(unprocessed)' />}
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { PureComponent } from 'react';
|
|||
|
||||
import { assetHost } from 'mastodon/utils/config';
|
||||
|
||||
import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light';
|
||||
import { unicodeMapping } from '../features/emoji/emoji_unicode_mapping_light';
|
||||
|
||||
export default class AutosuggestEmoji extends PureComponent {
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ export default class AutosuggestEmoji extends PureComponent {
|
|||
alt={emoji.native || emoji.colons}
|
||||
/>
|
||||
|
||||
{emoji.colons}
|
||||
<div className='autosuggest-emoji__name'>{emoji.colons}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { ShortNumber } from 'mastodon/components/short_number';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -16,27 +14,18 @@ interface Props {
|
|||
};
|
||||
}
|
||||
|
||||
export const AutosuggestHashtag: React.FC<Props> = ({ tag }) => {
|
||||
const weeklyUses = tag.history && (
|
||||
<ShortNumber
|
||||
value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='autosuggest-hashtag'>
|
||||
<div className='autosuggest-hashtag__name'>
|
||||
#<strong>{tag.name}</strong>
|
||||
</div>
|
||||
{tag.history !== undefined && (
|
||||
<div className='autosuggest-hashtag__uses'>
|
||||
<FormattedMessage
|
||||
id='autosuggest_hashtag.per_week'
|
||||
defaultMessage='{count} per week'
|
||||
values={{ count: weeklyUses }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
export const AutosuggestHashtag: React.FC<Props> = ({ tag }) => (
|
||||
<div className='autosuggest-hashtag'>
|
||||
<div className='autosuggest-hashtag__name'>
|
||||
#<strong>{tag.name}</strong>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
{tag.history !== undefined && (
|
||||
<div className='autosuggest-hashtag__uses'>
|
||||
<ShortNumber
|
||||
value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import classNames from 'classnames';
|
|||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import Overlay from 'react-overlays/Overlay';
|
||||
|
||||
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
||||
|
||||
import AutosuggestEmoji from './autosuggest_emoji';
|
||||
|
|
@ -195,34 +197,37 @@ export default class AutosuggestInput extends ImmutablePureComponent {
|
|||
|
||||
return (
|
||||
<div className='autosuggest-input'>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{placeholder}</span>
|
||||
<input
|
||||
type='text'
|
||||
ref={this.setInput}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
dir='auto'
|
||||
aria-autocomplete='list'
|
||||
aria-label={placeholder}
|
||||
id={id}
|
||||
className={className}
|
||||
maxLength={maxLength}
|
||||
lang={lang}
|
||||
spellCheck={spellCheck}
|
||||
/>
|
||||
|
||||
<input
|
||||
type='text'
|
||||
ref={this.setInput}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
dir='auto'
|
||||
aria-autocomplete='list'
|
||||
id={id}
|
||||
className={className}
|
||||
maxLength={maxLength}
|
||||
lang={lang}
|
||||
spellCheck={spellCheck}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
|
||||
{suggestions.map(this.renderSuggestion)}
|
||||
</div>
|
||||
<Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={this.input} popperConfig={{ strategy: 'fixed' }}>
|
||||
{({ props }) => (
|
||||
<div {...props}>
|
||||
<div className='autosuggest-textarea__suggestions' style={{ width: this.input?.clientWidth }}>
|
||||
{suggestions.map(this.renderSuggestion)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { useCallback, useRef, useState, useEffect, forwardRef } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
|
||||
import Overlay from 'react-overlays/Overlay';
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
|
||||
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
||||
|
|
@ -37,54 +38,45 @@ const textAtCursorMatchesToken = (str, caretPosition) => {
|
|||
}
|
||||
};
|
||||
|
||||
export default class AutosuggestTextarea extends ImmutablePureComponent {
|
||||
const AutosuggestTextarea = forwardRef(({
|
||||
value,
|
||||
suggestions,
|
||||
disabled,
|
||||
placeholder,
|
||||
onSuggestionSelected,
|
||||
onSuggestionsClearRequested,
|
||||
onSuggestionsFetchRequested,
|
||||
onChange,
|
||||
onKeyUp,
|
||||
onKeyDown,
|
||||
onPaste,
|
||||
onFocus,
|
||||
autoFocus = true,
|
||||
lang,
|
||||
}, textareaRef) => {
|
||||
|
||||
static propTypes = {
|
||||
value: PropTypes.string,
|
||||
suggestions: ImmutablePropTypes.list,
|
||||
disabled: PropTypes.bool,
|
||||
placeholder: PropTypes.string,
|
||||
onSuggestionSelected: PropTypes.func.isRequired,
|
||||
onSuggestionsClearRequested: PropTypes.func.isRequired,
|
||||
onSuggestionsFetchRequested: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onKeyUp: PropTypes.func,
|
||||
onKeyDown: PropTypes.func,
|
||||
onPaste: PropTypes.func.isRequired,
|
||||
autoFocus: PropTypes.bool,
|
||||
lang: PropTypes.string,
|
||||
};
|
||||
const [suggestionsHidden, setSuggestionsHidden] = useState(true);
|
||||
const [selectedSuggestion, setSelectedSuggestion] = useState(0);
|
||||
const lastTokenRef = useRef(null);
|
||||
const tokenStartRef = useRef(0);
|
||||
|
||||
static defaultProps = {
|
||||
autoFocus: true,
|
||||
};
|
||||
|
||||
state = {
|
||||
suggestionsHidden: true,
|
||||
focused: false,
|
||||
selectedSuggestion: 0,
|
||||
lastToken: null,
|
||||
tokenStart: 0,
|
||||
};
|
||||
|
||||
onChange = (e) => {
|
||||
const handleChange = useCallback((e) => {
|
||||
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart);
|
||||
|
||||
if (token !== null && this.state.lastToken !== token) {
|
||||
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
|
||||
this.props.onSuggestionsFetchRequested(token);
|
||||
if (token !== null && lastTokenRef.current !== token) {
|
||||
tokenStartRef.current = tokenStart;
|
||||
lastTokenRef.current = token;
|
||||
setSelectedSuggestion(0);
|
||||
onSuggestionsFetchRequested(token);
|
||||
} else if (token === null) {
|
||||
this.setState({ lastToken: null });
|
||||
this.props.onSuggestionsClearRequested();
|
||||
lastTokenRef.current = null;
|
||||
onSuggestionsClearRequested();
|
||||
}
|
||||
|
||||
this.props.onChange(e);
|
||||
};
|
||||
|
||||
onKeyDown = (e) => {
|
||||
const { suggestions, disabled } = this.props;
|
||||
const { selectedSuggestion, suggestionsHidden } = this.state;
|
||||
onChange(e);
|
||||
}, [onSuggestionsFetchRequested, onSuggestionsClearRequested, onChange, setSelectedSuggestion]);
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (disabled) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
|
|
@ -102,80 +94,75 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
|||
document.querySelector('.ui').parentElement.focus();
|
||||
} else {
|
||||
e.preventDefault();
|
||||
this.setState({ suggestionsHidden: true });
|
||||
setSuggestionsHidden(true);
|
||||
}
|
||||
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
if (suggestions.size > 0 && !suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
|
||||
setSelectedSuggestion(Math.min(selectedSuggestion + 1, suggestions.size - 1));
|
||||
}
|
||||
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
if (suggestions.size > 0 && !suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
|
||||
setSelectedSuggestion(Math.max(selectedSuggestion - 1, 0));
|
||||
}
|
||||
|
||||
break;
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
// Select suggestion
|
||||
if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
|
||||
if (lastTokenRef.current !== null && suggestions.size > 0 && !suggestionsHidden) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
|
||||
onSuggestionSelected(tokenStartRef.current, lastTokenRef.current, suggestions.get(selectedSuggestion));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (e.defaultPrevented || !this.props.onKeyDown) {
|
||||
if (e.defaultPrevented || !onKeyDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.onKeyDown(e);
|
||||
};
|
||||
onKeyDown(e);
|
||||
}, [disabled, suggestions, suggestionsHidden, selectedSuggestion, setSelectedSuggestion, setSuggestionsHidden, onSuggestionSelected, onKeyDown]);
|
||||
|
||||
onBlur = () => {
|
||||
this.setState({ suggestionsHidden: true, focused: false });
|
||||
};
|
||||
const handleBlur = useCallback(() => {
|
||||
setSuggestionsHidden(true);
|
||||
}, [setSuggestionsHidden]);
|
||||
|
||||
onFocus = (e) => {
|
||||
this.setState({ focused: true });
|
||||
if (this.props.onFocus) {
|
||||
this.props.onFocus(e);
|
||||
const handleFocus = useCallback((e) => {
|
||||
if (onFocus) {
|
||||
onFocus(e);
|
||||
}
|
||||
};
|
||||
}, [onFocus]);
|
||||
|
||||
onSuggestionClick = (e) => {
|
||||
const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
|
||||
const handleSuggestionClick = useCallback((e) => {
|
||||
const suggestion = suggestions.get(e.currentTarget.getAttribute('data-index'));
|
||||
e.preventDefault();
|
||||
this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
|
||||
this.textarea.focus();
|
||||
};
|
||||
onSuggestionSelected(tokenStartRef.current, lastTokenRef.current, suggestion);
|
||||
textareaRef.current?.focus();
|
||||
}, [suggestions, onSuggestionSelected, textareaRef]);
|
||||
|
||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
|
||||
this.setState({ suggestionsHidden: false });
|
||||
}
|
||||
}
|
||||
|
||||
setTextarea = (c) => {
|
||||
this.textarea = c;
|
||||
};
|
||||
|
||||
onPaste = (e) => {
|
||||
const handlePaste = useCallback((e) => {
|
||||
if (e.clipboardData && e.clipboardData.files.length === 1) {
|
||||
this.props.onPaste(e.clipboardData.files);
|
||||
onPaste(e.clipboardData.files);
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
}, [onPaste]);
|
||||
|
||||
renderSuggestion = (suggestion, i) => {
|
||||
const { selectedSuggestion } = this.state;
|
||||
// Show the suggestions again whenever they change and the textarea is focused
|
||||
useEffect(() => {
|
||||
if (suggestions.size > 0 && textareaRef.current === document.activeElement) {
|
||||
setSuggestionsHidden(false);
|
||||
}
|
||||
}, [suggestions, textareaRef, setSuggestionsHidden]);
|
||||
|
||||
const renderSuggestion = (suggestion, i) => {
|
||||
let inner, key;
|
||||
|
||||
if (suggestion.type === 'emoji') {
|
||||
|
|
@ -190,50 +177,61 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<div role='button' tabIndex={0} key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}>
|
||||
<div role='button' tabIndex={0} key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={handleSuggestionClick}>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render () {
|
||||
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, lang, children } = this.props;
|
||||
const { suggestionsHidden } = this.state;
|
||||
return (
|
||||
<div className='autosuggest-textarea'>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
className='autosuggest-textarea__textarea'
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onPaste={handlePaste}
|
||||
dir='auto'
|
||||
aria-autocomplete='list'
|
||||
aria-label={placeholder}
|
||||
lang={lang}
|
||||
/>
|
||||
|
||||
return [
|
||||
<div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'>
|
||||
<div className='autosuggest-textarea'>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{placeholder}</span>
|
||||
<Overlay show={!(suggestionsHidden || suggestions.isEmpty())} offset={[0, 0]} placement='bottom' target={textareaRef} popperConfig={{ strategy: 'fixed' }}>
|
||||
{({ props }) => (
|
||||
<div {...props}>
|
||||
<div className='autosuggest-textarea__suggestions' style={{ width: textareaRef.current?.clientWidth }}>
|
||||
{suggestions.map(renderSuggestion)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
<Textarea
|
||||
ref={this.setTextarea}
|
||||
className='autosuggest-textarea__textarea'
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
onChange={this.onChange}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
onPaste={this.onPaste}
|
||||
dir='auto'
|
||||
aria-autocomplete='list'
|
||||
lang={lang}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{children}
|
||||
</div>,
|
||||
AutosuggestTextarea.propTypes = {
|
||||
value: PropTypes.string,
|
||||
suggestions: ImmutablePropTypes.list,
|
||||
disabled: PropTypes.bool,
|
||||
placeholder: PropTypes.string,
|
||||
onSuggestionSelected: PropTypes.func.isRequired,
|
||||
onSuggestionsClearRequested: PropTypes.func.isRequired,
|
||||
onSuggestionsFetchRequested: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onKeyUp: PropTypes.func,
|
||||
onKeyDown: PropTypes.func,
|
||||
onPaste: PropTypes.func.isRequired,
|
||||
onFocus:PropTypes.func,
|
||||
autoFocus: PropTypes.bool,
|
||||
lang: PropTypes.string,
|
||||
};
|
||||
|
||||
<div className='autosuggest-textarea__suggestions-wrapper' key='suggestions-wrapper'>
|
||||
<div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
|
||||
{suggestions.map(this.renderSuggestion)}
|
||||
</div>
|
||||
</div>,
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
export default AutosuggestTextarea;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import classNames from 'classnames';
|
||||
|
||||
import type { Account } from 'mastodon/models/account';
|
||||
|
||||
import { useHovering } from '../../hooks/useHovering';
|
||||
import type { Account } from '../../types/resources';
|
||||
import { autoPlayGif } from '../initial_state';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -10,6 +11,8 @@ interface Props {
|
|||
style?: React.CSSProperties;
|
||||
inline?: boolean;
|
||||
animate?: boolean;
|
||||
counter?: number | string;
|
||||
counterBorderColor?: string;
|
||||
}
|
||||
|
||||
export const Avatar: React.FC<Props> = ({
|
||||
|
|
@ -18,6 +21,8 @@ export const Avatar: React.FC<Props> = ({
|
|||
size = 20,
|
||||
inline = false,
|
||||
style: styleFromParent,
|
||||
counter,
|
||||
counterBorderColor,
|
||||
}) => {
|
||||
const { hovering, handleMouseEnter, handleMouseLeave } = useHovering(animate);
|
||||
|
||||
|
|
@ -41,7 +46,15 @@ export const Avatar: React.FC<Props> = ({
|
|||
onMouseLeave={handleMouseLeave}
|
||||
style={style}
|
||||
>
|
||||
{src && <img src={src} alt={account?.get('acct')} />}
|
||||
{src && <img src={src} alt='' />}
|
||||
{counter && (
|
||||
<div
|
||||
className='account__avatar__counter'
|
||||
style={{ borderColor: counterBorderColor }}
|
||||
>
|
||||
{counter}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Account } from 'mastodon/models/account';
|
||||
|
||||
import { useHovering } from '../../hooks/useHovering';
|
||||
import type { Account } from '../../types/resources';
|
||||
import { autoPlayGif } from '../initial_state';
|
||||
|
||||
interface Props {
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ import PropTypes from 'prop-types';
|
|||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { ReactComponent as GroupsIcon } from '@material-design-icons/svg/outlined/group.svg';
|
||||
import { ReactComponent as PersonIcon } from '@material-design-icons/svg/outlined/person.svg';
|
||||
import { ReactComponent as SmartToyIcon } from '@material-design-icons/svg/outlined/smart_toy.svg';
|
||||
import GroupsIcon from '@/material-icons/400-24px/group.svg?react';
|
||||
import PersonIcon from '@/material-icons/400-24px/person.svg?react';
|
||||
import SmartToyIcon from '@/material-icons/400-24px/smart_toy.svg?react';
|
||||
|
||||
|
||||
export const Badge = ({ icon, label, domain }) => (
|
||||
<div className='account-role'>
|
||||
export const Badge = ({ icon = <PersonIcon />, label, domain, roleId }) => (
|
||||
<div className='account-role' data-account-role-id={roleId}>
|
||||
{icon}
|
||||
{label}
|
||||
{domain && <span className='account-role__domain'>{domain}</span>}
|
||||
|
|
@ -19,10 +19,7 @@ Badge.propTypes = {
|
|||
icon: PropTypes.node,
|
||||
label: PropTypes.node,
|
||||
domain: PropTypes.node,
|
||||
};
|
||||
|
||||
Badge.defaultProps = {
|
||||
icon: <PersonIcon />,
|
||||
roleId: PropTypes.string
|
||||
};
|
||||
|
||||
export const GroupBadge = () => (
|
||||
|
|
@ -31,4 +28,4 @@ export const GroupBadge = () => (
|
|||
|
||||
export const AutomatedBadge = () => (
|
||||
<Badge icon={<SmartToyIcon />} label={<FormattedMessage id='account.badges.bot' defaultMessage='Automated' />} />
|
||||
);
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,58 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
export default class Button extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
text: PropTypes.node,
|
||||
type: PropTypes.string,
|
||||
onClick: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
block: PropTypes.bool,
|
||||
secondary: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
type: 'button',
|
||||
};
|
||||
|
||||
handleClick = (e) => {
|
||||
if (!this.props.disabled && this.props.onClick) {
|
||||
this.props.onClick(e);
|
||||
}
|
||||
};
|
||||
|
||||
setRef = (c) => {
|
||||
this.node = c;
|
||||
};
|
||||
|
||||
focus() {
|
||||
this.node.focus();
|
||||
}
|
||||
|
||||
render () {
|
||||
const className = classNames('button', this.props.className, {
|
||||
'button-secondary': this.props.secondary,
|
||||
'button--block': this.props.block,
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
className={className}
|
||||
disabled={this.props.disabled}
|
||||
onClick={this.handleClick}
|
||||
ref={this.setRef}
|
||||
title={this.props.title}
|
||||
type={this.props.type}
|
||||
>
|
||||
{this.props.text || this.props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
62
app/javascript/mastodon/components/button.tsx
Normal file
62
app/javascript/mastodon/components/button.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import type { PropsWithChildren } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface BaseProps
|
||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
||||
block?: boolean;
|
||||
secondary?: boolean;
|
||||
dangerous?: boolean;
|
||||
}
|
||||
|
||||
interface PropsChildren extends PropsWithChildren<BaseProps> {
|
||||
text?: undefined;
|
||||
}
|
||||
|
||||
interface PropsWithText extends BaseProps {
|
||||
text: JSX.Element | string;
|
||||
children?: undefined;
|
||||
}
|
||||
|
||||
type Props = PropsWithText | PropsChildren;
|
||||
|
||||
export const Button: React.FC<Props> = ({
|
||||
type = 'button',
|
||||
onClick,
|
||||
disabled,
|
||||
block,
|
||||
secondary,
|
||||
dangerous,
|
||||
className,
|
||||
title,
|
||||
text,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
const handleClick = useCallback<React.MouseEventHandler<HTMLButtonElement>>(
|
||||
(e) => {
|
||||
if (!disabled && onClick) {
|
||||
onClick(e);
|
||||
}
|
||||
},
|
||||
[disabled, onClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={classNames('button', className, {
|
||||
'button-secondary': secondary,
|
||||
'button--block': block,
|
||||
'button--dangerous': dangerous,
|
||||
})}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
title={title}
|
||||
type={type}
|
||||
{...props}
|
||||
>
|
||||
{text ?? children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
export const Check: React.FC = () => (
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 20 20'
|
||||
fill='currentColor'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
48
app/javascript/mastodon/components/check_box.tsx
Normal file
48
app/javascript/mastodon/components/check_box.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import classNames from 'classnames';
|
||||
|
||||
import CheckIndeterminateSmallIcon from '@/material-icons/400-24px/check_indeterminate_small.svg?react';
|
||||
import DoneIcon from '@/material-icons/400-24px/done.svg?react';
|
||||
|
||||
import { Icon } from './icon';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
checked: boolean;
|
||||
indeterminate: boolean;
|
||||
name: string;
|
||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
label: React.ReactNode;
|
||||
}
|
||||
|
||||
export const CheckBox: React.FC<Props> = ({
|
||||
name,
|
||||
value,
|
||||
checked,
|
||||
indeterminate,
|
||||
onChange,
|
||||
label,
|
||||
}) => {
|
||||
return (
|
||||
<label className='check-box'>
|
||||
<input
|
||||
name={name}
|
||||
type='checkbox'
|
||||
value={value}
|
||||
checked={checked}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
<span
|
||||
className={classNames('check-box__input', { checked, indeterminate })}
|
||||
>
|
||||
{indeterminate ? (
|
||||
<Icon id='indeterminate' icon={CheckIndeterminateSmallIcon} />
|
||||
) : (
|
||||
checked && <Icon id='check' icon={DoneIcon} />
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
|
@ -22,6 +22,12 @@ export default class Column extends PureComponent {
|
|||
scrollable = document.scrollingElement;
|
||||
} else {
|
||||
scrollable = this.node.querySelector('.scrollable');
|
||||
|
||||
// Some columns have nested `.scrollable` containers, with the outer one
|
||||
// being a wrapper while the actual scrollable content is deeper.
|
||||
if (scrollable.classList.contains('scrollable--flex')) {
|
||||
scrollable = scrollable?.querySelector('.scrollable') || scrollable;
|
||||
}
|
||||
}
|
||||
|
||||
if (!scrollable) {
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
export default class ColumnBackButton extends PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
multiColumn: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
const { router } = this.context;
|
||||
const { onClick } = this.props;
|
||||
|
||||
if (onClick) {
|
||||
onClick();
|
||||
} else if (router.history.location?.state?.fromMastodon) {
|
||||
router.history.goBack();
|
||||
} else {
|
||||
router.history.push('/');
|
||||
}
|
||||
};
|
||||
|
||||
render () {
|
||||
const { multiColumn } = this.props;
|
||||
|
||||
const component = (
|
||||
<button onClick={this.handleClick} className='column-back-button'>
|
||||
<Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
|
||||
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
|
||||
</button>
|
||||
);
|
||||
|
||||
if (multiColumn) {
|
||||
return component;
|
||||
} else {
|
||||
// The portal container and the component may be rendered to the DOM in
|
||||
// the same React render pass, so the container might not be available at
|
||||
// the time `render()` is called.
|
||||
const container = document.getElementById('tabs-bar__portal');
|
||||
if (container === null) {
|
||||
// The container wasn't available, force a re-render so that the
|
||||
// component can eventually be inserted in the container and not scroll
|
||||
// with the rest of the area.
|
||||
this.forceUpdate();
|
||||
return component;
|
||||
} else {
|
||||
return createPortal(component, container);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
44
app/javascript/mastodon/components/column_back_button.tsx
Normal file
44
app/javascript/mastodon/components/column_back_button.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
|
||||
|
||||
import { useAppHistory } from './router';
|
||||
|
||||
type OnClickCallback = () => void;
|
||||
|
||||
function useHandleClick(onClick?: OnClickCallback) {
|
||||
const history = useAppHistory();
|
||||
|
||||
return useCallback(() => {
|
||||
if (onClick) {
|
||||
onClick();
|
||||
} else if (history.location.state?.fromMastodon) {
|
||||
history.goBack();
|
||||
} else {
|
||||
history.push('/');
|
||||
}
|
||||
}, [history, onClick]);
|
||||
}
|
||||
|
||||
export const ColumnBackButton: React.FC<{ onClick: OnClickCallback }> = ({
|
||||
onClick,
|
||||
}) => {
|
||||
const handleClick = useHandleClick(onClick);
|
||||
|
||||
const component = (
|
||||
<button onClick={handleClick} className='column-back-button'>
|
||||
<Icon
|
||||
id='chevron-left'
|
||||
icon={ArrowBackIcon}
|
||||
className='column-back-button__icon'
|
||||
/>
|
||||
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
|
||||
</button>
|
||||
);
|
||||
|
||||
return <ButtonInTabsBar>{component}</ButtonInTabsBar>;
|
||||
};
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
import ColumnBackButton from './column_back_button';
|
||||
|
||||
export default class ColumnBackButtonSlim extends ColumnBackButton {
|
||||
|
||||
render () {
|
||||
return (
|
||||
<div className='column-back-button--slim'>
|
||||
<div role='button' tabIndex={0} onClick={this.handleClick} className='column-back-button column-back-button--slim-button'>
|
||||
<Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
|
||||
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,218 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
const messages = defineMessages({
|
||||
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
|
||||
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
|
||||
moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' },
|
||||
moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' },
|
||||
});
|
||||
|
||||
class ColumnHeader extends PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
identity: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
intl: PropTypes.object.isRequired,
|
||||
title: PropTypes.node,
|
||||
icon: PropTypes.string,
|
||||
active: PropTypes.bool,
|
||||
multiColumn: PropTypes.bool,
|
||||
extraButton: PropTypes.node,
|
||||
showBackButton: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
pinned: PropTypes.bool,
|
||||
placeholder: PropTypes.bool,
|
||||
onPin: PropTypes.func,
|
||||
onMove: PropTypes.func,
|
||||
onClick: PropTypes.func,
|
||||
appendContent: PropTypes.node,
|
||||
collapseIssues: PropTypes.bool,
|
||||
};
|
||||
|
||||
state = {
|
||||
collapsed: true,
|
||||
animating: false,
|
||||
};
|
||||
|
||||
handleToggleClick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.setState({ collapsed: !this.state.collapsed, animating: true });
|
||||
};
|
||||
|
||||
handleTitleClick = () => {
|
||||
this.props.onClick?.();
|
||||
};
|
||||
|
||||
handleMoveLeft = () => {
|
||||
this.props.onMove(-1);
|
||||
};
|
||||
|
||||
handleMoveRight = () => {
|
||||
this.props.onMove(1);
|
||||
};
|
||||
|
||||
handleBackClick = () => {
|
||||
const { router } = this.context;
|
||||
|
||||
if (router.history.location?.state?.fromMastodon) {
|
||||
router.history.goBack();
|
||||
} else {
|
||||
router.history.push('/');
|
||||
}
|
||||
};
|
||||
|
||||
handleTransitionEnd = () => {
|
||||
this.setState({ animating: false });
|
||||
};
|
||||
|
||||
handlePin = () => {
|
||||
if (!this.props.pinned) {
|
||||
this.context.router.history.replace('/');
|
||||
}
|
||||
|
||||
this.props.onPin();
|
||||
};
|
||||
|
||||
render () {
|
||||
const { router } = this.context;
|
||||
const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props;
|
||||
const { collapsed, animating } = this.state;
|
||||
|
||||
const wrapperClassName = classNames('column-header__wrapper', {
|
||||
'active': active,
|
||||
});
|
||||
|
||||
const buttonClassName = classNames('column-header', {
|
||||
'active': active,
|
||||
});
|
||||
|
||||
const collapsibleClassName = classNames('column-header__collapsible', {
|
||||
'collapsed': collapsed,
|
||||
'animating': animating,
|
||||
});
|
||||
|
||||
const collapsibleButtonClassName = classNames('column-header__button', {
|
||||
'active': !collapsed,
|
||||
});
|
||||
|
||||
let extraContent, pinButton, moveButtons, backButton, collapseButton;
|
||||
|
||||
if (children) {
|
||||
extraContent = (
|
||||
<div key='extra-content' className='column-header__collapsible__extra'>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (multiColumn && pinned) {
|
||||
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='times' /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>;
|
||||
|
||||
moveButtons = (
|
||||
<div key='move-buttons' className='column-header__setting-arrows'>
|
||||
<button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='icon-button column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' /></button>
|
||||
<button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='icon-button column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' /></button>
|
||||
</div>
|
||||
);
|
||||
} else if (multiColumn && this.props.onPin) {
|
||||
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>;
|
||||
}
|
||||
|
||||
if (!pinned && ((multiColumn && router.history.location?.state?.fromMastodon) || showBackButton)) {
|
||||
backButton = (
|
||||
<button onClick={this.handleBackClick} className='column-header__back-button'>
|
||||
<Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
|
||||
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const collapsedContent = [
|
||||
extraContent,
|
||||
];
|
||||
|
||||
if (multiColumn) {
|
||||
collapsedContent.push(pinButton);
|
||||
collapsedContent.push(moveButtons);
|
||||
}
|
||||
|
||||
if (this.context.identity.signedIn && (children || (multiColumn && this.props.onPin))) {
|
||||
collapseButton = (
|
||||
<button
|
||||
className={collapsibleButtonClassName}
|
||||
title={formatMessage(collapsed ? messages.show : messages.hide)}
|
||||
aria-label={formatMessage(collapsed ? messages.show : messages.hide)}
|
||||
onClick={this.handleToggleClick}
|
||||
>
|
||||
<i className='icon-with-badge'>
|
||||
<Icon id='sliders' />
|
||||
{collapseIssues && <i className='icon-with-badge__issue-badge' />}
|
||||
</i>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const hasTitle = icon && title;
|
||||
|
||||
const component = (
|
||||
<div className={wrapperClassName}>
|
||||
<h1 className={buttonClassName}>
|
||||
{hasTitle && (
|
||||
<button onClick={this.handleTitleClick}>
|
||||
<Icon id={icon} fixedWidth className='column-header__icon' />
|
||||
{title}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!hasTitle && backButton}
|
||||
|
||||
<div className='column-header__buttons'>
|
||||
{hasTitle && backButton}
|
||||
{extraButton}
|
||||
{collapseButton}
|
||||
</div>
|
||||
</h1>
|
||||
|
||||
<div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}>
|
||||
<div className='column-header__collapsible-inner'>
|
||||
{(!collapsed || animating) && collapsedContent}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{appendContent}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (multiColumn || placeholder) {
|
||||
return component;
|
||||
} else {
|
||||
// The portal container and the component may be rendered to the DOM in
|
||||
// the same React render pass, so the container might not be available at
|
||||
// the time `render()` is called.
|
||||
const container = document.getElementById('tabs-bar__portal');
|
||||
if (container === null) {
|
||||
// The container wasn't available, force a re-render so that the
|
||||
// component can eventually be inserted in the container and not scroll
|
||||
// with the rest of the area.
|
||||
this.forceUpdate();
|
||||
return component;
|
||||
} else {
|
||||
return createPortal(component, container);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(ColumnHeader);
|
||||
301
app/javascript/mastodon/components/column_header.tsx
Normal file
301
app/javascript/mastodon/components/column_header.tsx
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||
import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
|
||||
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
|
||||
import type { IconProp } from 'mastodon/components/icon';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
|
||||
import { useAppHistory } from './router';
|
||||
|
||||
const messages = defineMessages({
|
||||
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
|
||||
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
|
||||
moveLeft: {
|
||||
id: 'column_header.moveLeft_settings',
|
||||
defaultMessage: 'Move column to the left',
|
||||
},
|
||||
moveRight: {
|
||||
id: 'column_header.moveRight_settings',
|
||||
defaultMessage: 'Move column to the right',
|
||||
},
|
||||
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
|
||||
});
|
||||
|
||||
const BackButton: React.FC<{
|
||||
onlyIcon: boolean;
|
||||
}> = ({ onlyIcon }) => {
|
||||
const history = useAppHistory();
|
||||
const intl = useIntl();
|
||||
|
||||
const handleBackClick = useCallback(() => {
|
||||
if (history.location.state?.fromMastodon) {
|
||||
history.goBack();
|
||||
} else {
|
||||
history.push('/');
|
||||
}
|
||||
}, [history]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleBackClick}
|
||||
className={classNames('column-header__back-button', {
|
||||
compact: onlyIcon,
|
||||
})}
|
||||
aria-label={intl.formatMessage(messages.back)}
|
||||
>
|
||||
<Icon
|
||||
id='chevron-left'
|
||||
icon={ArrowBackIcon}
|
||||
className='column-back-button__icon'
|
||||
/>
|
||||
{!onlyIcon && (
|
||||
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export interface Props {
|
||||
title?: string;
|
||||
icon?: string;
|
||||
iconComponent?: IconProp;
|
||||
active?: boolean;
|
||||
children?: React.ReactNode;
|
||||
pinned?: boolean;
|
||||
multiColumn?: boolean;
|
||||
extraButton?: React.ReactNode;
|
||||
showBackButton?: boolean;
|
||||
placeholder?: boolean;
|
||||
appendContent?: React.ReactNode;
|
||||
collapseIssues?: boolean;
|
||||
onClick?: () => void;
|
||||
onMove?: (arg0: number) => void;
|
||||
onPin?: () => void;
|
||||
}
|
||||
|
||||
export const ColumnHeader: React.FC<Props> = ({
|
||||
title,
|
||||
icon,
|
||||
iconComponent,
|
||||
active,
|
||||
children,
|
||||
pinned,
|
||||
multiColumn,
|
||||
extraButton,
|
||||
showBackButton,
|
||||
placeholder,
|
||||
appendContent,
|
||||
collapseIssues,
|
||||
onClick,
|
||||
onMove,
|
||||
onPin,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { signedIn } = useIdentity();
|
||||
const history = useAppHistory();
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const [animating, setAnimating] = useState(false);
|
||||
|
||||
const handleToggleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setCollapsed((value) => !value);
|
||||
setAnimating(true);
|
||||
},
|
||||
[setCollapsed, setAnimating],
|
||||
);
|
||||
|
||||
const handleTitleClick = useCallback(() => {
|
||||
onClick?.();
|
||||
}, [onClick]);
|
||||
|
||||
const handleMoveLeft = useCallback(() => {
|
||||
onMove?.(-1);
|
||||
}, [onMove]);
|
||||
|
||||
const handleMoveRight = useCallback(() => {
|
||||
onMove?.(1);
|
||||
}, [onMove]);
|
||||
|
||||
const handleTransitionEnd = useCallback(() => {
|
||||
setAnimating(false);
|
||||
}, [setAnimating]);
|
||||
|
||||
const handlePin = useCallback(() => {
|
||||
if (!pinned) {
|
||||
history.replace('/');
|
||||
}
|
||||
|
||||
onPin?.();
|
||||
}, [history, pinned, onPin]);
|
||||
|
||||
const wrapperClassName = classNames('column-header__wrapper', {
|
||||
active,
|
||||
});
|
||||
|
||||
const buttonClassName = classNames('column-header', {
|
||||
active,
|
||||
});
|
||||
|
||||
const collapsibleClassName = classNames('column-header__collapsible', {
|
||||
collapsed,
|
||||
animating,
|
||||
});
|
||||
|
||||
const collapsibleButtonClassName = classNames('column-header__button', {
|
||||
active: !collapsed,
|
||||
});
|
||||
|
||||
let extraContent, pinButton, moveButtons, backButton, collapseButton;
|
||||
|
||||
if (children) {
|
||||
extraContent = (
|
||||
<div key='extra-content' className='column-header__collapsible__extra'>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (multiColumn && pinned) {
|
||||
pinButton = (
|
||||
<button
|
||||
className='text-btn column-header__setting-btn'
|
||||
onClick={handlePin}
|
||||
>
|
||||
<Icon id='times' icon={CloseIcon} />{' '}
|
||||
<FormattedMessage id='column_header.unpin' defaultMessage='Unpin' />
|
||||
</button>
|
||||
);
|
||||
|
||||
moveButtons = (
|
||||
<div className='column-header__setting-arrows'>
|
||||
<button
|
||||
title={intl.formatMessage(messages.moveLeft)}
|
||||
aria-label={intl.formatMessage(messages.moveLeft)}
|
||||
className='icon-button column-header__setting-btn'
|
||||
onClick={handleMoveLeft}
|
||||
>
|
||||
<Icon id='chevron-left' icon={ChevronLeftIcon} />
|
||||
</button>
|
||||
<button
|
||||
title={intl.formatMessage(messages.moveRight)}
|
||||
aria-label={intl.formatMessage(messages.moveRight)}
|
||||
className='icon-button column-header__setting-btn'
|
||||
onClick={handleMoveRight}
|
||||
>
|
||||
<Icon id='chevron-right' icon={ChevronRightIcon} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else if (multiColumn && onPin) {
|
||||
pinButton = (
|
||||
<button
|
||||
className='text-btn column-header__setting-btn'
|
||||
onClick={handlePin}
|
||||
>
|
||||
<Icon id='plus' icon={AddIcon} />{' '}
|
||||
<FormattedMessage id='column_header.pin' defaultMessage='Pin' />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!pinned &&
|
||||
((multiColumn && history.location.state?.fromMastodon) || showBackButton)
|
||||
) {
|
||||
backButton = <BackButton onlyIcon={!!title} />;
|
||||
}
|
||||
|
||||
const collapsedContent = [extraContent];
|
||||
|
||||
if (multiColumn) {
|
||||
collapsedContent.push(
|
||||
<div key='buttons' className='column-header__advanced-buttons'>
|
||||
{pinButton}
|
||||
{moveButtons}
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
if (signedIn && (children || (multiColumn && onPin))) {
|
||||
collapseButton = (
|
||||
<button
|
||||
className={collapsibleButtonClassName}
|
||||
title={intl.formatMessage(collapsed ? messages.show : messages.hide)}
|
||||
aria-label={intl.formatMessage(
|
||||
collapsed ? messages.show : messages.hide,
|
||||
)}
|
||||
onClick={handleToggleClick}
|
||||
>
|
||||
<i className='icon-with-badge'>
|
||||
<Icon id='sliders' icon={SettingsIcon} />
|
||||
{collapseIssues && <i className='icon-with-badge__issue-badge' />}
|
||||
</i>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const hasIcon = icon && iconComponent;
|
||||
const hasTitle = hasIcon && title;
|
||||
|
||||
const component = (
|
||||
<div className={wrapperClassName}>
|
||||
<h1 className={buttonClassName}>
|
||||
{hasTitle && (
|
||||
<>
|
||||
{backButton}
|
||||
|
||||
<button onClick={handleTitleClick} className='column-header__title'>
|
||||
{!backButton && (
|
||||
<Icon
|
||||
id={icon}
|
||||
icon={iconComponent}
|
||||
className='column-header__icon'
|
||||
/>
|
||||
)}
|
||||
{title}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!hasTitle && backButton}
|
||||
|
||||
<div className='column-header__buttons'>
|
||||
{extraButton}
|
||||
{collapseButton}
|
||||
</div>
|
||||
</h1>
|
||||
|
||||
<div
|
||||
className={collapsibleClassName}
|
||||
tabIndex={collapsed ? -1 : undefined}
|
||||
onTransitionEnd={handleTransitionEnd}
|
||||
>
|
||||
<div className='column-header__collapsible-inner'>
|
||||
{(!collapsed || animating) && collapsedContent}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{appendContent}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (placeholder) {
|
||||
return component;
|
||||
} else {
|
||||
return <ButtonInTabsBar>{component}</ButtonInTabsBar>;
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default ColumnHeader;
|
||||
15
app/javascript/mastodon/components/content_warning.tsx
Normal file
15
app/javascript/mastodon/components/content_warning.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { StatusBanner, BannerVariant } from './status_banner';
|
||||
|
||||
export const ContentWarning: React.FC<{
|
||||
text: string;
|
||||
expanded?: boolean;
|
||||
onClick?: () => void;
|
||||
}> = ({ text, expanded, onClick }) => (
|
||||
<StatusBanner
|
||||
expanded={expanded}
|
||||
onClick={onClick}
|
||||
variant={BannerVariant.Yellow}
|
||||
>
|
||||
<p dangerouslySetInnerHTML={{ __html: text }} />
|
||||
</StatusBanner>
|
||||
);
|
||||
43
app/javascript/mastodon/components/copy_icon_button.jsx
Normal file
43
app/javascript/mastodon/components/copy_icon_button.jsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
import { defineMessages } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
|
||||
import { showAlert } from 'mastodon/actions/alerts';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
copied: { id: 'copy_icon_button.copied', defaultMessage: 'Copied to clipboard' },
|
||||
});
|
||||
|
||||
export const CopyIconButton = ({ title, value, className }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
dispatch(showAlert({ message: messages.copied }));
|
||||
setTimeout(() => setCopied(false), 700);
|
||||
}, [setCopied, value, dispatch]);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
className={classNames(className, copied ? 'copied' : 'copyable')}
|
||||
title={title}
|
||||
onClick={handleClick}
|
||||
iconComponent={ContentCopyIcon}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
CopyIconButton.propTypes = {
|
||||
title: PropTypes.string,
|
||||
value: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
90
app/javascript/mastodon/components/copy_paste_text.tsx
Normal file
90
app/javascript/mastodon/components/copy_paste_text.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { useRef, useState, useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
|
||||
import { useTimeout } from 'mastodon/../hooks/useTimeout';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
export const CopyPasteText: React.FC<{ value: string }> = ({ value }) => {
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [setAnimationTimeout] = useTimeout();
|
||||
|
||||
const handleInputClick = useCallback(() => {
|
||||
setCopied(false);
|
||||
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
inputRef.current.setSelectionRange(0, value.length);
|
||||
}
|
||||
}, [setCopied, value]);
|
||||
|
||||
const handleButtonClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
void navigator.clipboard.writeText(value);
|
||||
inputRef.current?.blur();
|
||||
setCopied(true);
|
||||
setAnimationTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 700);
|
||||
},
|
||||
[setCopied, setAnimationTimeout, value],
|
||||
);
|
||||
|
||||
const handleKeyUp = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key !== ' ') return;
|
||||
void navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setAnimationTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 700);
|
||||
},
|
||||
[setCopied, setAnimationTimeout, value],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
setFocused(true);
|
||||
}, [setFocused]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setFocused(false);
|
||||
}, [setFocused]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('copy-paste-text', { copied, focused })}
|
||||
tabIndex={0}
|
||||
role='button'
|
||||
onClick={handleInputClick}
|
||||
onKeyUp={handleKeyUp}
|
||||
>
|
||||
<textarea
|
||||
readOnly
|
||||
value={value}
|
||||
ref={inputRef}
|
||||
onClick={handleInputClick}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
|
||||
<button className='button' onClick={handleButtonClick}>
|
||||
<Icon id='copy' icon={ContentCopyIcon} />{' '}
|
||||
{copied ? (
|
||||
<FormattedMessage id='copypaste.copied' defaultMessage='Copied' />
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='copypaste.copy_to_clipboard'
|
||||
defaultMessage='Copy to clipboard'
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -8,6 +8,7 @@ import { useCallback, useState, useEffect } from 'react';
|
|||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { changeSetting } from 'mastodon/actions/settings';
|
||||
import { bannerSettings } from 'mastodon/settings';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
|
@ -55,6 +56,7 @@ export const DismissableBanner: React.FC<PropsWithChildren<Props>> = ({
|
|||
<div className='dismissable-banner__action'>
|
||||
<IconButton
|
||||
icon='times'
|
||||
iconComponent={CloseIcon}
|
||||
title={intl.formatMessage(messages.dismiss)}
|
||||
onClick={handleDismiss}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import React from 'react';
|
|||
|
||||
import type { List } from 'immutable';
|
||||
|
||||
import type { Account } from '../../types/resources';
|
||||
import type { Account } from 'mastodon/models/account';
|
||||
|
||||
import { autoPlayGif } from '../initial_state';
|
||||
|
||||
import { Skeleton } from './skeleton';
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import { useCallback } from 'react';
|
|||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import LockOpenIcon from '@/material-icons/400-24px/lock_open.svg?react';
|
||||
import { unblockDomain } from 'mastodon/actions/domain_blocks';
|
||||
import { useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { IconButton } from './icon_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
|
|
@ -11,17 +15,15 @@ const messages = defineMessages({
|
|||
},
|
||||
});
|
||||
|
||||
interface Props {
|
||||
export const Domain: React.FC<{
|
||||
domain: string;
|
||||
onUnblockDomain: (domain: string) => void;
|
||||
}
|
||||
|
||||
export const Domain: React.FC<Props> = ({ domain, onUnblockDomain }) => {
|
||||
}> = ({ domain }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleDomainUnblock = useCallback(() => {
|
||||
onUnblockDomain(domain);
|
||||
}, [domain, onUnblockDomain]);
|
||||
dispatch(unblockDomain(domain));
|
||||
}, [dispatch, domain]);
|
||||
|
||||
return (
|
||||
<div className='domain'>
|
||||
|
|
@ -34,6 +36,7 @@ export const Domain: React.FC<Props> = ({ domain, onUnblockDomain }) => {
|
|||
<IconButton
|
||||
active
|
||||
icon='unlock'
|
||||
iconComponent={LockOpenIcon}
|
||||
title={intl.formatMessage(messages.unblockDomain, { domain })}
|
||||
onClick={handleDomainUnblock}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -2,13 +2,16 @@ import PropTypes from 'prop-types';
|
|||
import { PureComponent, cloneElement, Children } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
import Overlay from 'react-overlays/Overlay';
|
||||
|
||||
import { CircularProgress } from "./circular_progress";
|
||||
import { CircularProgress } from 'mastodon/components/circular_progress';
|
||||
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
||||
|
||||
import { IconButton } from './icon_button';
|
||||
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
|
||||
|
|
@ -16,12 +19,8 @@ let id = 0;
|
|||
|
||||
class DropdownMenu extends PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired,
|
||||
items: PropTypes.array.isRequired,
|
||||
loading: PropTypes.bool,
|
||||
scrollable: PropTypes.bool,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
|
|
@ -160,16 +159,13 @@ class DropdownMenu extends PureComponent {
|
|||
|
||||
}
|
||||
|
||||
export default class Dropdown extends PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
class Dropdown extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
icon: PropTypes.string,
|
||||
items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired,
|
||||
iconComponent: PropTypes.func,
|
||||
items: PropTypes.array.isRequired,
|
||||
loading: PropTypes.bool,
|
||||
size: PropTypes.number,
|
||||
title: PropTypes.string,
|
||||
|
|
@ -184,6 +180,7 @@ export default class Dropdown extends PureComponent {
|
|||
renderItem: PropTypes.func,
|
||||
renderHeader: PropTypes.func,
|
||||
onItemClick: PropTypes.func,
|
||||
...WithRouterPropTypes
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
|
|
@ -251,7 +248,7 @@ export default class Dropdown extends PureComponent {
|
|||
item.action();
|
||||
} else if (item && item.to) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(item.to);
|
||||
this.props.history.push(item.to);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -260,7 +257,7 @@ export default class Dropdown extends PureComponent {
|
|||
};
|
||||
|
||||
findTarget = () => {
|
||||
return this.target;
|
||||
return this.target?.buttonRef?.current ?? this.target;
|
||||
};
|
||||
|
||||
componentWillUnmount = () => {
|
||||
|
|
@ -276,6 +273,7 @@ export default class Dropdown extends PureComponent {
|
|||
render () {
|
||||
const {
|
||||
icon,
|
||||
iconComponent,
|
||||
items,
|
||||
size,
|
||||
title,
|
||||
|
|
@ -296,9 +294,11 @@ export default class Dropdown extends PureComponent {
|
|||
onMouseDown: this.handleMouseDown,
|
||||
onKeyDown: this.handleButtonKeyDown,
|
||||
onKeyPress: this.handleKeyPress,
|
||||
ref: this.setTargetRef,
|
||||
}) : (
|
||||
<IconButton
|
||||
icon={!open ? icon : 'close'}
|
||||
iconComponent={iconComponent}
|
||||
title={title}
|
||||
active={open}
|
||||
disabled={disabled}
|
||||
|
|
@ -307,14 +307,14 @@ export default class Dropdown extends PureComponent {
|
|||
onMouseDown={this.handleMouseDown}
|
||||
onKeyDown={this.handleButtonKeyDown}
|
||||
onKeyPress={this.handleKeyPress}
|
||||
ref={this.setTargetRef}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<span ref={this.setTargetRef}>
|
||||
{button}
|
||||
</span>
|
||||
{button}
|
||||
|
||||
<Overlay show={open} offset={[5, 5]} placement={'bottom'} flip target={this.findTarget} popperConfig={{ strategy: 'fixed' }}>
|
||||
{({ props, arrowProps, placement }) => (
|
||||
<div {...props}>
|
||||
|
|
@ -339,3 +339,5 @@ export default class Dropdown extends PureComponent {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
export default withRouter(Dropdown);
|
||||
|
|
|
|||
185
app/javascript/mastodon/components/dropdown_selector.tsx
Normal file
185
app/javascript/mastodon/components/dropdown_selector.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||
|
||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||
|
||||
import type { IconProp } from './icon';
|
||||
import { Icon } from './icon';
|
||||
|
||||
const listenerOptions = supportsPassiveEvents
|
||||
? { passive: true, capture: true }
|
||||
: true;
|
||||
|
||||
export interface SelectItem {
|
||||
value: string;
|
||||
icon?: string;
|
||||
iconComponent?: IconProp;
|
||||
text: string;
|
||||
meta: string;
|
||||
extra?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
classNamePrefix: string;
|
||||
style?: React.CSSProperties;
|
||||
items: SelectItem[];
|
||||
onChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const DropdownSelector: React.FC<Props> = ({
|
||||
style,
|
||||
items,
|
||||
value,
|
||||
classNamePrefix = 'privacy-dropdown',
|
||||
onClose,
|
||||
onChange,
|
||||
}) => {
|
||||
const nodeRef = useRef<HTMLUListElement>(null);
|
||||
const focusedItemRef = useRef<HTMLLIElement>(null);
|
||||
const [currentValue, setCurrentValue] = useState(value);
|
||||
|
||||
const handleDocumentClick = useCallback(
|
||||
(e: MouseEvent | TouchEvent) => {
|
||||
if (
|
||||
nodeRef.current &&
|
||||
e.target instanceof Node &&
|
||||
!nodeRef.current.contains(e.target)
|
||||
) {
|
||||
onClose();
|
||||
e.stopPropagation();
|
||||
}
|
||||
},
|
||||
[nodeRef, onClose],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(
|
||||
e: React.MouseEvent<HTMLLIElement> | React.KeyboardEvent<HTMLLIElement>,
|
||||
) => {
|
||||
const value = e.currentTarget.getAttribute('data-index');
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
onClose();
|
||||
if (value) onChange(value);
|
||||
},
|
||||
[onClose, onChange],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLLIElement>) => {
|
||||
const value = e.currentTarget.getAttribute('data-index');
|
||||
const index = items.findIndex((item) => item.value === value);
|
||||
|
||||
let element: Element | null | undefined = null;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
case ' ':
|
||||
case 'Enter':
|
||||
handleClick(e);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
element =
|
||||
nodeRef.current?.children[index + 1] ??
|
||||
nodeRef.current?.firstElementChild;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
element =
|
||||
nodeRef.current?.children[index - 1] ??
|
||||
nodeRef.current?.lastElementChild;
|
||||
break;
|
||||
case 'Tab':
|
||||
if (e.shiftKey) {
|
||||
element =
|
||||
nodeRef.current?.children[index + 1] ??
|
||||
nodeRef.current?.firstElementChild;
|
||||
} else {
|
||||
element =
|
||||
nodeRef.current?.children[index - 1] ??
|
||||
nodeRef.current?.lastElementChild;
|
||||
}
|
||||
break;
|
||||
case 'Home':
|
||||
element = nodeRef.current?.firstElementChild;
|
||||
break;
|
||||
case 'End':
|
||||
element = nodeRef.current?.lastElementChild;
|
||||
break;
|
||||
}
|
||||
|
||||
if (element && element instanceof HTMLElement) {
|
||||
const selectedValue = element.getAttribute('data-index');
|
||||
element.focus();
|
||||
if (selectedValue) setCurrentValue(selectedValue);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
},
|
||||
[nodeRef, items, onClose, handleClick, setCurrentValue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('click', handleDocumentClick, { capture: true });
|
||||
document.addEventListener('touchend', handleDocumentClick, listenerOptions);
|
||||
focusedItemRef.current?.focus({ preventScroll: true });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleDocumentClick, {
|
||||
capture: true,
|
||||
});
|
||||
document.removeEventListener(
|
||||
'touchend',
|
||||
handleDocumentClick,
|
||||
listenerOptions,
|
||||
);
|
||||
};
|
||||
}, [handleDocumentClick]);
|
||||
|
||||
return (
|
||||
<ul style={style} role='listbox' ref={nodeRef}>
|
||||
{items.map((item) => (
|
||||
<li
|
||||
role='option'
|
||||
tabIndex={0}
|
||||
key={item.value}
|
||||
data-index={item.value}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={handleClick}
|
||||
className={classNames(`${classNamePrefix}__option`, {
|
||||
active: item.value === currentValue,
|
||||
})}
|
||||
aria-selected={item.value === currentValue}
|
||||
ref={item.value === currentValue ? focusedItemRef : null}
|
||||
>
|
||||
{item.icon && item.iconComponent && (
|
||||
<div className={`${classNamePrefix}__option__icon`}>
|
||||
<Icon id={item.icon} icon={item.iconComponent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`${classNamePrefix}__option__content`}>
|
||||
<strong>{item.text}</strong>
|
||||
{item.meta}
|
||||
</div>
|
||||
|
||||
{item.extra && (
|
||||
<div
|
||||
className={`${classNamePrefix}__option__additional`}
|
||||
title={item.extra}
|
||||
>
|
||||
<Icon id='info-circle' icon={InfoIcon} />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
|
@ -4,9 +4,14 @@ import { openDropdownMenu, closeDropdownMenu } from 'mastodon/actions/dropdown_m
|
|||
import { fetchHistory } from 'mastodon/actions/history';
|
||||
import DropdownMenu from 'mastodon/components/dropdown_menu';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('mastodon/store').RootState} state
|
||||
* @param {*} props
|
||||
*/
|
||||
const mapStateToProps = (state, { statusId }) => ({
|
||||
openDropdownId: state.getIn(['dropdown_menu', 'openId']),
|
||||
openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']),
|
||||
openDropdownId: state.dropdownMenu.openId,
|
||||
openedViaKeyboard: state.dropdownMenu.keyboard,
|
||||
items: state.getIn(['history', statusId, 'items']),
|
||||
loading: state.getIn(['history', statusId, 'loading']),
|
||||
});
|
||||
|
|
@ -15,11 +20,11 @@ const mapDispatchToProps = (dispatch, { statusId }) => ({
|
|||
|
||||
onOpen (id, onItemClick, keyboard) {
|
||||
dispatch(fetchHistory(statusId));
|
||||
dispatch(openDropdownMenu(id, keyboard));
|
||||
dispatch(openDropdownMenu({ id, keyboard }));
|
||||
},
|
||||
|
||||
onClose (id) {
|
||||
dispatch(closeDropdownMenu(id));
|
||||
dispatch(closeDropdownMenu({ id }));
|
||||
},
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { FormattedMessage, injectIntl } from 'react-intl';
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import InlineAccount from 'mastodon/components/inline_account';
|
||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||
|
||||
|
|
@ -66,7 +65,7 @@ class EditedTimestamp extends PureComponent {
|
|||
return (
|
||||
<DropdownMenu statusId={statusId} renderItem={this.renderItem} scrollable renderHeader={this.renderHeader} onItemClick={this.handleItemClick}>
|
||||
<button className='dropdown-menu__text-button'>
|
||||
<FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: intl.formatDate(timestamp, { hour12: false, month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }} /> <Icon id='caret-down' />
|
||||
<FormattedMessage id='status.edited' defaultMessage='Edited {date}' values={{ date: <span className='animated-number'>{intl.formatDate(timestamp, { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' })}</span> }} />
|
||||
</button>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -60,8 +60,8 @@ export default class ErrorBoundary extends PureComponent {
|
|||
try {
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
} catch (e) {
|
||||
|
||||
} catch {
|
||||
// do nothing
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
|
|
|||
23
app/javascript/mastodon/components/filter_warning.tsx
Normal file
23
app/javascript/mastodon/components/filter_warning.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { StatusBanner, BannerVariant } from './status_banner';
|
||||
|
||||
export const FilterWarning: React.FC<{
|
||||
title: string;
|
||||
expanded?: boolean;
|
||||
onClick?: () => void;
|
||||
}> = ({ title, expanded, onClick }) => (
|
||||
<StatusBanner
|
||||
expanded={expanded}
|
||||
onClick={onClick}
|
||||
variant={BannerVariant.Blue}
|
||||
>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='filter_warning.matches_filter'
|
||||
defaultMessage='Matches filter “{title}”'
|
||||
values={{ title }}
|
||||
/>
|
||||
</p>
|
||||
</StatusBanner>
|
||||
);
|
||||
109
app/javascript/mastodon/components/follow_button.tsx
Normal file
109
app/javascript/mastodon/components/follow_button.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { useIdentity } from '@/mastodon/identity_context';
|
||||
import { fetchRelationships, followAccount } from 'mastodon/actions/accounts';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { Button } from 'mastodon/components/button';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { me } from 'mastodon/initial_state';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||
followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' },
|
||||
mutual: { id: 'account.mutual', defaultMessage: 'Mutual' },
|
||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||
});
|
||||
|
||||
export const FollowButton: React.FC<{
|
||||
accountId?: string;
|
||||
}> = ({ accountId }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const { signedIn } = useIdentity();
|
||||
const account = useAppSelector((state) =>
|
||||
accountId ? state.accounts.get(accountId) : undefined,
|
||||
);
|
||||
const relationship = useAppSelector((state) =>
|
||||
accountId ? state.relationships.get(accountId) : undefined,
|
||||
);
|
||||
const following = relationship?.following || relationship?.requested;
|
||||
|
||||
useEffect(() => {
|
||||
if (accountId && signedIn) {
|
||||
dispatch(fetchRelationships([accountId]));
|
||||
}
|
||||
}, [dispatch, accountId, signedIn]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!signedIn) {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'INTERACTION',
|
||||
modalProps: {
|
||||
type: 'follow',
|
||||
accountId: accountId,
|
||||
url: account?.url,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!relationship) return;
|
||||
|
||||
if (accountId === me) {
|
||||
return;
|
||||
} else if (account && (relationship.following || relationship.requested)) {
|
||||
dispatch(
|
||||
openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }),
|
||||
);
|
||||
} else {
|
||||
dispatch(followAccount(accountId));
|
||||
}
|
||||
}, [dispatch, accountId, relationship, account, signedIn]);
|
||||
|
||||
let label;
|
||||
|
||||
if (!signedIn) {
|
||||
label = intl.formatMessage(messages.follow);
|
||||
} else if (accountId === me) {
|
||||
label = intl.formatMessage(messages.edit_profile);
|
||||
} else if (!relationship) {
|
||||
label = <LoadingIndicator />;
|
||||
} else if (relationship.following && relationship.followed_by) {
|
||||
label = intl.formatMessage(messages.mutual);
|
||||
} else if (relationship.following || relationship.requested) {
|
||||
label = intl.formatMessage(messages.unfollow);
|
||||
} else if (relationship.followed_by) {
|
||||
label = intl.formatMessage(messages.followBack);
|
||||
} else {
|
||||
label = intl.formatMessage(messages.follow);
|
||||
}
|
||||
|
||||
if (accountId === me) {
|
||||
return (
|
||||
<a
|
||||
href='/settings/profile'
|
||||
target='_blank'
|
||||
rel='noreferrer noopener'
|
||||
className='button button-secondary'
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
disabled={relationship?.blocked_by || relationship?.blocking}
|
||||
secondary={following}
|
||||
className={following ? 'button--destructive' : undefined}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
// @ts-check
|
||||
import PropTypes from 'prop-types';
|
||||
import { Component } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
import { Sparklines, SparklinesCurve } from 'react-sparklines';
|
||||
|
||||
import { ShortNumber } from 'mastodon/components/short_number';
|
||||
import { Skeleton } from 'mastodon/components/skeleton';
|
||||
|
||||
class SilentErrorBoundary extends Component {
|
||||
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
state = {
|
||||
error: false,
|
||||
};
|
||||
|
||||
componentDidCatch() {
|
||||
this.setState({ error: true });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to render counter of how much people are talking about hashtag
|
||||
* @type {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
|
||||
*/
|
||||
export const accountsCountRenderer = (displayNumber, pluralReady) => (
|
||||
<FormattedMessage
|
||||
id='trends.counter_by_accounts'
|
||||
defaultMessage='{count, plural, one {{counter} person} other {{counter} people}} in the past {days, plural, one {day} other {# days}}'
|
||||
values={{
|
||||
count: pluralReady,
|
||||
counter: <strong>{displayNumber}</strong>,
|
||||
days: 2,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
export const ImmutableHashtag = ({ hashtag }) => (
|
||||
<Hashtag
|
||||
name={hashtag.get('name')}
|
||||
to={`/tags/${hashtag.get('name')}`}
|
||||
people={hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1}
|
||||
// @ts-expect-error
|
||||
history={hashtag.get('history').reverse().map((day) => day.get('uses')).toArray()}
|
||||
/>
|
||||
);
|
||||
|
||||
ImmutableHashtag.propTypes = {
|
||||
hashtag: ImmutablePropTypes.map.isRequired,
|
||||
};
|
||||
|
||||
// @ts-expect-error
|
||||
const Hashtag = ({ name, to, people, uses, history, className, description, withGraph }) => (
|
||||
<div className={classNames('trends__item', className)}>
|
||||
<div className='trends__item__name'>
|
||||
<Link to={to}>
|
||||
{name ? <>#<span>{name}</span></> : <Skeleton width={50} />}
|
||||
</Link>
|
||||
|
||||
{description ? (
|
||||
<span>{description}</span>
|
||||
) : (
|
||||
typeof people !== 'undefined' ? <ShortNumber value={people} renderer={accountsCountRenderer} /> : <Skeleton width={100} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{typeof uses !== 'undefined' && (
|
||||
<div className='trends__item__current'>
|
||||
<ShortNumber value={uses} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{withGraph && (
|
||||
<div className='trends__item__sparkline'>
|
||||
<SilentErrorBoundary>
|
||||
<Sparklines width={50} height={28} data={history ? history : Array.from(Array(7)).map(() => 0)}>
|
||||
<SparklinesCurve style={{ fill: 'none' }} />
|
||||
</Sparklines>
|
||||
</SilentErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Hashtag.propTypes = {
|
||||
name: PropTypes.string,
|
||||
to: PropTypes.string,
|
||||
people: PropTypes.number,
|
||||
description: PropTypes.node,
|
||||
uses: PropTypes.number,
|
||||
history: PropTypes.arrayOf(PropTypes.number),
|
||||
className: PropTypes.string,
|
||||
withGraph: PropTypes.bool,
|
||||
};
|
||||
|
||||
Hashtag.defaultProps = {
|
||||
withGraph: true,
|
||||
};
|
||||
|
||||
export default Hashtag;
|
||||
145
app/javascript/mastodon/components/hashtag.tsx
Normal file
145
app/javascript/mastodon/components/hashtag.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import type { JSX } from 'react';
|
||||
import { Component } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import type Immutable from 'immutable';
|
||||
|
||||
import { Sparklines, SparklinesCurve } from 'react-sparklines';
|
||||
|
||||
import { ShortNumber } from 'mastodon/components/short_number';
|
||||
import { Skeleton } from 'mastodon/components/skeleton';
|
||||
|
||||
interface SilentErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
class SilentErrorBoundary extends Component<SilentErrorBoundaryProps> {
|
||||
state = {
|
||||
error: false,
|
||||
};
|
||||
|
||||
componentDidCatch() {
|
||||
this.setState({ error: true });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to render counter of how much people are talking about hashtag
|
||||
* @param displayNumber Counter number to display
|
||||
* @param pluralReady Whether the count is plural
|
||||
* @returns Formatted counter of how much people are talking about hashtag
|
||||
*/
|
||||
export const accountsCountRenderer = (
|
||||
displayNumber: JSX.Element,
|
||||
pluralReady: number,
|
||||
) => (
|
||||
<FormattedMessage
|
||||
id='trends.counter_by_accounts'
|
||||
defaultMessage='{count, plural, one {{counter} person} other {{counter} people}} in the past {days, plural, one {day} other {# days}}'
|
||||
values={{
|
||||
count: pluralReady,
|
||||
counter: <strong>{displayNumber}</strong>,
|
||||
days: 2,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
interface ImmutableHashtagProps {
|
||||
hashtag: Immutable.Map<string, unknown>;
|
||||
}
|
||||
|
||||
export const ImmutableHashtag = ({ hashtag }: ImmutableHashtagProps) => (
|
||||
<Hashtag
|
||||
name={hashtag.get('name') as string}
|
||||
to={`/tags/${hashtag.get('name') as string}`}
|
||||
people={
|
||||
(hashtag.getIn(['history', 0, 'accounts']) as number) * 1 +
|
||||
(hashtag.getIn(['history', 1, 'accounts']) as number) * 1
|
||||
}
|
||||
history={(
|
||||
hashtag.get('history') as Immutable.Collection.Indexed<
|
||||
Immutable.Map<string, number>
|
||||
>
|
||||
)
|
||||
.reverse()
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
.map((day) => day.get('uses')!)
|
||||
.toArray()}
|
||||
/>
|
||||
);
|
||||
|
||||
export interface HashtagProps {
|
||||
className?: string;
|
||||
description?: React.ReactNode;
|
||||
history?: number[];
|
||||
name: string;
|
||||
people: number;
|
||||
to: string;
|
||||
uses?: number;
|
||||
withGraph?: boolean;
|
||||
}
|
||||
|
||||
export const Hashtag: React.FC<HashtagProps> = ({
|
||||
name,
|
||||
to,
|
||||
people,
|
||||
uses,
|
||||
history,
|
||||
className,
|
||||
description,
|
||||
withGraph = true,
|
||||
}) => (
|
||||
<div className={classNames('trends__item', className)}>
|
||||
<div className='trends__item__name'>
|
||||
<Link to={to}>
|
||||
{name ? (
|
||||
<>
|
||||
#<span>{name}</span>
|
||||
</>
|
||||
) : (
|
||||
<Skeleton width={50} />
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{description ? (
|
||||
<span>{description}</span>
|
||||
) : typeof people !== 'undefined' ? (
|
||||
<ShortNumber value={people} renderer={accountsCountRenderer} />
|
||||
) : (
|
||||
<Skeleton width={100} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{typeof uses !== 'undefined' && (
|
||||
<div className='trends__item__current'>
|
||||
<ShortNumber value={uses} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{withGraph && (
|
||||
<div className='trends__item__sparkline'>
|
||||
<SilentErrorBoundary>
|
||||
<Sparklines
|
||||
width={50}
|
||||
height={28}
|
||||
data={history ? history : Array.from(Array(7)).map(() => 0)}
|
||||
>
|
||||
<SparklinesCurve style={{ fill: 'none' }} />
|
||||
</Sparklines>
|
||||
</SilentErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -24,7 +24,7 @@ export type StatusLike = Record<{
|
|||
|
||||
function normalizeHashtag(hashtag: string) {
|
||||
return (
|
||||
hashtag && hashtag.startsWith('#') ? hashtag.slice(1) : hashtag
|
||||
!!hashtag && hashtag.startsWith('#') ? hashtag.slice(1) : hashtag
|
||||
).normalize('NFKC');
|
||||
}
|
||||
|
||||
|
|
@ -52,7 +52,10 @@ function uniqueHashtagsWithCaseHandling(hashtags: string[]) {
|
|||
);
|
||||
|
||||
return Object.values(groups).map((tags) => {
|
||||
if (tags.length === 1) return tags[0];
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- we know that the array has at least one element
|
||||
const firstTag = tags[0]!;
|
||||
|
||||
if (tags.length === 1) return firstTag;
|
||||
|
||||
// The best match is the one where we have the less difference between upper and lower case letter count
|
||||
const best = minBy(tags, (tag) => {
|
||||
|
|
@ -66,7 +69,7 @@ function uniqueHashtagsWithCaseHandling(hashtags: string[]) {
|
|||
return Math.abs(lowerCase - upperCase);
|
||||
});
|
||||
|
||||
return best ?? tags[0];
|
||||
return best ?? firstTag;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
92
app/javascript/mastodon/components/hover_card_account.tsx
Normal file
92
app/javascript/mastodon/components/hover_card_account.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { useEffect, forwardRef } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { fetchAccount } from 'mastodon/actions/accounts';
|
||||
import { AccountBio } from 'mastodon/components/account_bio';
|
||||
import { AccountFields } from 'mastodon/components/account_fields';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { FollowersCounter } from 'mastodon/components/counters';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import { FollowButton } from 'mastodon/components/follow_button';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { ShortNumber } from 'mastodon/components/short_number';
|
||||
import { domain } from 'mastodon/initial_state';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
export const HoverCardAccount = forwardRef<
|
||||
HTMLDivElement,
|
||||
{ accountId?: string }
|
||||
>(({ accountId }, ref) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const account = useAppSelector((state) =>
|
||||
accountId ? state.accounts.get(accountId) : undefined,
|
||||
);
|
||||
|
||||
const note = useAppSelector(
|
||||
(state) =>
|
||||
state.relationships.getIn([accountId, 'note']) as string | undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (accountId && !account) {
|
||||
dispatch(fetchAccount(accountId));
|
||||
}
|
||||
}, [dispatch, accountId, account]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
id='hover-card'
|
||||
role='tooltip'
|
||||
className={classNames('hover-card dropdown-animation', {
|
||||
'hover-card--loading': !account,
|
||||
})}
|
||||
>
|
||||
{account ? (
|
||||
<>
|
||||
<Link to={`/@${account.acct}`} className='hover-card__name'>
|
||||
<Avatar account={account} size={46} />
|
||||
<DisplayName account={account} localDomain={domain} />
|
||||
</Link>
|
||||
|
||||
<div className='hover-card__text-row'>
|
||||
<AccountBio
|
||||
note={account.note_emojified}
|
||||
className='hover-card__bio'
|
||||
/>
|
||||
<AccountFields fields={account.fields} limit={2} />
|
||||
{note && note.length > 0 && (
|
||||
<dl className='hover-card__note'>
|
||||
<dt className='hover-card__note-label'>
|
||||
<FormattedMessage
|
||||
id='account.account_note_header'
|
||||
defaultMessage='Personal note'
|
||||
/>
|
||||
</dt>
|
||||
<dd>{note}</dd>
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='hover-card__number'>
|
||||
<ShortNumber
|
||||
value={account.followers_count}
|
||||
renderer={FollowersCounter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FollowButton accountId={accountId} />
|
||||
</>
|
||||
) : (
|
||||
<LoadingIndicator />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
HoverCardAccount.displayName = 'HoverCardAccount';
|
||||
189
app/javascript/mastodon/components/hover_card_controller.tsx
Normal file
189
app/javascript/mastodon/components/hover_card_controller.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import Overlay from 'react-overlays/Overlay';
|
||||
import type {
|
||||
OffsetValue,
|
||||
UsePopperOptions,
|
||||
} from 'react-overlays/esm/usePopper';
|
||||
|
||||
import { useTimeout } from 'mastodon/../hooks/useTimeout';
|
||||
import { HoverCardAccount } from 'mastodon/components/hover_card_account';
|
||||
|
||||
const offset = [-12, 4] as OffsetValue;
|
||||
const enterDelay = 750;
|
||||
const leaveDelay = 150;
|
||||
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
||||
|
||||
const isHoverCardAnchor = (element: HTMLElement) =>
|
||||
element.matches('[data-hover-card-account]');
|
||||
|
||||
export const HoverCardController: React.FC = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [accountId, setAccountId] = useState<string | undefined>();
|
||||
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout();
|
||||
const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout();
|
||||
const [setScrollTimeout] = useTimeout();
|
||||
const location = useLocation();
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
cancelEnterTimeout();
|
||||
cancelLeaveTimeout();
|
||||
setOpen(false);
|
||||
setAnchor(null);
|
||||
}, [cancelEnterTimeout, cancelLeaveTimeout, setOpen, setAnchor]);
|
||||
|
||||
useEffect(() => {
|
||||
handleClose();
|
||||
}, [handleClose, location]);
|
||||
|
||||
useEffect(() => {
|
||||
let isScrolling = false;
|
||||
let currentAnchor: HTMLElement | null = null;
|
||||
let currentTitle: string | null = null;
|
||||
|
||||
const open = (target: HTMLElement) => {
|
||||
target.setAttribute('aria-describedby', 'hover-card');
|
||||
setOpen(true);
|
||||
setAnchor(target);
|
||||
setAccountId(target.getAttribute('data-hover-card-account') ?? undefined);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
currentAnchor?.removeAttribute('aria-describedby');
|
||||
currentAnchor = null;
|
||||
setOpen(false);
|
||||
setAnchor(null);
|
||||
setAccountId(undefined);
|
||||
};
|
||||
|
||||
const handleMouseEnter = (e: MouseEvent) => {
|
||||
const { target } = e;
|
||||
|
||||
// We've exited the window
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
// We've entered an anchor
|
||||
if (!isScrolling && isHoverCardAnchor(target)) {
|
||||
cancelLeaveTimeout();
|
||||
|
||||
currentAnchor?.removeAttribute('aria-describedby');
|
||||
currentAnchor = target;
|
||||
|
||||
currentTitle = target.getAttribute('title');
|
||||
target.removeAttribute('title');
|
||||
|
||||
setEnterTimeout(() => {
|
||||
open(target);
|
||||
}, enterDelay);
|
||||
}
|
||||
|
||||
// We've entered the hover card
|
||||
if (
|
||||
!isScrolling &&
|
||||
(target === currentAnchor || target === cardRef.current)
|
||||
) {
|
||||
cancelLeaveTimeout();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = (e: MouseEvent) => {
|
||||
const { target } = e;
|
||||
|
||||
if (!currentAnchor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
currentTitle &&
|
||||
target instanceof HTMLElement &&
|
||||
target === currentAnchor
|
||||
)
|
||||
target.setAttribute('title', currentTitle);
|
||||
|
||||
if (target === currentAnchor || target === cardRef.current) {
|
||||
cancelEnterTimeout();
|
||||
|
||||
setLeaveTimeout(() => {
|
||||
close();
|
||||
}, leaveDelay);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScrollEnd = () => {
|
||||
isScrolling = false;
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
isScrolling = true;
|
||||
cancelEnterTimeout();
|
||||
setScrollTimeout(handleScrollEnd, 100);
|
||||
};
|
||||
|
||||
const handleMouseMove = () => {
|
||||
delayEnterTimeout(enterDelay);
|
||||
};
|
||||
|
||||
document.body.addEventListener('mouseenter', handleMouseEnter, {
|
||||
passive: true,
|
||||
capture: true,
|
||||
});
|
||||
|
||||
document.body.addEventListener('mousemove', handleMouseMove, {
|
||||
passive: true,
|
||||
capture: false,
|
||||
});
|
||||
|
||||
document.body.addEventListener('mouseleave', handleMouseLeave, {
|
||||
passive: true,
|
||||
capture: true,
|
||||
});
|
||||
|
||||
document.addEventListener('scroll', handleScroll, {
|
||||
passive: true,
|
||||
capture: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
document.body.removeEventListener('mouseenter', handleMouseEnter);
|
||||
document.body.removeEventListener('mousemove', handleMouseMove);
|
||||
document.body.removeEventListener('mouseleave', handleMouseLeave);
|
||||
document.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [
|
||||
setEnterTimeout,
|
||||
setLeaveTimeout,
|
||||
setScrollTimeout,
|
||||
cancelEnterTimeout,
|
||||
cancelLeaveTimeout,
|
||||
delayEnterTimeout,
|
||||
setOpen,
|
||||
setAccountId,
|
||||
setAnchor,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Overlay
|
||||
rootClose
|
||||
onHide={handleClose}
|
||||
show={open}
|
||||
target={anchor}
|
||||
placement='bottom-start'
|
||||
flip
|
||||
offset={offset}
|
||||
popperConfig={popperConfig}
|
||||
>
|
||||
{({ props }) => (
|
||||
<div {...props} className='hover-card-controller'>
|
||||
<HoverCardAccount accountId={accountId} ref={cardRef} />
|
||||
</div>
|
||||
)}
|
||||
</Overlay>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,20 +1,53 @@
|
|||
import classNames from 'classnames';
|
||||
|
||||
interface Props extends React.HTMLAttributes<HTMLImageElement> {
|
||||
id: string;
|
||||
className?: string;
|
||||
fixedWidth?: boolean;
|
||||
import CheckBoxOutlineBlankIcon from '@/material-icons/400-24px/check_box_outline_blank.svg?react';
|
||||
import { isProduction } from 'mastodon/utils/environment';
|
||||
|
||||
interface SVGPropsWithTitle extends React.SVGProps<SVGSVGElement> {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export type IconProp = React.FC<SVGPropsWithTitle>;
|
||||
|
||||
interface Props extends React.SVGProps<SVGSVGElement> {
|
||||
children?: never;
|
||||
id: string;
|
||||
icon: IconProp;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const Icon: React.FC<Props> = ({
|
||||
id,
|
||||
icon: IconComponent,
|
||||
className,
|
||||
fixedWidth,
|
||||
title: titleProp,
|
||||
...other
|
||||
}) => (
|
||||
<i
|
||||
className={classNames('fa', `fa-${id}`, className, { 'fa-fw': fixedWidth })}
|
||||
{...other}
|
||||
/>
|
||||
);
|
||||
}) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!IconComponent) {
|
||||
if (!isProduction()) {
|
||||
throw new Error(
|
||||
`<Icon id="${id}" className="${className}"> is missing an "icon" prop.`,
|
||||
);
|
||||
}
|
||||
|
||||
IconComponent = CheckBoxOutlineBlankIcon;
|
||||
}
|
||||
|
||||
const ariaHidden = titleProp ? undefined : true;
|
||||
const role = !ariaHidden ? 'img' : undefined;
|
||||
|
||||
// Set the title to an empty string to remove the built-in SVG one if any
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
const title = titleProp || '';
|
||||
|
||||
return (
|
||||
<IconComponent
|
||||
className={classNames('icon', `icon-${id}`, className)}
|
||||
title={title}
|
||||
aria-hidden={ariaHidden}
|
||||
role={role}
|
||||
{...other}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,19 +1,20 @@
|
|||
import { PureComponent } from 'react';
|
||||
import { PureComponent, createRef } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { AnimatedNumber } from './animated_number';
|
||||
import type { IconProp } from './icon';
|
||||
import { Icon } from './icon';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
iconComponent: IconProp;
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
onMouseDown?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLButtonElement>;
|
||||
onKeyPress?: React.KeyboardEventHandler<HTMLButtonElement>;
|
||||
size: number;
|
||||
active: boolean;
|
||||
expanded?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
|
|
@ -32,8 +33,9 @@ interface States {
|
|||
deactivate: boolean;
|
||||
}
|
||||
export class IconButton extends PureComponent<Props, States> {
|
||||
buttonRef = createRef<HTMLButtonElement>();
|
||||
|
||||
static defaultProps = {
|
||||
size: 18,
|
||||
active: false,
|
||||
disabled: false,
|
||||
animate: false,
|
||||
|
|
@ -85,10 +87,6 @@ export class IconButton extends PureComponent<Props, States> {
|
|||
|
||||
render() {
|
||||
const style = {
|
||||
fontSize: `${this.props.size}px`,
|
||||
width: `${this.props.size * 1.28571429}px`,
|
||||
height: `${this.props.size * 1.28571429}px`,
|
||||
lineHeight: `${this.props.size}px`,
|
||||
...this.props.style,
|
||||
...(this.props.active ? this.props.activeStyle : {}),
|
||||
};
|
||||
|
|
@ -99,6 +97,7 @@ export class IconButton extends PureComponent<Props, States> {
|
|||
disabled,
|
||||
expanded,
|
||||
icon,
|
||||
iconComponent,
|
||||
inverted,
|
||||
overlay,
|
||||
tabIndex,
|
||||
|
|
@ -120,13 +119,9 @@ export class IconButton extends PureComponent<Props, States> {
|
|||
'icon-button--with-counter': typeof counter !== 'undefined',
|
||||
});
|
||||
|
||||
if (typeof counter !== 'undefined') {
|
||||
style.width = 'auto';
|
||||
}
|
||||
|
||||
let contents = (
|
||||
<>
|
||||
<Icon id={icon} fixedWidth aria-hidden='true' />{' '}
|
||||
<Icon id={icon} icon={iconComponent} aria-hidden='true' />{' '}
|
||||
{typeof counter !== 'undefined' && (
|
||||
<span className='icon-button__counter'>
|
||||
<AnimatedNumber value={counter} />
|
||||
|
|
@ -158,6 +153,7 @@ export class IconButton extends PureComponent<Props, States> {
|
|||
style={style}
|
||||
tabIndex={tabIndex}
|
||||
disabled={disabled}
|
||||
ref={this.buttonRef}
|
||||
>
|
||||
{contents}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,24 @@
|
|||
import type { IconProp } from './icon';
|
||||
import { Icon } from './icon';
|
||||
|
||||
const formatNumber = (num: number): number | string => (num > 40 ? '40+' : num);
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
icon: IconProp;
|
||||
count: number;
|
||||
issueBadge: boolean;
|
||||
className: string;
|
||||
}
|
||||
export const IconWithBadge: React.FC<Props> = ({
|
||||
id,
|
||||
icon,
|
||||
count,
|
||||
issueBadge,
|
||||
className,
|
||||
}) => (
|
||||
<i className='icon-with-badge'>
|
||||
<Icon id={id} fixedWidth className={className} />
|
||||
<Icon id={id} icon={icon} className={className} />
|
||||
{count > 0 && (
|
||||
<i className='icon-with-badge__badge'>{formatNumber(count)}</i>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const makeMapStateToProps = () => {
|
|||
class InlineAccount extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
account: ImmutablePropTypes.record.isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
|
|
|
|||
|
|
@ -2,24 +2,25 @@ import { useCallback } from 'react';
|
|||
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
const messages = defineMessages({
|
||||
load_more: { id: 'status.load_more', defaultMessage: 'Load more' },
|
||||
});
|
||||
|
||||
interface Props {
|
||||
interface Props<T> {
|
||||
disabled: boolean;
|
||||
maxId: string;
|
||||
onClick: (maxId: string) => void;
|
||||
param: T;
|
||||
onClick: (params: T) => void;
|
||||
}
|
||||
|
||||
export const LoadGap: React.FC<Props> = ({ disabled, maxId, onClick }) => {
|
||||
export const LoadGap = <T,>({ disabled, param, onClick }: Props<T>) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onClick(maxId);
|
||||
}, [maxId, onClick]);
|
||||
onClick(param);
|
||||
}, [param, onClick]);
|
||||
|
||||
return (
|
||||
<button
|
||||
|
|
@ -28,7 +29,7 @@ export const LoadGap: React.FC<Props> = ({ disabled, maxId, onClick }) => {
|
|||
onClick={handleClick}
|
||||
aria-label={intl.formatMessage(messages.load_more)}
|
||||
>
|
||||
<Icon id='ellipsis-h' />
|
||||
<Icon id='ellipsis-h' icon={MoreHorizIcon} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,23 @@
|
|||
import { useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { CircularProgress } from './circular_progress';
|
||||
|
||||
export const LoadingIndicator: React.FC = () => (
|
||||
<div className='loading-indicator'>
|
||||
<CircularProgress size={50} strokeWidth={6} />
|
||||
</div>
|
||||
);
|
||||
const messages = defineMessages({
|
||||
loading: { id: 'loading_indicator.label', defaultMessage: 'Loading…' },
|
||||
});
|
||||
|
||||
export const LoadingIndicator: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div
|
||||
className='loading-indicator'
|
||||
role='progressbar'
|
||||
aria-busy
|
||||
aria-live='polite'
|
||||
aria-label={intl.formatMessage(messages.loading)}
|
||||
>
|
||||
<CircularProgress size={50} strokeWidth={6} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import logo from 'mastodon/../images/logo.svg';
|
||||
import logo from '@/images/logo.svg';
|
||||
|
||||
export const WordmarkLogo: React.FC = () => (
|
||||
<svg viewBox='0 0 261 66' className='logo logo--wordmark' role='img'>
|
||||
|
|
@ -7,6 +7,13 @@ export const WordmarkLogo: React.FC = () => (
|
|||
</svg>
|
||||
);
|
||||
|
||||
export const IconLogo: React.FC = () => (
|
||||
<svg viewBox='0 0 79 79' className='logo logo--icon' role='img'>
|
||||
<title>Mastodon</title>
|
||||
<use xlinkHref='#logo-symbol-icon' />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SymbolLogo: React.FC = () => (
|
||||
<img src={logo} alt='Mastodon' className='logo logo--icon' />
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export default class MediaAttachments extends ImmutablePureComponent {
|
|||
lang: PropTypes.string,
|
||||
height: PropTypes.number,
|
||||
width: PropTypes.number,
|
||||
visible: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
|
|
@ -51,7 +52,7 @@ export default class MediaAttachments extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
render () {
|
||||
const { status, width, height } = this.props;
|
||||
const { status, width, height, visible } = this.props;
|
||||
const mediaAttachments = status.get('media_attachments');
|
||||
const language = status.getIn(['language', 'translation']) || status.get('language') || this.props.lang;
|
||||
|
||||
|
|
@ -99,6 +100,7 @@ export default class MediaAttachments extends ImmutablePureComponent {
|
|||
height={height}
|
||||
inline
|
||||
sensitive={status.get('sensitive')}
|
||||
visible={visible}
|
||||
onOpenVideo={noop}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -113,6 +115,7 @@ export default class MediaAttachments extends ImmutablePureComponent {
|
|||
lang={language}
|
||||
sensitive={status.get('sensitive')}
|
||||
defaultWidth={width}
|
||||
visible={visible}
|
||||
height={height}
|
||||
onOpenMedia={noop}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
|
|
@ -10,16 +10,12 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
|||
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { AltTextBadge } from 'mastodon/components/alt_text_badge';
|
||||
import { Blurhash } from 'mastodon/components/blurhash';
|
||||
import { formatTime } from 'mastodon/features/video';
|
||||
|
||||
import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state';
|
||||
|
||||
import { IconButton } from './icon_button';
|
||||
|
||||
const messages = defineMessages({
|
||||
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: '{number, plural, one {Hide image} other {Hide images}}' },
|
||||
});
|
||||
|
||||
class Item extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
|
|
@ -63,7 +59,7 @@ class Item extends PureComponent {
|
|||
|
||||
hoverToPlay () {
|
||||
const { attachment } = this.props;
|
||||
return !this.getAutoPlay() && attachment.get('type') === 'gifv';
|
||||
return !this.getAutoPlay() && ['gifv', 'video'].includes(attachment.get('type'));
|
||||
}
|
||||
|
||||
handleClick = (e) => {
|
||||
|
|
@ -102,7 +98,7 @@ class Item extends PureComponent {
|
|||
}
|
||||
|
||||
if (attachment.get('description')?.length > 0) {
|
||||
badges.push(<span key='alt' className='media-gallery__gifv__label'>ALT</span>);
|
||||
badges.push(<AltTextBadge key='alt' description={attachment.get('description')} />);
|
||||
}
|
||||
|
||||
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
|
||||
|
|
@ -156,10 +152,15 @@ class Item extends PureComponent {
|
|||
/>
|
||||
</a>
|
||||
);
|
||||
} else if (attachment.get('type') === 'gifv') {
|
||||
} else if (['gifv', 'video'].includes(attachment.get('type'))) {
|
||||
const autoPlay = this.getAutoPlay();
|
||||
const duration = attachment.getIn(['meta', 'original', 'duration']);
|
||||
|
||||
badges.push(<span key='gif' className='media-gallery__gifv__label'>GIF</span>);
|
||||
if (attachment.get('type') === 'gifv') {
|
||||
badges.push(<span key='gif' className='media-gallery__alt__label media-gallery__alt__label--non-interactive'>GIF</span>);
|
||||
} else {
|
||||
badges.push(<span key='video' className='media-gallery__alt__label media-gallery__alt__label--non-interactive'>{formatTime(Math.floor(duration))}</span>);
|
||||
}
|
||||
|
||||
thumbnail = (
|
||||
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
|
||||
|
|
@ -173,6 +174,7 @@ class Item extends PureComponent {
|
|||
onClick={this.handleClick}
|
||||
onMouseEnter={this.handleMouseEnter}
|
||||
onMouseLeave={this.handleMouseLeave}
|
||||
onLoadedData={this.handleImageLoad}
|
||||
autoPlay={autoPlay}
|
||||
playsInline
|
||||
loop
|
||||
|
|
@ -214,7 +216,6 @@ class MediaGallery extends PureComponent {
|
|||
size: PropTypes.object,
|
||||
height: PropTypes.number.isRequired,
|
||||
onOpenMedia: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
defaultWidth: PropTypes.number,
|
||||
cacheWidth: PropTypes.func,
|
||||
visible: PropTypes.bool,
|
||||
|
|
@ -290,7 +291,7 @@ class MediaGallery extends PureComponent {
|
|||
}
|
||||
|
||||
render () {
|
||||
const { media, lang, intl, sensitive, defaultWidth, autoplay } = this.props;
|
||||
const { media, lang, sensitive, defaultWidth, autoplay } = this.props;
|
||||
const { visible } = this.state;
|
||||
const width = this.state.width || defaultWidth;
|
||||
|
||||
|
|
@ -304,13 +305,13 @@ class MediaGallery extends PureComponent {
|
|||
style.aspectRatio = '3 / 2';
|
||||
}
|
||||
|
||||
const size = media.take(4).size;
|
||||
const size = media.size;
|
||||
const uncached = media.every(attachment => attachment.get('type') === 'unknown');
|
||||
|
||||
if (this.isFullSizeEligible()) {
|
||||
children = <Item standalone autoplay={autoplay} onClick={this.handleClick} attachment={media.get(0)} lang={lang} displayWidth={width} visible={visible} />;
|
||||
} else {
|
||||
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} autoplay={autoplay} onClick={this.handleClick} attachment={attachment} index={i} lang={lang} size={size} displayWidth={width} visible={visible || uncached} />);
|
||||
children = media.map((attachment, i) => <Item key={attachment.get('id')} autoplay={autoplay} onClick={this.handleClick} attachment={attachment} index={i} lang={lang} size={size} displayWidth={width} visible={visible || uncached} />);
|
||||
}
|
||||
|
||||
if (uncached) {
|
||||
|
|
@ -322,9 +323,7 @@ class MediaGallery extends PureComponent {
|
|||
</span>
|
||||
</button>
|
||||
);
|
||||
} else if (visible) {
|
||||
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible, { number: size })} icon='eye-slash' overlay onClick={this.handleOpen} ariaHidden />;
|
||||
} else {
|
||||
} else if (!visible) {
|
||||
spoilerButton = (
|
||||
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
|
||||
<span className='spoiler-button__overlay__label'>
|
||||
|
|
@ -336,16 +335,24 @@ class MediaGallery extends PureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className='media-gallery' style={style} ref={this.handleRef}>
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--minified': visible && !uncached, 'spoiler-button--click-thru': uncached })}>
|
||||
{spoilerButton}
|
||||
</div>
|
||||
<div className={`media-gallery media-gallery--layout-${size}`} style={style} ref={this.handleRef}>
|
||||
{(!visible || uncached) && (
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--click-thru': uncached })}>
|
||||
{spoilerButton}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{(visible && !uncached) && (
|
||||
<div className='media-gallery__actions'>
|
||||
<button className='media-gallery__actions__pill' onClick={this.handleOpen}><FormattedMessage id='media_gallery.hide' defaultMessage='Hide' /></button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(MediaGallery);
|
||||
export default MediaGallery;
|
||||
|
|
|
|||
|
|
@ -2,14 +2,13 @@ import PropTypes from 'prop-types';
|
|||
import { PureComponent } from 'react';
|
||||
|
||||
import 'wicg-inert';
|
||||
|
||||
import { multiply } from 'color-blend';
|
||||
import { createBrowserHistory } from 'history';
|
||||
|
||||
export default class ModalRoot extends PureComponent {
|
||||
import { WithOptionalRouterPropTypes, withOptionalRouter } from 'mastodon/utils/react_router';
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
class ModalRoot extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
children: PropTypes.node,
|
||||
|
|
@ -20,6 +19,7 @@ export default class ModalRoot extends PureComponent {
|
|||
b: PropTypes.number,
|
||||
}),
|
||||
ignoreFocus: PropTypes.bool,
|
||||
...WithOptionalRouterPropTypes,
|
||||
};
|
||||
|
||||
activeElement = this.props.children ? document.activeElement : null;
|
||||
|
|
@ -55,7 +55,7 @@ export default class ModalRoot extends PureComponent {
|
|||
componentDidMount () {
|
||||
window.addEventListener('keyup', this.handleKeyUp, false);
|
||||
window.addEventListener('keydown', this.handleKeyDown, false);
|
||||
this.history = this.context.router ? this.context.router.history : createBrowserHistory();
|
||||
this.history = this.props.history || createBrowserHistory();
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
||||
|
|
@ -148,7 +148,7 @@ export default class ModalRoot extends PureComponent {
|
|||
return (
|
||||
<div className='modal-root' ref={this.setRef}>
|
||||
<div style={{ pointerEvents: visible ? 'auto' : 'none' }}>
|
||||
<div role='presentation' className='modal-root__overlay' onClick={onClose} style={{ backgroundColor: backgroundColor ? `rgba(${backgroundColor.r}, ${backgroundColor.g}, ${backgroundColor.b}, 0.7)` : null }} />
|
||||
<div role='presentation' className='modal-root__overlay' onClick={onClose} style={{ backgroundColor: backgroundColor ? `rgba(${backgroundColor.r}, ${backgroundColor.g}, ${backgroundColor.b}, 0.9)` : null }} />
|
||||
<div role='dialog' className='modal-root__container'>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -156,3 +156,5 @@ export default class ModalRoot extends PureComponent {
|
|||
}
|
||||
|
||||
}
|
||||
|
||||
export default withOptionalRouter(ModalRoot);
|
||||
|
|
|
|||
17
app/javascript/mastodon/components/more_from_author.jsx
Normal file
17
app/javascript/mastodon/components/more_from_author.jsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { IconLogo } from 'mastodon/components/logo';
|
||||
import { AuthorLink } from 'mastodon/features/explore/components/author_link';
|
||||
|
||||
export const MoreFromAuthor = ({ accountId }) => (
|
||||
<div className='more-from-author'>
|
||||
<IconLogo />
|
||||
<FormattedMessage id='link_preview.more_from_author' defaultMessage='More from {name}' values={{ name: <AuthorLink accountId={accountId} /> }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
MoreFromAuthor.propTypes = {
|
||||
accountId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { PureComponent } from 'react';
|
||||
|
||||
import { Switch, Route, withRouter } from 'react-router-dom';
|
||||
|
||||
import AccountNavigation from 'mastodon/features/account/navigation';
|
||||
import Trends from 'mastodon/features/getting_started/containers/trends_container';
|
||||
import { showTrends } from 'mastodon/initial_state';
|
||||
|
||||
const DefaultNavigation = () => (
|
||||
showTrends ? (
|
||||
<>
|
||||
<div className='flex-spacer' />
|
||||
<Trends />
|
||||
</>
|
||||
) : null
|
||||
);
|
||||
|
||||
class NavigationPortal extends PureComponent {
|
||||
|
||||
render () {
|
||||
return (
|
||||
<Switch>
|
||||
<Route path='/@:acct' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/tagged/:tagged?' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/with_replies' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/followers' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/following' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/media' exact component={AccountNavigation} />
|
||||
<Route component={DefaultNavigation} />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
export default withRouter(NavigationPortal);
|
||||
25
app/javascript/mastodon/components/navigation_portal.tsx
Normal file
25
app/javascript/mastodon/components/navigation_portal.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Switch, Route } from 'react-router-dom';
|
||||
|
||||
import AccountNavigation from 'mastodon/features/account/navigation';
|
||||
import Trends from 'mastodon/features/getting_started/containers/trends_container';
|
||||
import { showTrends } from 'mastodon/initial_state';
|
||||
|
||||
const DefaultNavigation: React.FC = () => (showTrends ? <Trends /> : null);
|
||||
|
||||
export const NavigationPortal: React.FC = () => (
|
||||
<div className='navigation-panel__portal'>
|
||||
<Switch>
|
||||
<Route path='/@:acct' exact component={AccountNavigation} />
|
||||
<Route
|
||||
path='/@:acct/tagged/:tagged?'
|
||||
exact
|
||||
component={AccountNavigation}
|
||||
/>
|
||||
<Route path='/@:acct/with_replies' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/followers' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/following' exact component={AccountNavigation} />
|
||||
<Route path='/@:acct/media' exact component={AccountNavigation} />
|
||||
<Route component={DefaultNavigation} />
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -5,6 +5,7 @@ import { FormattedMessage } from 'react-intl';
|
|||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import CancelPresentationIcon from '@/material-icons/400-24px/cancel_presentation.svg?react';
|
||||
import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
|
|
@ -25,7 +26,7 @@ class PictureInPicturePlaceholder extends PureComponent {
|
|||
|
||||
return (
|
||||
<div className='picture-in-picture-placeholder' style={{ aspectRatio }} role='button' tabIndex={0} onClick={this.handleClick}>
|
||||
<Icon id='window-restore' />
|
||||
<Icon id='window-restore' icon={CancelPresentationIcon} />
|
||||
<FormattedMessage id='picture_in_picture.restore' defaultMessage='Put it back' />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||
import escapeTextContentForBrowser from 'escape-html';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
|
||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import emojify from 'mastodon/features/emoji/emoji';
|
||||
import Motion from 'mastodon/features/ui/util/optional_motion';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
|
||||
import { RelativeTimestamp } from './relative_timestamp';
|
||||
|
||||
|
|
@ -37,12 +39,8 @@ const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
|
|||
}, {});
|
||||
|
||||
class Poll extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
identity: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
poll: ImmutablePropTypes.map,
|
||||
lang: PropTypes.string,
|
||||
intl: PropTypes.object.isRequired,
|
||||
|
|
@ -132,7 +130,7 @@ class Poll extends ImmutablePureComponent {
|
|||
|
||||
handleReveal = () => {
|
||||
this.setState({ revealed: true });
|
||||
}
|
||||
};
|
||||
|
||||
renderOption (option, optionIndex, showResults) {
|
||||
const { poll, lang, disabled, intl } = this.props;
|
||||
|
|
@ -192,7 +190,7 @@ class Poll extends ImmutablePureComponent {
|
|||
/>
|
||||
|
||||
{!!voted && <span className='poll__voted'>
|
||||
<Icon id='check' className='poll__voted__mark' title={intl.formatMessage(messages.voted)} />
|
||||
<Icon id='check' icon={CheckIcon} className='poll__voted__mark' title={intl.formatMessage(messages.voted)} />
|
||||
</span>}
|
||||
</label>
|
||||
|
||||
|
|
@ -234,7 +232,7 @@ class Poll extends ImmutablePureComponent {
|
|||
</ul>
|
||||
|
||||
<div className='poll__footer'>
|
||||
{!showResults && <button className='button button-secondary' disabled={disabled || !this.context.identity.signedIn} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>}
|
||||
{!showResults && <button className='button button-secondary' disabled={disabled || !this.props.identity.signedIn} onClick={this.handleVote}><FormattedMessage id='poll.vote' defaultMessage='Vote' /></button>}
|
||||
{!showResults && <><button className='poll__link' onClick={this.handleReveal}><FormattedMessage id='poll.reveal' defaultMessage='See results' /></button> · </>}
|
||||
{showResults && !this.props.disabled && <><button className='poll__link' onClick={this.handleRefresh}><FormattedMessage id='poll.refresh' defaultMessage='Refresh' /></button> · </>}
|
||||
{votesCount}
|
||||
|
|
@ -246,4 +244,4 @@ class Poll extends ImmutablePureComponent {
|
|||
|
||||
}
|
||||
|
||||
export default injectIntl(Poll);
|
||||
export default injectIntl(withIdentity(Poll));
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import illustration from 'mastodon/../images/elephant_ui_working.svg';
|
||||
import illustration from '@/images/elephant_ui_working.svg';
|
||||
|
||||
const RegenerationIndicator = () => (
|
||||
<div className='regeneration-indicator'>
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ const messages = defineMessages({
|
|||
});
|
||||
|
||||
const dateFormatOptions = {
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
|
|
@ -103,7 +102,7 @@ const getUnitDelay = (units: string) => {
|
|||
};
|
||||
|
||||
export const timeAgoString = (
|
||||
intl: IntlShape,
|
||||
intl: Pick<IntlShape, 'formatDate' | 'formatMessage'>,
|
||||
date: Date,
|
||||
now: number,
|
||||
year: number,
|
||||
|
|
@ -192,7 +191,7 @@ const timeRemainingString = (
|
|||
interface Props {
|
||||
intl: IntlShape;
|
||||
timestamp: string;
|
||||
year: number;
|
||||
year?: number;
|
||||
futureDate?: boolean;
|
||||
short?: boolean;
|
||||
}
|
||||
|
|
@ -204,11 +203,6 @@ class RelativeTimestamp extends Component<Props, States> {
|
|||
now: Date.now(),
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
year: new Date().getFullYear(),
|
||||
short: true,
|
||||
};
|
||||
|
||||
_timer: number | undefined;
|
||||
|
||||
shouldComponentUpdate(nextProps: Props, nextState: States) {
|
||||
|
|
@ -258,7 +252,13 @@ class RelativeTimestamp extends Component<Props, States> {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { timestamp, intl, year, futureDate, short } = this.props;
|
||||
const {
|
||||
timestamp,
|
||||
intl,
|
||||
futureDate,
|
||||
year = new Date().getFullYear(),
|
||||
short = true,
|
||||
} = this.props;
|
||||
|
||||
const timeGiven = timestamp.includes('T');
|
||||
const date = new Date(timestamp);
|
||||
|
|
|
|||
|
|
@ -1,44 +1,85 @@
|
|||
import type { PropsWithChildren } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { Router as OriginalRouter, useHistory } from 'react-router';
|
||||
|
||||
import type {
|
||||
LocationDescriptor,
|
||||
LocationDescriptorObject,
|
||||
Path,
|
||||
} from 'history';
|
||||
import { createBrowserHistory } from 'history';
|
||||
import { Router as OriginalRouter } from 'react-router';
|
||||
|
||||
import { layoutFromWindow } from 'mastodon/is_mobile';
|
||||
import { isDevelopment } from 'mastodon/utils/environment';
|
||||
|
||||
interface MastodonLocationState {
|
||||
fromMastodon?: boolean;
|
||||
mastodonModalKey?: string;
|
||||
}
|
||||
|
||||
const browserHistory = createBrowserHistory<
|
||||
MastodonLocationState | undefined
|
||||
>();
|
||||
type LocationState = MastodonLocationState | null | undefined;
|
||||
|
||||
type HistoryPath = Path | LocationDescriptor<LocationState>;
|
||||
|
||||
export const browserHistory = createBrowserHistory<LocationState>();
|
||||
const originalPush = browserHistory.push.bind(browserHistory);
|
||||
const originalReplace = browserHistory.replace.bind(browserHistory);
|
||||
|
||||
browserHistory.push = (path: string, state?: MastodonLocationState) => {
|
||||
state = state ?? {};
|
||||
state.fromMastodon = true;
|
||||
export function useAppHistory() {
|
||||
return useHistory<LocationState>();
|
||||
}
|
||||
|
||||
if (layoutFromWindow() === 'multi-column' && !path.startsWith('/deck')) {
|
||||
originalPush(`/deck${path}`, state);
|
||||
} else {
|
||||
originalPush(path, state);
|
||||
function normalizePath(
|
||||
path: HistoryPath,
|
||||
state?: LocationState,
|
||||
): LocationDescriptorObject<LocationState> {
|
||||
const location = typeof path === 'string' ? { pathname: path } : { ...path };
|
||||
|
||||
if (location.state === undefined && state !== undefined) {
|
||||
location.state = state;
|
||||
} else if (
|
||||
location.state !== undefined &&
|
||||
state !== undefined &&
|
||||
isDevelopment()
|
||||
) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
'You should avoid providing a 2nd state argument to push when the 1st argument is a location-like object that already has state; it is ignored',
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
layoutFromWindow() === 'multi-column' &&
|
||||
location.pathname &&
|
||||
!location.pathname.startsWith('/deck')
|
||||
) {
|
||||
location.pathname = `/deck${location.pathname}`;
|
||||
}
|
||||
|
||||
return location;
|
||||
}
|
||||
|
||||
browserHistory.push = (path: HistoryPath, state?: MastodonLocationState) => {
|
||||
const location = normalizePath(path, state);
|
||||
|
||||
location.state = location.state ?? {};
|
||||
location.state.fromMastodon = true;
|
||||
|
||||
originalPush(location);
|
||||
};
|
||||
|
||||
browserHistory.replace = (path: string, state?: MastodonLocationState) => {
|
||||
browserHistory.replace = (path: HistoryPath, state?: MastodonLocationState) => {
|
||||
const location = normalizePath(path, state);
|
||||
|
||||
if (!location.pathname) return;
|
||||
|
||||
if (browserHistory.location.state?.fromMastodon) {
|
||||
state = state ?? {};
|
||||
state.fromMastodon = true;
|
||||
location.state = location.state ?? {};
|
||||
location.state.fromMastodon = true;
|
||||
}
|
||||
|
||||
if (layoutFromWindow() === 'multi-column' && !path.startsWith('/deck')) {
|
||||
originalReplace(`/deck${path}`, state);
|
||||
} else {
|
||||
originalReplace(path, state);
|
||||
}
|
||||
originalReplace(location);
|
||||
};
|
||||
|
||||
export const Router: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
|
|||
import { Children, cloneElement, PureComponent } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
import { connect } from 'react-redux';
|
||||
|
|
@ -23,17 +24,43 @@ const MOUSE_IDLE_DELAY = 300;
|
|||
|
||||
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('mastodon/store').RootState} state
|
||||
* @param {*} props
|
||||
*/
|
||||
const mapStateToProps = (state, { scrollKey }) => {
|
||||
return {
|
||||
preventScroll: scrollKey === state.getIn(['dropdown_menu', 'scroll_key']),
|
||||
preventScroll: scrollKey === state.dropdownMenu.scrollKey,
|
||||
};
|
||||
};
|
||||
|
||||
class ScrollableList extends PureComponent {
|
||||
// This component only exists to be able to call useLocation()
|
||||
const IOArticleContainerWrapper = ({id, index, listLength, intersectionObserverWrapper, trackScroll, scrollKey, children}) => {
|
||||
const location = useLocation();
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
return (<IntersectionObserverArticleContainer
|
||||
id={id}
|
||||
index={index}
|
||||
listLength={listLength}
|
||||
intersectionObserverWrapper={intersectionObserverWrapper}
|
||||
saveHeightKey={trackScroll ? `${location.key}:${scrollKey}` : null}
|
||||
>
|
||||
{children}
|
||||
</IntersectionObserverArticleContainer>);
|
||||
};
|
||||
|
||||
IOArticleContainerWrapper.propTypes = {
|
||||
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
scrollKey: PropTypes.string.isRequired,
|
||||
intersectionObserverWrapper: PropTypes.object.isRequired,
|
||||
trackScroll: PropTypes.bool.isRequired,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
class ScrollableList extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
scrollKey: PropTypes.string.isRequired,
|
||||
|
|
@ -326,13 +353,14 @@ class ScrollableList extends PureComponent {
|
|||
{loadPending}
|
||||
|
||||
{Children.map(this.props.children, (child, index) => (
|
||||
<IntersectionObserverArticleContainer
|
||||
<IOArticleContainerWrapper
|
||||
key={child.key}
|
||||
id={child.key}
|
||||
index={index}
|
||||
listLength={childrenCount}
|
||||
intersectionObserverWrapper={this.intersectionObserverWrapper}
|
||||
saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null}
|
||||
trackScroll={trackScroll}
|
||||
scrollKey={scrollKey}
|
||||
>
|
||||
{cloneElement(child, {
|
||||
getScrollPosition: this.getScrollPosition,
|
||||
|
|
@ -340,7 +368,7 @@ class ScrollableList extends PureComponent {
|
|||
cachedMediaWidth: this.state.cachedMediaWidth,
|
||||
cacheMediaWidth: this.cacheMediaWidth,
|
||||
})}
|
||||
</IntersectionObserverArticleContainer>
|
||||
</IOArticleContainerWrapper>
|
||||
))}
|
||||
|
||||
{loadMore}
|
||||
|
|
|
|||
|
|
@ -42,10 +42,12 @@ class ServerBanner extends PureComponent {
|
|||
return (
|
||||
<div className='server-banner'>
|
||||
<div className='server-banner__introduction'>
|
||||
<FormattedMessage id='server_banner.introduction' defaultMessage='{domain} is part of the decentralized social network that includes {mastodon}.' values={{ domain: <strong>{domain}</strong>, mastodon: <a href='https://joinmastodon.org' target='_blank'>Mastodon</a> }} />
|
||||
<FormattedMessage id='server_banner.is_one_of_many' defaultMessage='{domain} is one of the many independent Mastodon servers you can use to participate in the fediverse.' values={{ domain: <strong>{domain}</strong>, mastodon: <a href='https://joinmastodon.org' target='_blank'>Mastodon</a> }} />
|
||||
</div>
|
||||
|
||||
<ServerHeroImage blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} className='server-banner__hero' />
|
||||
<Link to='/about'>
|
||||
<ServerHeroImage blurhash={server.getIn(['thumbnail', 'blurhash'])} src={server.getIn(['thumbnail', 'url'])} className='server-banner__hero' />
|
||||
</Link>
|
||||
|
||||
<div className='server-banner__description'>
|
||||
{isLoading ? (
|
||||
|
|
@ -84,10 +86,6 @@ class ServerBanner extends PureComponent {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className='spacer' />
|
||||
|
||||
<Link className='button button--block button-secondary' to='/about'><FormattedMessage id='server_banner.learn_more' defaultMessage='Learn more' /></Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ const ShortNumberCounter: React.FC<ShortNumberCounterProps> = ({ value }) => {
|
|||
|
||||
const count = (
|
||||
<FormattedNumber
|
||||
value={rawNumber}
|
||||
value={rawNumber ?? 0}
|
||||
maximumFractionDigits={maxFractionDigits}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,14 +9,21 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import PushPinIcon from '@/material-icons/400-24px/push_pin.svg?react';
|
||||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||
import { ContentWarning } from 'mastodon/components/content_warning';
|
||||
import { FilterWarning } from 'mastodon/components/filter_warning';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
|
||||
import { withOptionalRouter, WithOptionalRouterPropTypes } from 'mastodon/utils/react_router';
|
||||
|
||||
import Card from '../features/status/components/card';
|
||||
// We use the component (and not the container) since we do not want
|
||||
// to use the progress bar to show download progress
|
||||
import Bundle from '../features/ui/components/bundle';
|
||||
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
|
||||
import { SensitiveMediaContext } from '../features/ui/util/sensitive_media_context';
|
||||
import { displayMedia } from '../initial_state';
|
||||
|
||||
import { Avatar } from './avatar';
|
||||
|
|
@ -26,6 +33,8 @@ import { getHashtagBarForStatus } from './hashtag_bar';
|
|||
import { RelativeTimestamp } from './relative_timestamp';
|
||||
import StatusActionBar from './status_action_bar';
|
||||
import StatusContent from './status_content';
|
||||
import { StatusThreadLabel } from './status_thread_label';
|
||||
import { VisibilityIcon } from './visibility_icon';
|
||||
|
||||
const domParser = new DOMParser();
|
||||
|
||||
|
|
@ -64,21 +73,19 @@ export const defaultMediaVisibility = (status) => {
|
|||
|
||||
const messages = defineMessages({
|
||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
|
||||
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers only' },
|
||||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Mentioned people only' },
|
||||
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Quiet public' },
|
||||
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers' },
|
||||
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Specific people' },
|
||||
edited: { id: 'status.edited', defaultMessage: 'Edited {date}' },
|
||||
});
|
||||
|
||||
class Status extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
};
|
||||
static contextType = SensitiveMediaContext;
|
||||
|
||||
static propTypes = {
|
||||
status: ImmutablePropTypes.map,
|
||||
account: ImmutablePropTypes.map,
|
||||
account: ImmutablePropTypes.record,
|
||||
previousId: PropTypes.string,
|
||||
nextInReplyToId: PropTypes.string,
|
||||
rootId: PropTypes.string,
|
||||
|
|
@ -111,11 +118,15 @@ class Status extends ImmutablePureComponent {
|
|||
cacheMediaWidth: PropTypes.func,
|
||||
cachedMediaWidth: PropTypes.number,
|
||||
scrollKey: PropTypes.string,
|
||||
skipPrepend: PropTypes.bool,
|
||||
avatarSize: PropTypes.number,
|
||||
deployPictureInPicture: PropTypes.func,
|
||||
unfocusable: PropTypes.bool,
|
||||
pictureInPicture: ImmutablePropTypes.contains({
|
||||
inUse: PropTypes.bool,
|
||||
available: PropTypes.bool,
|
||||
}),
|
||||
...WithOptionalRouterPropTypes,
|
||||
};
|
||||
|
||||
// Avoid checking props that are functions (and whose equality will always
|
||||
|
|
@ -130,19 +141,21 @@ class Status extends ImmutablePureComponent {
|
|||
];
|
||||
|
||||
state = {
|
||||
showMedia: defaultMediaVisibility(this.props.status),
|
||||
statusId: undefined,
|
||||
forceFilter: undefined,
|
||||
showMedia: defaultMediaVisibility(this.props.status) && !(this.context?.hideMediaByDefault),
|
||||
showDespiteFilter: undefined,
|
||||
};
|
||||
|
||||
static getDerivedStateFromProps(nextProps, prevState) {
|
||||
if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
|
||||
return {
|
||||
showMedia: defaultMediaVisibility(nextProps.status),
|
||||
statusId: nextProps.status.get('id'),
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
componentDidUpdate (prevProps) {
|
||||
// This will potentially cause a wasteful redraw, but in most cases `Status` components are used
|
||||
// with a `key` directly depending on their `id`, preventing re-use of the component across
|
||||
// different IDs.
|
||||
// But just in case this does change, reset the state on status change.
|
||||
|
||||
if (this.props.status?.get('id') !== prevProps.status?.get('id')) {
|
||||
this.setState({
|
||||
showMedia: defaultMediaVisibility(this.props.status) && !(this.context?.hideMediaByDefault),
|
||||
showDespiteFilter: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -199,7 +212,7 @@ class Status extends ImmutablePureComponent {
|
|||
} else if (attachments.getIn([0, 'type']) === 'audio') {
|
||||
return '16 / 9';
|
||||
} else {
|
||||
return (attachments.size === 1 && attachments.getIn([0, 'meta', 'small', 'aspect'])) ? attachments.getIn([0, 'meta', 'small', 'aspect']) : '3 / 2'
|
||||
return (attachments.size === 1 && attachments.getIn([0, 'meta', 'small', 'aspect'])) ? attachments.getIn([0, 'meta', 'small', 'aspect']) : '3 / 2';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -258,7 +271,7 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
handleHotkeyReply = e => {
|
||||
e.preventDefault();
|
||||
this.props.onReply(this._properStatus(), this.context.router.history);
|
||||
this.props.onReply(this._properStatus());
|
||||
};
|
||||
|
||||
handleHotkeyFavourite = () => {
|
||||
|
|
@ -271,7 +284,7 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
handleHotkeyMention = e => {
|
||||
e.preventDefault();
|
||||
this.props.onMention(this._properStatus().get('account'), this.context.router.history);
|
||||
this.props.onMention(this._properStatus().get('account'));
|
||||
};
|
||||
|
||||
handleHotkeyOpen = () => {
|
||||
|
|
@ -280,14 +293,14 @@ class Status extends ImmutablePureComponent {
|
|||
return;
|
||||
}
|
||||
|
||||
const { router } = this.context;
|
||||
const { history } = this.props;
|
||||
const status = this._properStatus();
|
||||
|
||||
if (!router) {
|
||||
if (!history) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
|
||||
history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
|
||||
};
|
||||
|
||||
handleHotkeyOpenProfile = () => {
|
||||
|
|
@ -295,14 +308,14 @@ class Status extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
_openProfile = (proper = true) => {
|
||||
const { router } = this.context;
|
||||
const { history } = this.props;
|
||||
const status = proper ? this._properStatus() : this.props.status;
|
||||
|
||||
if (!router) {
|
||||
if (!history) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.history.push(`/@${status.getIn(['account', 'acct'])}`);
|
||||
history.push(`/@${status.getIn(['account', 'acct'])}`);
|
||||
};
|
||||
|
||||
handleHotkeyMoveUp = e => {
|
||||
|
|
@ -314,20 +327,32 @@ class Status extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
handleHotkeyToggleHidden = () => {
|
||||
this.props.onToggleHidden(this._properStatus());
|
||||
const { onToggleHidden } = this.props;
|
||||
const status = this._properStatus();
|
||||
|
||||
if (status.get('matched_filters')) {
|
||||
const expandedBecauseOfCW = !status.get('hidden') || status.get('spoiler_text').length === 0;
|
||||
const expandedBecauseOfFilter = this.state.showDespiteFilter;
|
||||
|
||||
if (expandedBecauseOfFilter && !expandedBecauseOfCW) {
|
||||
onToggleHidden(status);
|
||||
} else if (expandedBecauseOfFilter && expandedBecauseOfCW) {
|
||||
onToggleHidden(status);
|
||||
this.handleFilterToggle();
|
||||
} else {
|
||||
this.handleFilterToggle();
|
||||
}
|
||||
} else {
|
||||
onToggleHidden(status);
|
||||
}
|
||||
};
|
||||
|
||||
handleHotkeyToggleSensitive = () => {
|
||||
this.handleToggleMediaVisibility();
|
||||
};
|
||||
|
||||
handleUnfilterClick = e => {
|
||||
this.setState({ forceFilter: false });
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
handleFilterClick = () => {
|
||||
this.setState({ forceFilter: true });
|
||||
handleFilterToggle = () => {
|
||||
this.setState(state => ({ ...state, showDespiteFilter: !state.showDespiteFilter }));
|
||||
};
|
||||
|
||||
_properStatus () {
|
||||
|
|
@ -345,7 +370,7 @@ class Status extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
render () {
|
||||
const { intl, hidden, featured, unread, showThread, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId } = this.props;
|
||||
const { intl, hidden, featured, unfocusable, unread, showThread, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId, skipPrepend, avatarSize = 46 } = this.props;
|
||||
|
||||
let { status, account, ...other } = this.props;
|
||||
|
||||
|
|
@ -371,8 +396,8 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
if (hidden) {
|
||||
return (
|
||||
<HotKeys handlers={handlers}>
|
||||
<div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex={0}>
|
||||
<HotKeys handlers={handlers} tabIndex={unfocusable ? null : -1}>
|
||||
<div ref={this.handleRef} className={classNames('status__wrapper', { focusable: !this.props.muted })} tabIndex={unfocusable ? null : 0}>
|
||||
<span>{status.getIn(['account', 'display_name']) || status.getIn(['account', 'username'])}</span>
|
||||
<span>{status.get('content')}</span>
|
||||
</div>
|
||||
|
|
@ -385,29 +410,10 @@ class Status extends ImmutablePureComponent {
|
|||
const connectReply = nextInReplyToId && nextInReplyToId === status.get('id');
|
||||
const matchedFilters = status.get('matched_filters');
|
||||
|
||||
if (this.state.forceFilter === undefined ? matchedFilters : this.state.forceFilter) {
|
||||
const minHandlers = this.props.muted ? {} : {
|
||||
moveUp: this.handleHotkeyMoveUp,
|
||||
moveDown: this.handleHotkeyMoveDown,
|
||||
};
|
||||
|
||||
return (
|
||||
<HotKeys handlers={minHandlers}>
|
||||
<div className='status__wrapper status__wrapper--filtered focusable' tabIndex={0} ref={this.handleRef}>
|
||||
<FormattedMessage id='status.filtered' defaultMessage='Filtered' />: {matchedFilters.join(', ')}.
|
||||
{' '}
|
||||
<button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}>
|
||||
<FormattedMessage id='status.show_filter_reason' defaultMessage='Show anyway' />
|
||||
</button>
|
||||
</div>
|
||||
</HotKeys>
|
||||
);
|
||||
}
|
||||
|
||||
if (featured) {
|
||||
prepend = (
|
||||
<div className='status__prepend'>
|
||||
<div className='status__prepend-icon-wrapper'><Icon id='thumb-tack' className='status__prepend-icon' fixedWidth /></div>
|
||||
<div className='status__prepend__icon'><Icon id='thumb-tack' icon={PushPinIcon} /></div>
|
||||
<FormattedMessage id='status.pinned' defaultMessage='Pinned post' />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -416,8 +422,8 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
prepend = (
|
||||
<div className='status__prepend'>
|
||||
<div className='status__prepend-icon-wrapper'><Icon id='retweet' className='status__prepend-icon' fixedWidth /></div>
|
||||
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
|
||||
<div className='status__prepend__icon'><Icon id='retweet' icon={RepeatIcon} /></div>
|
||||
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} data-hover-card-account={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -428,18 +434,13 @@ class Status extends ImmutablePureComponent {
|
|||
} else if (status.get('visibility') === 'direct') {
|
||||
prepend = (
|
||||
<div className='status__prepend'>
|
||||
<div className='status__prepend-icon-wrapper'><Icon id='at' className='status__prepend-icon' fixedWidth /></div>
|
||||
<div className='status__prepend__icon'><Icon id='at' icon={AlternateEmailIcon} /></div>
|
||||
<FormattedMessage id='status.direct_indicator' defaultMessage='Private mention' />
|
||||
</div>
|
||||
);
|
||||
} else if (showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id'])) {
|
||||
const display_name_html = { __html: status.getIn(['account', 'display_name_html']) };
|
||||
|
||||
} else if (showThread && status.get('in_reply_to_id')) {
|
||||
prepend = (
|
||||
<div className='status__prepend'>
|
||||
<div className='status__prepend-icon-wrapper'><Icon id='reply' className='status__prepend-icon' fixedWidth /></div>
|
||||
<FormattedMessage id='status.replied_to' defaultMessage='Replied to {name}' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
|
||||
</div>
|
||||
<StatusThreadLabel accountId={status.getIn(['account', 'id'])} inReplyToAccountId={status.get('in_reply_to_account_id')} />
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -448,7 +449,25 @@ class Status extends ImmutablePureComponent {
|
|||
} else if (status.get('media_attachments').size > 0) {
|
||||
const language = status.getIn(['translation', 'language']) || status.get('language');
|
||||
|
||||
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||
if (['image', 'gifv'].includes(status.getIn(['media_attachments', 0, 'type'])) || status.get('media_attachments').size > 1) {
|
||||
media = (
|
||||
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
|
||||
{Component => (
|
||||
<Component
|
||||
media={status.get('media_attachments')}
|
||||
lang={language}
|
||||
sensitive={status.get('sensitive')}
|
||||
height={110}
|
||||
onOpenMedia={this.handleOpenMedia}
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
defaultWidth={this.props.cachedMediaWidth}
|
||||
visible={this.state.showMedia}
|
||||
onToggleVisibility={this.handleToggleMediaVisibility}
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
);
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
|
||||
|
||||
|
|
@ -500,24 +519,6 @@ class Status extends ImmutablePureComponent {
|
|||
)}
|
||||
</Bundle>
|
||||
);
|
||||
} else {
|
||||
media = (
|
||||
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
|
||||
{Component => (
|
||||
<Component
|
||||
media={status.get('media_attachments')}
|
||||
lang={language}
|
||||
sensitive={status.get('sensitive')}
|
||||
height={110}
|
||||
onOpenMedia={this.handleOpenMedia}
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
defaultWidth={this.props.cachedMediaWidth}
|
||||
visible={this.state.showMedia}
|
||||
onToggleVisibility={this.handleToggleMediaVisibility}
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
);
|
||||
}
|
||||
} else if (status.get('spoiler_text').length === 0 && status.get('card')) {
|
||||
media = (
|
||||
|
|
@ -531,27 +532,18 @@ class Status extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
if (account === undefined || account === null) {
|
||||
statusAvatar = <Avatar account={status.get('account')} size={46} />;
|
||||
statusAvatar = <Avatar account={status.get('account')} size={avatarSize} />;
|
||||
} else {
|
||||
statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
|
||||
}
|
||||
|
||||
const visibilityIconInfo = {
|
||||
'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) },
|
||||
'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) },
|
||||
'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) },
|
||||
'direct': { icon: 'at', text: intl.formatMessage(messages.direct_short) },
|
||||
};
|
||||
|
||||
const visibilityIcon = visibilityIconInfo[status.get('visibility')];
|
||||
|
||||
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
|
||||
const expanded = !status.get('hidden') || status.get('spoiler_text').length === 0;
|
||||
const expanded = (!matchedFilters || this.state.showDespiteFilter) && (!status.get('hidden') || status.get('spoiler_text').length === 0);
|
||||
|
||||
return (
|
||||
<HotKeys handlers={handlers}>
|
||||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
|
||||
{prepend}
|
||||
<HotKeys handlers={handlers} tabIndex={unfocusable ? null : -1}>
|
||||
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted || unfocusable ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
|
||||
{!skipPrepend && prepend}
|
||||
|
||||
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted })} data-id={status.get('id')}>
|
||||
{(connectReply || connectUp || connectToRoot) && <div className={classNames('status__line', { 'status__line--full': connectReply, 'status__line--first': !status.get('in_reply_to_id') && !connectToRoot })} />}
|
||||
|
|
@ -559,11 +551,11 @@ class Status extends ImmutablePureComponent {
|
|||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||
<div onClick={this.handleClick} className='status__info'>
|
||||
<a href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
|
||||
<span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
|
||||
<RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { hour12: false, year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
|
||||
<span className='status__visibility-icon'><VisibilityIcon visibility={status.get('visibility')} /></span>
|
||||
<RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
|
||||
</a>
|
||||
|
||||
<a onClick={this.handleAccountClick} href={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
|
||||
<a onClick={this.handleAccountClick} href={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} data-hover-card-account={status.getIn(['account', 'id'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
|
||||
<div className='status__avatar'>
|
||||
{statusAvatar}
|
||||
</div>
|
||||
|
|
@ -572,22 +564,27 @@ class Status extends ImmutablePureComponent {
|
|||
</a>
|
||||
</div>
|
||||
|
||||
<StatusContent
|
||||
status={status}
|
||||
onClick={this.handleClick}
|
||||
expanded={expanded}
|
||||
onExpandedToggle={this.handleExpandedToggle}
|
||||
onTranslate={this.handleTranslate}
|
||||
collapsible
|
||||
onCollapsedToggle={this.handleCollapsedToggle}
|
||||
{...statusContentProps}
|
||||
/>
|
||||
{matchedFilters && <FilterWarning title={matchedFilters.join(', ')} expanded={this.state.showDespiteFilter} onClick={this.handleFilterToggle} />}
|
||||
|
||||
{media}
|
||||
{(status.get('spoiler_text').length > 0 && (!matchedFilters || this.state.showDespiteFilter)) && <ContentWarning text={status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml')} expanded={expanded} onClick={this.handleExpandedToggle} />}
|
||||
|
||||
{expanded && hashtagBar}
|
||||
{expanded && (
|
||||
<>
|
||||
<StatusContent
|
||||
status={status}
|
||||
onClick={this.handleClick}
|
||||
onTranslate={this.handleTranslate}
|
||||
collapsible
|
||||
onCollapsedToggle={this.handleCollapsedToggle}
|
||||
{...statusContentProps}
|
||||
/>
|
||||
|
||||
<StatusActionBar scrollKey={scrollKey} status={status} account={account} onFilter={matchedFilters ? this.handleFilterClick : null} {...other} />
|
||||
{media}
|
||||
{hashtagBar}
|
||||
</>
|
||||
)}
|
||||
|
||||
<StatusActionBar scrollKey={scrollKey} status={status} account={account} {...other} />
|
||||
</div>
|
||||
</div>
|
||||
</HotKeys>
|
||||
|
|
@ -596,4 +593,4 @@ class Status extends ImmutablePureComponent {
|
|||
|
||||
}
|
||||
|
||||
export default injectIntl(Status);
|
||||
export default withOptionalRouter(injectIntl(Status));
|
||||
|
|
|
|||
|
|
@ -3,12 +3,27 @@ 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';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg';
|
||||
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';
|
||||
|
||||
import DropdownMenuContainer from '../containers/dropdown_menu_container';
|
||||
import { me } from '../initial_state';
|
||||
|
|
@ -40,12 +55,11 @@ const messages = defineMessages({
|
|||
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
|
||||
pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
|
||||
unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
|
||||
embed: { id: 'status.embed', defaultMessage: 'Embed' },
|
||||
embed: { id: 'status.embed', defaultMessage: 'Get embed code' },
|
||||
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
|
||||
admin_status: { id: 'status.admin_status', defaultMessage: 'Open this post in the moderation interface' },
|
||||
admin_domain: { id: 'status.admin_domain', defaultMessage: 'Open moderation interface for {domain}' },
|
||||
copy: { id: 'status.copy', defaultMessage: 'Copy link to post' },
|
||||
hide: { id: 'status.hide', defaultMessage: 'Hide post' },
|
||||
blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
|
||||
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
|
||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||
|
|
@ -59,15 +73,10 @@ const mapStateToProps = (state, { status }) => ({
|
|||
});
|
||||
|
||||
class StatusActionBar extends ImmutablePureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
identity: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
relationship: ImmutablePropTypes.map,
|
||||
relationship: ImmutablePropTypes.record,
|
||||
onReply: PropTypes.func,
|
||||
onFavourite: PropTypes.func,
|
||||
onReblog: PropTypes.func,
|
||||
|
|
@ -92,6 +101,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
withCounters: PropTypes.bool,
|
||||
scrollKey: PropTypes.string,
|
||||
intl: PropTypes.object.isRequired,
|
||||
...WithRouterPropTypes,
|
||||
};
|
||||
|
||||
// Avoid checking props that are functions (and whose equality will always
|
||||
|
|
@ -103,10 +113,10 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
];
|
||||
|
||||
handleReplyClick = () => {
|
||||
const { signedIn } = this.context.identity;
|
||||
const { signedIn } = this.props.identity;
|
||||
|
||||
if (signedIn) {
|
||||
this.props.onReply(this.props.status, this.context.router.history);
|
||||
this.props.onReply(this.props.status);
|
||||
} else {
|
||||
this.props.onInteractionModal('reply', this.props.status);
|
||||
}
|
||||
|
|
@ -121,7 +131,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
handleFavouriteClick = () => {
|
||||
const { signedIn } = this.context.identity;
|
||||
const { signedIn } = this.props.identity;
|
||||
|
||||
if (signedIn) {
|
||||
this.props.onFavourite(this.props.status);
|
||||
|
|
@ -131,7 +141,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
handleReblogClick = e => {
|
||||
const { signedIn } = this.context.identity;
|
||||
const { signedIn } = this.props.identity;
|
||||
|
||||
if (signedIn) {
|
||||
this.props.onReblog(this.props.status, e);
|
||||
|
|
@ -145,15 +155,15 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
handleDeleteClick = () => {
|
||||
this.props.onDelete(this.props.status, this.context.router.history);
|
||||
this.props.onDelete(this.props.status);
|
||||
};
|
||||
|
||||
handleRedraftClick = () => {
|
||||
this.props.onDelete(this.props.status, this.context.router.history, true);
|
||||
this.props.onDelete(this.props.status, true);
|
||||
};
|
||||
|
||||
handleEditClick = () => {
|
||||
this.props.onEdit(this.props.status, this.context.router.history);
|
||||
this.props.onEdit(this.props.status);
|
||||
};
|
||||
|
||||
handlePinClick = () => {
|
||||
|
|
@ -161,11 +171,11 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
handleMentionClick = () => {
|
||||
this.props.onMention(this.props.status.get('account'), this.context.router.history);
|
||||
this.props.onMention(this.props.status.get('account'));
|
||||
};
|
||||
|
||||
handleDirectClick = () => {
|
||||
this.props.onDirect(this.props.status.get('account'), this.context.router.history);
|
||||
this.props.onDirect(this.props.status.get('account'));
|
||||
};
|
||||
|
||||
handleMuteClick = () => {
|
||||
|
|
@ -194,7 +204,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
const { status, onBlockDomain } = this.props;
|
||||
const account = status.get('account');
|
||||
|
||||
onBlockDomain(account.get('acct').split('@')[1]);
|
||||
onBlockDomain(account);
|
||||
};
|
||||
|
||||
handleUnblockDomain = () => {
|
||||
|
|
@ -205,7 +215,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
handleOpen = () => {
|
||||
this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}`);
|
||||
this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}`);
|
||||
};
|
||||
|
||||
handleEmbed = () => {
|
||||
|
|
@ -229,13 +239,9 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
navigator.clipboard.writeText(url);
|
||||
};
|
||||
|
||||
handleHideClick = () => {
|
||||
this.props.onFilter();
|
||||
};
|
||||
|
||||
render () {
|
||||
const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props;
|
||||
const { signedIn, permissions } = this.context.identity;
|
||||
const { signedIn, permissions } = this.props.identity;
|
||||
|
||||
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
||||
const pinnableStatus = ['public', 'unlisted', 'private'].includes(status.get('visibility'));
|
||||
|
|
@ -334,48 +340,60 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
let replyIcon;
|
||||
let replyIconComponent;
|
||||
let replyTitle;
|
||||
|
||||
if (status.get('in_reply_to_id', null) === null) {
|
||||
replyIcon = 'reply';
|
||||
replyIconComponent = ReplyIcon;
|
||||
replyTitle = intl.formatMessage(messages.reply);
|
||||
} else {
|
||||
replyIcon = 'reply-all';
|
||||
replyIconComponent = ReplyAllIcon;
|
||||
replyTitle = intl.formatMessage(messages.replyAll);
|
||||
}
|
||||
|
||||
const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
|
||||
|
||||
let reblogTitle = '';
|
||||
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 filterButton = this.props.onFilter && (
|
||||
<IconButton className='status__action-bar__button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleHideClick} />
|
||||
);
|
||||
const isReply = status.get('in_reply_to_account_id') === status.getIn(['account', 'id']);
|
||||
|
||||
return (
|
||||
<div className='status__action-bar'>
|
||||
<IconButton className='status__action-bar__button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} />
|
||||
<IconButton className={classNames('status__action-bar__button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
|
||||
<IconButton className='status__action-bar__button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
|
||||
<IconButton className='status__action-bar__button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />
|
||||
|
||||
{filterButton}
|
||||
|
||||
<div className='status__action-bar__dropdown'>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<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'>
|
||||
<IconButton className={classNames('status__action-bar__button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' iconComponent={reblogIconComponent} onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
|
||||
</div>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<IconButton className='status__action-bar__button star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon} onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
|
||||
</div>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<IconButton className='status__action-bar__button bookmark-icon' disabled={!signedIn} active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' iconComponent={status.get('bookmarked') ? BookmarkIcon : BookmarkBorderIcon} onClick={this.handleBookmarkClick} />
|
||||
</div>
|
||||
<div className='status__action-bar__button-wrapper'>
|
||||
<DropdownMenuContainer
|
||||
scrollKey={scrollKey}
|
||||
status={status}
|
||||
items={menu}
|
||||
icon='ellipsis-h'
|
||||
size={18}
|
||||
iconComponent={MoreHorizIcon}
|
||||
direction='right'
|
||||
title={intl.formatMessage(messages.more)}
|
||||
/>
|
||||
|
|
@ -386,4 +404,4 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(injectIntl(StatusActionBar));
|
||||
export default withRouter(withIdentity(connect(mapStateToProps)(injectIntl(StatusActionBar))));
|
||||
|
|
|
|||
37
app/javascript/mastodon/components/status_banner.tsx
Normal file
37
app/javascript/mastodon/components/status_banner.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export enum BannerVariant {
|
||||
Yellow = 'yellow',
|
||||
Blue = 'blue',
|
||||
}
|
||||
|
||||
export const StatusBanner: React.FC<{
|
||||
children: React.ReactNode;
|
||||
variant: BannerVariant;
|
||||
expanded?: boolean;
|
||||
onClick?: () => void;
|
||||
}> = ({ children, variant, expanded, onClick }) => (
|
||||
<div
|
||||
className={
|
||||
variant === BannerVariant.Yellow
|
||||
? 'content-warning'
|
||||
: 'content-warning content-warning--filter'
|
||||
}
|
||||
>
|
||||
{children}
|
||||
|
||||
<button className='link-button' onClick={onClick}>
|
||||
{expanded ? (
|
||||
<FormattedMessage
|
||||
id='content_warning.hide'
|
||||
defaultMessage='Hide post'
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='content_warning.show'
|
||||
defaultMessage='Show anyway'
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -4,13 +4,15 @@ import { PureComponent } from 'react';
|
|||
import { FormattedMessage, injectIntl } from 'react-intl';
|
||||
|
||||
import classnames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import PollContainer from 'mastodon/containers/poll_container';
|
||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||
import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state';
|
||||
|
||||
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
||||
|
|
@ -66,27 +68,20 @@ const mapStateToProps = state => ({
|
|||
});
|
||||
|
||||
class StatusContent extends PureComponent {
|
||||
|
||||
static contextTypes = {
|
||||
router: PropTypes.object,
|
||||
identity: PropTypes.object,
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
identity: identityContextPropShape,
|
||||
status: ImmutablePropTypes.map.isRequired,
|
||||
statusContent: PropTypes.string,
|
||||
expanded: PropTypes.bool,
|
||||
onExpandedToggle: PropTypes.func,
|
||||
onTranslate: PropTypes.func,
|
||||
onClick: PropTypes.func,
|
||||
collapsible: PropTypes.bool,
|
||||
onCollapsedToggle: PropTypes.func,
|
||||
languages: ImmutablePropTypes.map,
|
||||
intl: PropTypes.object,
|
||||
};
|
||||
|
||||
state = {
|
||||
hidden: true,
|
||||
// from react-router
|
||||
match: PropTypes.object.isRequired,
|
||||
location: PropTypes.object.isRequired,
|
||||
history: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
_updateStatusLinks () {
|
||||
|
|
@ -116,6 +111,7 @@ class StatusContent extends PureComponent {
|
|||
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
|
||||
link.setAttribute('title', `@${mention.get('acct')}`);
|
||||
link.setAttribute('href', `/@${mention.get('acct')}`);
|
||||
link.setAttribute('data-hover-card-account', mention.get('id'));
|
||||
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
|
||||
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
|
||||
link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`);
|
||||
|
|
@ -173,18 +169,18 @@ class StatusContent extends PureComponent {
|
|||
}
|
||||
|
||||
onMentionClick = (mention, e) => {
|
||||
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
if (this.props.history && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(`/@${mention.get('acct')}`);
|
||||
this.props.history.push(`/@${mention.get('acct')}`);
|
||||
}
|
||||
};
|
||||
|
||||
onHashtagClick = (hashtag, e) => {
|
||||
hashtag = hashtag.replace(/^#/, '');
|
||||
|
||||
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
if (this.props.history && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
this.context.router.history.push(`/tags/${hashtag}`);
|
||||
this.props.history.push(`/tags/${hashtag}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -215,17 +211,6 @@ class StatusContent extends PureComponent {
|
|||
this.startXY = null;
|
||||
};
|
||||
|
||||
handleSpoilerClick = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (this.props.onExpandedToggle) {
|
||||
// The parent manages the state
|
||||
this.props.onExpandedToggle();
|
||||
} else {
|
||||
this.setState({ hidden: !this.state.hidden });
|
||||
}
|
||||
};
|
||||
|
||||
handleTranslate = () => {
|
||||
this.props.onTranslate();
|
||||
};
|
||||
|
|
@ -237,24 +222,21 @@ class StatusContent extends PureComponent {
|
|||
render () {
|
||||
const { status, intl, statusContent } = this.props;
|
||||
|
||||
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
|
||||
const renderReadMore = this.props.onClick && status.get('collapsed');
|
||||
const contentLocale = intl.locale.replace(/[_-].*/, '');
|
||||
const targetLanguages = this.props.languages?.get(status.get('language') || 'und');
|
||||
const renderTranslate = this.props.onTranslate && this.context.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale);
|
||||
const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale);
|
||||
|
||||
const content = { __html: statusContent ?? getStatusContent(status) };
|
||||
const spoilerContent = { __html: status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml') };
|
||||
const language = status.getIn(['translation', 'language']) || status.get('language');
|
||||
const classNames = classnames('status__content', {
|
||||
'status__content--with-action': this.props.onClick && this.context.router,
|
||||
'status__content--with-spoiler': status.get('spoiler_text').length > 0,
|
||||
'status__content--with-action': this.props.onClick && this.props.history,
|
||||
'status__content--collapsed': renderReadMore,
|
||||
});
|
||||
|
||||
const readMoreButton = renderReadMore && (
|
||||
<button className='status__content__read-more-button' onClick={this.props.onClick} key='read-more'>
|
||||
<FormattedMessage id='status.read_more' defaultMessage='Read more' /><Icon id='angle-right' fixedWidth />
|
||||
<FormattedMessage id='status.read_more' defaultMessage='Read more' /><Icon id='angle-right' icon={ChevronRightIcon} />
|
||||
</button>
|
||||
);
|
||||
|
||||
|
|
@ -266,38 +248,7 @@ class StatusContent extends PureComponent {
|
|||
<PollContainer pollId={status.get('poll')} lang={language} />
|
||||
);
|
||||
|
||||
if (status.get('spoiler_text').length > 0) {
|
||||
let mentionsPlaceholder = '';
|
||||
|
||||
const mentionLinks = status.get('mentions').map(item => (
|
||||
<Link to={`/@${item.get('acct')}`} key={item.get('id')} className='status-link mention'>
|
||||
@<span>{item.get('username')}</span>
|
||||
</Link>
|
||||
)).reduce((aggregate, item) => [...aggregate, item, ' '], []);
|
||||
|
||||
const toggleText = hidden ? <FormattedMessage id='status.show_more' defaultMessage='Show more' /> : <FormattedMessage id='status.show_less' defaultMessage='Show less' />;
|
||||
|
||||
if (hidden) {
|
||||
mentionsPlaceholder = <div>{mentionLinks}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames} ref={this.setRef} tabIndex={0} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
<p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}>
|
||||
<span dangerouslySetInnerHTML={spoilerContent} className='translate' lang={language} />
|
||||
{' '}
|
||||
<button type='button' className={`status__content__spoiler-link ${hidden ? 'status__content__spoiler-link--show-more' : 'status__content__spoiler-link--show-less'}`} onClick={this.handleSpoilerClick} aria-expanded={!hidden}>{toggleText}</button>
|
||||
</p>
|
||||
|
||||
{mentionsPlaceholder}
|
||||
|
||||
<div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''} translate`} lang={language} dangerouslySetInnerHTML={content} />
|
||||
|
||||
{!hidden && poll}
|
||||
{translateButton}
|
||||
</div>
|
||||
);
|
||||
} else if (this.props.onClick) {
|
||||
if (this.props.onClick) {
|
||||
return (
|
||||
<>
|
||||
<div className={classNames} ref={this.setRef} tabIndex={0} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
|
|
@ -324,4 +275,4 @@ class StatusContent extends PureComponent {
|
|||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(injectIntl(StatusContent));
|
||||
export default withRouter(withIdentity(connect(mapStateToProps)(injectIntl(StatusContent))));
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines';
|
||||
import RegenerationIndicator from 'mastodon/components/regeneration_indicator';
|
||||
import { InlineFollowSuggestions } from 'mastodon/features/home_timeline/components/inline_follow_suggestions';
|
||||
|
||||
import StatusContainer from '../containers/status_container';
|
||||
|
||||
|
|
@ -31,6 +33,7 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
withCounters: PropTypes.bool,
|
||||
timelineId: PropTypes.string,
|
||||
lastId: PropTypes.string,
|
||||
bindToDocument: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
|
|
@ -91,25 +94,38 @@ export default class StatusList extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
let scrollableContent = (isLoading || statusIds.size > 0) ? (
|
||||
statusIds.map((statusId, index) => statusId === null ? (
|
||||
<LoadGap
|
||||
key={'gap:' + statusIds.get(index + 1)}
|
||||
disabled={isLoading}
|
||||
maxId={index > 0 ? statusIds.get(index - 1) : null}
|
||||
onClick={onLoadMore}
|
||||
/>
|
||||
) : (
|
||||
<StatusContainer
|
||||
key={statusId}
|
||||
id={statusId}
|
||||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
contextType={timelineId}
|
||||
scrollKey={this.props.scrollKey}
|
||||
showThread
|
||||
withCounters={this.props.withCounters}
|
||||
/>
|
||||
))
|
||||
statusIds.map((statusId, index) => {
|
||||
switch(statusId) {
|
||||
case TIMELINE_SUGGESTIONS:
|
||||
return (
|
||||
<InlineFollowSuggestions
|
||||
key='inline-follow-suggestions'
|
||||
/>
|
||||
);
|
||||
case TIMELINE_GAP:
|
||||
return (
|
||||
<LoadGap
|
||||
key={'gap:' + statusIds.get(index + 1)}
|
||||
disabled={isLoading}
|
||||
param={index > 0 ? statusIds.get(index - 1) : null}
|
||||
onClick={onLoadMore}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<StatusContainer
|
||||
key={statusId}
|
||||
id={statusId}
|
||||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
contextType={timelineId}
|
||||
scrollKey={this.props.scrollKey}
|
||||
showThread
|
||||
withCounters={this.props.withCounters}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})
|
||||
) : null;
|
||||
|
||||
if (scrollableContent && featuredStatusIds) {
|
||||
|
|
|
|||
50
app/javascript/mastodon/components/status_thread_label.tsx
Normal file
50
app/javascript/mastodon/components/status_thread_label.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { DisplayedName } from 'mastodon/features/notifications_v2/components/displayed_name';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
export const StatusThreadLabel: React.FC<{
|
||||
accountId: string;
|
||||
inReplyToAccountId: string;
|
||||
}> = ({ accountId, inReplyToAccountId }) => {
|
||||
const inReplyToAccount = useAppSelector((state) =>
|
||||
state.accounts.get(inReplyToAccountId),
|
||||
);
|
||||
|
||||
let label;
|
||||
|
||||
if (accountId === inReplyToAccountId) {
|
||||
label = (
|
||||
<FormattedMessage
|
||||
id='status.continued_thread'
|
||||
defaultMessage='Continued thread'
|
||||
/>
|
||||
);
|
||||
} else if (inReplyToAccount) {
|
||||
label = (
|
||||
<FormattedMessage
|
||||
id='status.replied_to'
|
||||
defaultMessage='Replied to {name}'
|
||||
values={{ name: <DisplayedName accountIds={[inReplyToAccountId]} /> }}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
label = (
|
||||
<FormattedMessage
|
||||
id='status.replied_in_thread'
|
||||
defaultMessage='Replied in thread'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='status__prepend'>
|
||||
<div className='status__prepend__icon'>
|
||||
<Icon id='reply' icon={ReplyIcon} />
|
||||
</div>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,25 +1,23 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
resource: JSX.Element;
|
||||
message: React.ReactNode;
|
||||
label: React.ReactNode;
|
||||
url: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const TimelineHint: React.FC<Props> = ({ resource, url }) => (
|
||||
<div className='timeline-hint'>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id='timeline_hint.remote_resource_not_displayed'
|
||||
defaultMessage='{resource} from other servers are not displayed.'
|
||||
values={{ resource }}
|
||||
/>
|
||||
</strong>
|
||||
<br />
|
||||
export const TimelineHint: React.FC<Props> = ({
|
||||
className,
|
||||
message,
|
||||
label,
|
||||
url,
|
||||
}) => (
|
||||
<div className={classNames('timeline-hint', className)}>
|
||||
<p>{message}</p>
|
||||
|
||||
<a href={url} target='_blank' rel='noopener noreferrer'>
|
||||
<FormattedMessage
|
||||
id='account.browse_more_on_origin_server'
|
||||
defaultMessage='Browse more on the original profile'
|
||||
/>
|
||||
{label}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||
|
||||
import { Icon } from './icon';
|
||||
|
||||
const domParser = new DOMParser();
|
||||
|
|
@ -21,7 +23,7 @@ interface Props {
|
|||
}
|
||||
export const VerifiedBadge: React.FC<Props> = ({ link }) => (
|
||||
<span className='verified-badge'>
|
||||
<Icon id='check' className='verified-badge__mark' />
|
||||
<Icon id='check' icon={CheckIcon} className='verified-badge__mark' />
|
||||
<span dangerouslySetInnerHTML={stripRelMe(link)} />
|
||||
</span>
|
||||
);
|
||||
|
|
|
|||
64
app/javascript/mastodon/components/visibility_icon.tsx
Normal file
64
app/javascript/mastodon/components/visibility_icon.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
||||
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
|
||||
import type { StatusVisibility } from 'mastodon/models/status';
|
||||
|
||||
import { Icon } from './icon';
|
||||
|
||||
const messages = defineMessages({
|
||||
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
|
||||
unlisted_short: {
|
||||
id: 'privacy.unlisted.short',
|
||||
defaultMessage: 'Quiet public',
|
||||
},
|
||||
private_short: {
|
||||
id: 'privacy.private.short',
|
||||
defaultMessage: 'Followers',
|
||||
},
|
||||
direct_short: {
|
||||
id: 'privacy.direct.short',
|
||||
defaultMessage: 'Specific people',
|
||||
},
|
||||
});
|
||||
|
||||
export const VisibilityIcon: React.FC<{ visibility: StatusVisibility }> = ({
|
||||
visibility,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const visibilityIconInfo = {
|
||||
public: {
|
||||
icon: 'globe',
|
||||
iconComponent: PublicIcon,
|
||||
text: intl.formatMessage(messages.public_short),
|
||||
},
|
||||
unlisted: {
|
||||
icon: 'unlock',
|
||||
iconComponent: QuietTimeIcon,
|
||||
text: intl.formatMessage(messages.unlisted_short),
|
||||
},
|
||||
private: {
|
||||
icon: 'lock',
|
||||
iconComponent: LockIcon,
|
||||
text: intl.formatMessage(messages.private_short),
|
||||
},
|
||||
direct: {
|
||||
icon: 'at',
|
||||
iconComponent: AlternateEmailIcon,
|
||||
text: intl.formatMessage(messages.direct_short),
|
||||
},
|
||||
};
|
||||
|
||||
const visibilityIcon = visibilityIconInfo[visibility];
|
||||
|
||||
return (
|
||||
<Icon
|
||||
id={visibilityIcon.icon}
|
||||
icon={visibilityIcon.iconComponent}
|
||||
title={visibilityIcon.text}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue