Merge tag 'v4.3.0-rc.1'

This commit is contained in:
Mike Barnes 2024-10-02 10:34:27 +10:00
commit 26c9b9ba39
3459 changed files with 130932 additions and 69993 deletions

View file

@ -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>
`;

View file

@ -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>

View file

@ -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', () => {

View file

@ -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'],

View file

@ -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;

View 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}
/>
);
};

View 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>
);
};

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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>

View file

@ -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'>

View file

@ -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,

View 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>
</>
);
};

View file

@ -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} />

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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>
);

View file

@ -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>
);
}

View file

@ -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;

View file

@ -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>
);
};

View file

@ -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 {

View file

@ -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' />} />
);
);

View file

@ -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>
);
}
}

View 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>
);
};

View file

@ -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>
);

View 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>
);
};

View file

@ -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) {

View file

@ -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);
}
}
}
}

View 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>;
};

View file

@ -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>
);
}
}

View file

@ -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);

View 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;

View 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>
);

View 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,
};

View 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>
);
};

View file

@ -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}
/>

View file

@ -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';

View file

@ -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}
/>

View file

@ -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);

View 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>
);
};

View file

@ -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 }));
},
});

View file

@ -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>
);

View file

@ -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);
}

View 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>
);

View 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>
);
};

View file

@ -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;

View 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>
);

View file

@ -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;
});
}

View 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';

View 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>
);
};

View file

@ -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}
/>
);
};

View file

@ -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>

View file

@ -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>
)}

View file

@ -19,7 +19,7 @@ const makeMapStateToProps = () => {
class InlineAccount extends PureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
account: ImmutablePropTypes.record.isRequired,
};
render () {

View file

@ -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>
);
};

View file

@ -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>
);
};

View file

@ -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' />
);

View file

@ -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}
/>

View file

@ -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;

View file

@ -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);

View 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,
};

View file

@ -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);

View 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>
);

View file

@ -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>
);

View file

@ -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));

View file

@ -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'>

View file

@ -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);

View file

@ -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 }) => {

View file

@ -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}

View file

@ -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>
);
}

View file

@ -48,7 +48,7 @@ const ShortNumberCounter: React.FC<ShortNumberCounterProps> = ({ value }) => {
const count = (
<FormattedNumber
value={rawNumber}
value={rawNumber ?? 0}
maximumFractionDigits={maxFractionDigits}
/>
);

View file

@ -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));

View file

@ -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))));

View 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>
);

View file

@ -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))));

View file

@ -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) {

View 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>
);
};

View file

@ -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>
);

View file

@ -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>
);

View 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}
/>
);
};