Merge commit '2e6bf60f15' into glitch-soc/merge-upstream

Conflicts:
- `README.md`:
  Upstream has updated their README but we have a completely different one.
  Kept our version of `README.md`
This commit is contained in:
Claire 2023-12-10 18:05:02 +01:00
commit 9f92b05bd2
101 changed files with 819 additions and 488 deletions

View file

@ -1,6 +1,7 @@
import { render, fireEvent, screen } from '@testing-library/react';
import renderer from 'react-test-renderer';
import { render, fireEvent, screen } from 'mastodon/test_helpers';
import { Button } from '../button';
describe('<Button />', () => {

View file

@ -1,65 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { createPortal } from 'react-dom';
import { FormattedMessage } from 'react-intl';
import { withRouter } from 'react-router-dom';
import { ReactComponent as ArrowBackIcon } from '@material-symbols/svg-600/outlined/arrow_back.svg';
import { Icon } from 'mastodon/components/icon';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
export class ColumnBackButton extends PureComponent {
static propTypes = {
multiColumn: PropTypes.bool,
onClick: PropTypes.func,
...WithRouterPropTypes,
};
handleClick = () => {
const { onClick, history } = this.props;
if (onClick) {
onClick();
} else if (history.location?.state?.fromMastodon) {
history.goBack();
} else {
history.push('/');
}
};
render () {
const { multiColumn } = this.props;
const component = (
<button onClick={this.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>
);
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);
}
}
}
}
export default withRouter(ColumnBackButton);

View file

@ -0,0 +1,45 @@
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { ReactComponent as ArrowBackIcon } from '@material-symbols/svg-600/outlined/arrow_back.svg';
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 { ReactComponent as ArrowBackIcon } from '@material-symbols/svg-600/outlined/arrow_back.svg';
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' icon={ArrowBackIcon} className='column-back-button__icon' />
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
</div>
</div>
);
}
}

View file

@ -1,6 +1,5 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { createPortal } from 'react-dom';
import { PureComponent, useCallback } from 'react';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
@ -15,8 +14,11 @@ import { ReactComponent as CloseIcon } from '@material-symbols/svg-600/outlined/
import { ReactComponent as TuneIcon } from '@material-symbols/svg-600/outlined/tune.svg';
import { Icon } from 'mastodon/components/icon';
import { ButtonInTabsBar, useColumnsContext } from 'mastodon/features/ui/util/columns_context';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
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' },
@ -24,6 +26,34 @@ const messages = defineMessages({
moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' },
});
const BackButton = ({ pinned, show }) => {
const history = useAppHistory();
const { multiColumn } = useColumnsContext();
const handleBackClick = useCallback(() => {
if (history.location?.state?.fromMastodon) {
history.goBack();
} else {
history.push('/');
}
}, [history]);
const showButton = history && !pinned && ((multiColumn && history.location?.state?.fromMastodon) || show);
if(!showButton) return null;
return (<button onClick={handleBackClick} className='column-header__back-button'>
<Icon id='chevron-left' icon={ArrowBackIcon} className='column-back-button__icon' />
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
</button>);
};
BackButton.propTypes = {
pinned: PropTypes.bool,
show: PropTypes.bool,
};
class ColumnHeader extends PureComponent {
static contextTypes = {
@ -72,16 +102,6 @@ class ColumnHeader extends PureComponent {
this.props.onMove(1);
};
handleBackClick = () => {
const { history } = this.props;
if (history.location?.state?.fromMastodon) {
history.goBack();
} else {
history.push('/');
}
};
handleTransitionEnd = () => {
this.setState({ animating: false });
};
@ -95,7 +115,7 @@ class ColumnHeader extends PureComponent {
};
render () {
const { title, icon, iconComponent, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues, history } = this.props;
const { title, icon, iconComponent, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props;
const { collapsed, animating } = this.state;
const wrapperClassName = classNames('column-header__wrapper', {
@ -138,14 +158,7 @@ class ColumnHeader extends PureComponent {
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' icon={AddIcon} /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>;
}
if (!pinned && ((multiColumn && history.location?.state?.fromMastodon) || showBackButton)) {
backButton = (
<button onClick={this.handleBackClick} className='column-header__back-button'>
<Icon id='chevron-left' icon={ArrowBackIcon} className='column-back-button__icon' />
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
</button>
);
}
backButton = <BackButton pinned={pinned} show={showBackButton} />;
const collapsedContent = [
extraContent,
@ -203,22 +216,12 @@ class ColumnHeader extends PureComponent {
</div>
);
if (multiColumn || placeholder) {
if (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);
}
return (<ButtonInTabsBar>
{component}
</ButtonInTabsBar>);
}
}

View file

@ -1,7 +1,7 @@
import type { PropsWithChildren } from 'react';
import React from 'react';
import { Router as OriginalRouter } from 'react-router';
import { Router as OriginalRouter, useHistory } from 'react-router';
import type {
LocationDescriptor,
@ -16,18 +16,23 @@ interface MastodonLocationState {
fromMastodon?: boolean;
mastodonModalKey?: string;
}
type HistoryPath = Path | LocationDescriptor<MastodonLocationState>;
const browserHistory = createBrowserHistory<
MastodonLocationState | undefined
>();
type LocationState = MastodonLocationState | null | undefined;
type HistoryPath = Path | LocationDescriptor<LocationState>;
const browserHistory = createBrowserHistory<LocationState>();
const originalPush = browserHistory.push.bind(browserHistory);
const originalReplace = browserHistory.replace.bind(browserHistory);
export function useAppHistory() {
return useHistory<LocationState>();
}
function normalizePath(
path: HistoryPath,
state?: MastodonLocationState,
): LocationDescriptorObject<MastodonLocationState> {
state?: LocationState,
): LocationDescriptorObject<LocationState> {
const location = typeof path === 'string' ? { pathname: path } : { ...path };
if (location.state === undefined && state !== undefined) {

View file

@ -12,8 +12,8 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { ReactComponent as CheckIcon } from '@material-symbols/svg-600/outlined/check.svg';
import { ReactComponent as LockIcon } from '@material-symbols/svg-600/outlined/lock.svg';
import { ReactComponent as MoreHorizIcon } from '@material-symbols/svg-600/outlined/more_horiz.svg';
import { ReactComponent as NotificationsIcon } from '@material-symbols/svg-600/outlined/notifications-fill.svg';
import { ReactComponent as NotificationsActiveIcon } from '@material-symbols/svg-600/outlined/notifications_active.svg';
import { ReactComponent as NotificationsIcon } from '@material-symbols/svg-600/outlined/notifications.svg';
import { ReactComponent as NotificationsActiveIcon } from '@material-symbols/svg-600/outlined/notifications_active-fill.svg';
import { Avatar } from 'mastodon/components/avatar';
import { Badge, AutomatedBadge, GroupBadge } from 'mastodon/components/badge';
@ -264,7 +264,7 @@ class Header extends ImmutablePureComponent {
}
if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) {
bellBtn = <IconButton icon={account.getIn(['relationship', 'notifying']) ? 'bell' : 'bell-o'} iconComponent={account.getIn(['relationship', 'notifying']) ? NotificationsIcon : NotificationsActiveIcon} size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
bellBtn = <IconButton icon={account.getIn(['relationship', 'notifying']) ? 'bell' : 'bell-o'} iconComponent={account.getIn(['relationship', 'notifying']) ? NotificationsActiveIcon : NotificationsIcon} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
}
if (me !== account.get('id')) {

View file

@ -8,7 +8,7 @@ import { connect } from 'react-redux';
import { lookupAccount, fetchAccount } from 'mastodon/actions/accounts';
import { openModal } from 'mastodon/actions/modal';
import ColumnBackButton from 'mastodon/components/column_back_button';
import { ColumnBackButton } from 'mastodon/components/column_back_button';
import { LoadMore } from 'mastodon/components/load_more';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import ScrollContainer from 'mastodon/containers/scroll_container';
@ -203,7 +203,7 @@ class AccountGallery extends ImmutablePureComponent {
return (
<Column>
<ColumnBackButton multiColumn={multiColumn} />
<ColumnBackButton />
<ScrollContainer scrollKey='account_gallery'>
<div className='scrollable scrollable--flex' onScroll={this.handleScroll}>

View file

@ -16,7 +16,7 @@ import { getAccountHidden } from 'mastodon/selectors';
import { lookupAccount, fetchAccount } from '../../actions/accounts';
import { fetchFeaturedTags } from '../../actions/featured_tags';
import { expandAccountFeaturedTimeline, expandAccountTimeline, connectTimeline, disconnectTimeline } from '../../actions/timelines';
import ColumnBackButton from '../../components/column_back_button';
import { ColumnBackButton } from '../../components/column_back_button';
import { LoadingIndicator } from '../../components/loading_indicator';
import StatusList from '../../components/status_list';
import Column from '../ui/components/column';
@ -184,7 +184,7 @@ class AccountTimeline extends ImmutablePureComponent {
return (
<Column>
<ColumnBackButton multiColumn={multiColumn} />
<ColumnBackButton />
<StatusList
prepend={<HeaderContainer accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={this.props.params.tagged} />}

View file

@ -9,10 +9,10 @@ import { is } from 'immutable';
import { ReactComponent as DownloadIcon } from '@material-symbols/svg-600/outlined/download.svg';
import { ReactComponent as PauseIcon } from '@material-symbols/svg-600/outlined/pause.svg';
import { ReactComponent as PlayArrowIcon } from '@material-symbols/svg-600/outlined/play_arrow.svg';
import { ReactComponent as PlayArrowIcon } from '@material-symbols/svg-600/outlined/play_arrow-fill.svg';
import { ReactComponent as VisibilityOffIcon } from '@material-symbols/svg-600/outlined/visibility_off.svg';
import { ReactComponent as VolumeOffIcon } from '@material-symbols/svg-600/outlined/volume_off.svg';
import { ReactComponent as VolumeUpIcon } from '@material-symbols/svg-600/outlined/volume_up.svg';
import { ReactComponent as VolumeOffIcon } from '@material-symbols/svg-600/outlined/volume_off-fill.svg';
import { ReactComponent as VolumeUpIcon } from '@material-symbols/svg-600/outlined/volume_up-fill.svg';
import { throttle, debounce } from 'lodash';
import { Icon } from 'mastodon/components/icon';

View file

@ -10,7 +10,6 @@ import { ReactComponent as BlockIcon } from '@material-symbols/svg-600/outlined/
import { debounce } from 'lodash';
import { fetchBlocks, expandBlocks } from '../../actions/blocks';
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list';
import AccountContainer from '../../containers/account_container';
@ -60,8 +59,7 @@ class Blocks extends ImmutablePureComponent {
const emptyMessage = <FormattedMessage id='empty_column.blocks' defaultMessage="You haven't blocked any users yet." />;
return (
<Column bindToDocument={!multiColumn} icon='ban' iconComponent={BlockIcon} heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim />
<Column bindToDocument={!multiColumn} icon='ban' iconComponent={BlockIcon} heading={intl.formatMessage(messages.heading)} alwaysShowBackButton>
<ScrollableList
scrollKey='blocks'
onLoadMore={this.handleLoadMore}

View file

@ -12,7 +12,6 @@ import { ReactComponent as BlockIcon } from '@material-symbols/svg-600/outlined/
import { debounce } from 'lodash';
import { fetchDomainBlocks, expandDomainBlocks } from '../../actions/domain_blocks';
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list';
import DomainContainer from '../../containers/domain_container';
@ -61,9 +60,7 @@ class Blocks extends ImmutablePureComponent {
const emptyMessage = <FormattedMessage id='empty_column.domain_blocks' defaultMessage='There are no blocked domains yet.' />;
return (
<Column bindToDocument={!multiColumn} icon='ban' iconComponent={BlockIcon} heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim />
<Column bindToDocument={!multiColumn} icon='ban' iconComponent={BlockIcon} heading={intl.formatMessage(messages.heading)} alwaysShowBackButton>
<ScrollableList
scrollKey='domain_blocks'
onLoadMore={this.handleLoadMore}

View file

@ -12,7 +12,6 @@ import { ReactComponent as PersonAddIcon } from '@material-symbols/svg-600/outli
import { debounce } from 'lodash';
import { fetchFollowRequests, expandFollowRequests } from '../../actions/accounts';
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import ScrollableList from '../../components/scrollable_list';
import { me } from '../../initial_state';
import Column from '../ui/components/column';
@ -68,8 +67,7 @@ class FollowRequests extends ImmutablePureComponent {
);
return (
<Column bindToDocument={!multiColumn} icon='user-plus' iconComponent={PersonAddIcon} heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim />
<Column bindToDocument={!multiColumn} icon='user-plus' iconComponent={PersonAddIcon} heading={intl.formatMessage(messages.heading)} alwaysShowBackButton>
<ScrollableList
scrollKey='follow_requests'
onLoadMore={this.handleLoadMore}

View file

@ -19,7 +19,7 @@ import {
fetchFollowers,
expandFollowers,
} from '../../actions/accounts';
import ColumnBackButton from '../../components/column_back_button';
import { ColumnBackButton } from '../../components/column_back_button';
import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list';
import AccountContainer from '../../containers/account_container';
@ -147,7 +147,7 @@ class Followers extends ImmutablePureComponent {
return (
<Column>
<ColumnBackButton multiColumn={multiColumn} />
<ColumnBackButton />
<ScrollableList
scrollKey='followers'

View file

@ -19,7 +19,7 @@ import {
fetchFollowing,
expandFollowing,
} from '../../actions/accounts';
import ColumnBackButton from '../../components/column_back_button';
import { ColumnBackButton } from '../../components/column_back_button';
import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list';
import AccountContainer from '../../containers/account_container';
@ -147,7 +147,7 @@ class Following extends ImmutablePureComponent {
return (
<Column>
<ColumnBackButton multiColumn={multiColumn} />
<ColumnBackButton />
<ScrollableList
scrollKey='following'

View file

@ -12,7 +12,6 @@ import { ReactComponent as VolumeOffIcon } from '@material-symbols/svg-600/outli
import { debounce } from 'lodash';
import { fetchMutes, expandMutes } from '../../actions/mutes';
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list';
import AccountContainer from '../../containers/account_container';
@ -62,8 +61,7 @@ class Mutes extends ImmutablePureComponent {
const emptyMessage = <FormattedMessage id='empty_column.mutes' defaultMessage="You haven't muted any users yet." />;
return (
<Column bindToDocument={!multiColumn} icon='volume-off' iconComponent={VolumeOffIcon} heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim />
<Column bindToDocument={!multiColumn} icon='volume-off' iconComponent={VolumeOffIcon} heading={intl.formatMessage(messages.heading)} alwaysShowBackButton>
<ScrollableList
scrollKey='mutes'
onLoadMore={this.handleLoadMore}

View file

@ -9,7 +9,7 @@ import { connect } from 'react-redux';
import { fetchSuggestions } from 'mastodon/actions/suggestions';
import { markAsPartial } from 'mastodon/actions/timelines';
import Column from 'mastodon/components/column';
import ColumnBackButton from 'mastodon/components/column_back_button';
import { ColumnBackButton } from 'mastodon/components/column_back_button';
import { EmptyAccount } from 'mastodon/components/empty_account';
import Account from 'mastodon/containers/account_container';
@ -25,7 +25,6 @@ class Follows extends PureComponent {
dispatch: PropTypes.func.isRequired,
suggestions: ImmutablePropTypes.list,
isLoading: PropTypes.bool,
multiColumn: PropTypes.bool,
};
componentDidMount () {
@ -39,7 +38,7 @@ class Follows extends PureComponent {
}
render () {
const { onBack, isLoading, suggestions, multiColumn } = this.props;
const { onBack, isLoading, suggestions } = this.props;
let loadedContent;
@ -53,7 +52,7 @@ class Follows extends PureComponent {
return (
<Column>
<ColumnBackButton multiColumn={multiColumn} onClick={onBack} />
<ColumnBackButton onClick={onBack} />
<div className='scrollable privacy-policy'>
<div className='column-title'>

View file

@ -47,7 +47,6 @@ class Onboarding extends ImmutablePureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
account: ImmutablePropTypes.map,
multiColumn: PropTypes.bool,
...WithRouterPropTypes,
};
@ -100,14 +99,14 @@ class Onboarding extends ImmutablePureComponent {
}
render () {
const { account, multiColumn } = this.props;
const { account } = this.props;
const { step, shareClicked } = this.state;
switch(step) {
case 'follows':
return <Follows onBack={this.handleBackClick} multiColumn={multiColumn} />;
return <Follows onBack={this.handleBackClick} />;
case 'share':
return <Share onBack={this.handleBackClick} multiColumn={multiColumn} />;
return <Share onBack={this.handleBackClick} />;
}
return (

View file

@ -14,7 +14,7 @@ import { ReactComponent as ContentCopyIcon } from '@material-symbols/svg-600/out
import SwipeableViews from 'react-swipeable-views';
import Column from 'mastodon/components/column';
import ColumnBackButton from 'mastodon/components/column_back_button';
import { ColumnBackButton } from 'mastodon/components/column_back_button';
import { Icon } from 'mastodon/components/icon';
import { me, domain } from 'mastodon/initial_state';
@ -146,18 +146,17 @@ class Share extends PureComponent {
static propTypes = {
onBack: PropTypes.func,
account: ImmutablePropTypes.map,
multiColumn: PropTypes.bool,
intl: PropTypes.object,
};
render () {
const { onBack, account, multiColumn, intl } = this.props;
const { onBack, account, intl } = this.props;
const url = (new URL(`/@${account.get('username')}`, document.baseURI)).href;
return (
<Column>
<ColumnBackButton multiColumn={multiColumn} onClick={onBack} />
<ColumnBackButton onClick={onBack} />
<div className='scrollable privacy-policy'>
<div className='column-title'>

View file

@ -13,7 +13,6 @@ import { ReactComponent as PushPinIcon } from '@material-symbols/svg-600/outline
import { getStatusList } from 'mastodon/selectors';
import { fetchPinnedStatuses } from '../../actions/pin_statuses';
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import StatusList from '../../components/status_list';
import Column from '../ui/components/column';
@ -52,8 +51,7 @@ class PinnedStatuses extends ImmutablePureComponent {
const { intl, statusIds, hasMore, multiColumn } = this.props;
return (
<Column bindToDocument={!multiColumn} icon='thumb-tack' iconComponent={PushPinIcon} heading={intl.formatMessage(messages.heading)} ref={this.setRef}>
<ColumnBackButtonSlim />
<Column bindToDocument={!multiColumn} icon='thumb-tack' iconComponent={PushPinIcon} heading={intl.formatMessage(messages.heading)} ref={this.setRef} alwaysShowBackButton>
<StatusList
statusIds={statusIds}
scrollKey='pinned_statuses'

View file

@ -10,9 +10,9 @@ import classNames from 'classnames';
import Immutable from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { ReactComponent as DescriptionIcon } from '@material-symbols/svg-600/outlined/description.svg';
import { ReactComponent as DescriptionIcon } from '@material-symbols/svg-600/outlined/description-fill.svg';
import { ReactComponent as OpenInNewIcon } from '@material-symbols/svg-600/outlined/open_in_new.svg';
import { ReactComponent as PlayArrowIcon } from '@material-symbols/svg-600/outlined/play_arrow.svg';
import { ReactComponent as PlayArrowIcon } from '@material-symbols/svg-600/outlined/play_arrow-fill.svg';
import { Blurhash } from 'mastodon/components/blurhash';
import { Icon } from 'mastodon/components/icon';
@ -143,7 +143,7 @@ export default class Card extends PureComponent {
<strong className='status-card__title' title={card.get('title')} lang={language}>{card.get('title')}</strong>
{card.get('author_name').length > 0 ? <span className='status-card__author'><FormattedMessage id='link_preview.author' defaultMessage='By {name}' values={{ name: <strong>{card.get('author_name')}</strong> }} /></span> : <span className='status-card__description'>{card.get('description')}</span>}
{card.get('author_name').length > 0 ? <span className='status-card__author'><FormattedMessage id='link_preview.author' defaultMessage='By {name}' values={{ name: <strong>{card.get('author_name')}</strong> }} /></span> : <span className='status-card__description' lang={language}>{card.get('description')}</span>}
</div>
);

View file

@ -58,7 +58,7 @@ class DetailedStatus extends ImmutablePureComponent {
handleAccountClick = (e) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey) && this.props.history) {
e.preventDefault();
this.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
}
e.stopPropagation();

View file

@ -1,13 +1,15 @@
import { render, fireEvent, screen } from '@testing-library/react';
import { render, fireEvent, screen } from 'mastodon/test_helpers';
import Column from '../column';
const fakeIcon = () => <span />;
describe('<Column />', () => {
describe('<ColumnHeader /> click handler', () => {
it('runs the scroll animation if the column contains scrollable content', () => {
const scrollToMock = jest.fn();
const { container } = render(
<Column heading='notifications'>
<Column heading='notifications' icon='notifications' iconComponent={fakeIcon}>
<div className='scrollable' />
</Column>,
);
@ -17,7 +19,7 @@ describe('<Column />', () => {
});
it('does not try to scroll if there is no scrollable content', () => {
render(<Column heading='notifications' />);
render(<Column heading='notifications' icon='notifications' iconComponent={fakeIcon} />);
fireEvent.click(screen.getByText('notifications'));
});
});

View file

@ -3,15 +3,15 @@ import { PureComponent } from 'react';
import { debounce } from 'lodash';
import ColumnHeader from '../../../components/column_header';
import { isMobile } from '../../../is_mobile';
import { scrollTop } from '../../../scroll';
import ColumnHeader from './column_header';
export default class Column extends PureComponent {
static propTypes = {
heading: PropTypes.string,
alwaysShowBackButton: PropTypes.bool,
icon: PropTypes.string,
iconComponent: PropTypes.func,
children: PropTypes.node,
@ -51,13 +51,14 @@ export default class Column extends PureComponent {
};
render () {
const { heading, icon, iconComponent, children, active, hideHeadingOnMobile } = this.props;
const { heading, icon, iconComponent, children, active, hideHeadingOnMobile, alwaysShowBackButton } = this.props;
const showHeading = heading && (!hideHeadingOnMobile || (hideHeadingOnMobile && !isMobile(window.innerWidth)));
const columnHeaderId = showHeading && heading.replace(/ /g, '-');
const header = showHeading && (
<ColumnHeader icon={icon} iconComponent={iconComponent} active={active} type={heading} onClick={this.handleHeaderClick} columnHeaderId={columnHeaderId} />
<ColumnHeader icon={icon} iconComponent={iconComponent} active={active} title={heading} onClick={this.handleHeaderClick} columnHeaderId={columnHeaderId} showBackButton={alwaysShowBackButton} />
);
return (
<div

View file

@ -1,41 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import classNames from 'classnames';
import { Icon } from 'mastodon/components/icon';
export default class ColumnHeader extends PureComponent {
static propTypes = {
icon: PropTypes.string,
iconComponent: PropTypes.func,
type: PropTypes.string,
active: PropTypes.bool,
onClick: PropTypes.func,
columnHeaderId: PropTypes.string,
};
handleClick = () => {
this.props.onClick();
};
render () {
const { icon, iconComponent, type, active, columnHeaderId } = this.props;
let iconElement = '';
if (icon) {
iconElement = <Icon id={icon} icon={iconComponent} className='column-header__icon' />;
}
return (
<h1 className={classNames('column-header', { active })} id={columnHeaderId || null}>
<button onClick={this.handleClick}>
{iconElement}
{type}
</button>
</h1>
);
}
}

View file

@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
import { Children, cloneElement } from 'react';
import { Children, cloneElement, useCallback } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
@ -21,6 +21,7 @@ import {
ListTimeline,
Directory,
} from '../util/async-components';
import { useColumnsContext } from '../util/columns_context';
import BundleColumnError from './bundle_column_error';
import { ColumnLoading } from './column_loading';
@ -43,6 +44,17 @@ const componentMap = {
'DIRECTORY': Directory,
};
const TabsBarPortal = () => {
const {setTabsBarElement} = useColumnsContext();
const setRef = useCallback((element) => {
if(element)
setTabsBarElement(element);
}, [setTabsBarElement]);
return <div id='tabs-bar__portal' ref={setRef} />;
};
export default class ColumnsArea extends ImmutablePureComponent {
static propTypes = {
columns: ImmutablePropTypes.list.isRequired,
@ -146,7 +158,7 @@ export default class ColumnsArea extends ImmutablePureComponent {
</div>
<div className='columns-area__panels__main'>
<div className='tabs-bar__wrapper'><div id='tabs-bar__portal' /></div>
<div className='tabs-bar__wrapper'><TabsBarPortal /></div>
<div className='columns-area columns-area--mobile'>{children}</div>
</div>

View file

@ -64,8 +64,8 @@ import {
About,
PrivacyPolicy,
} from './util/async-components';
import { ColumnsContextProvider } from './util/columns_context';
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
// Dummy import, to make sure that <Status /> ends up in the application bundle.
// Without this it ends up in ~8 very commonly used bundles.
import '../../components/status';
@ -179,68 +179,70 @@ class SwitchingColumnsArea extends PureComponent {
}
return (
<ColumnsAreaContainer ref={this.setRef} singleColumn={singleColumn}>
<WrappedSwitch>
{redirect}
<ColumnsContextProvider multiColumn={!singleColumn}>
<ColumnsAreaContainer ref={this.setRef} singleColumn={singleColumn}>
<WrappedSwitch>
{redirect}
{singleColumn ? <Redirect from='/deck' to='/home' exact /> : null}
{singleColumn && pathName.startsWith('/deck/') ? <Redirect from={pathName} to={pathName.slice(5)} /> : null}
{/* Redirect old bookmarks (without /deck) with home-like routes to the advanced interface */}
{!singleColumn && pathName === '/getting-started' ? <Redirect from='/getting-started' to='/deck/getting-started' exact /> : null}
{!singleColumn && pathName === '/home' ? <Redirect from='/home' to='/deck/getting-started' exact /> : null}
{singleColumn ? <Redirect from='/deck' to='/home' exact /> : null}
{singleColumn && pathName.startsWith('/deck/') ? <Redirect from={pathName} to={pathName.slice(5)} /> : null}
{/* Redirect old bookmarks (without /deck) with home-like routes to the advanced interface */}
{!singleColumn && pathName === '/getting-started' ? <Redirect from='/getting-started' to='/deck/getting-started' exact /> : null}
{!singleColumn && pathName === '/home' ? <Redirect from='/home' to='/deck/getting-started' exact /> : null}
<WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
<WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
<WrappedRoute path='/about' component={About} content={children} />
<WrappedRoute path='/privacy-policy' component={PrivacyPolicy} content={children} />
<WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
<WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
<WrappedRoute path='/about' component={About} content={children} />
<WrappedRoute path='/privacy-policy' component={PrivacyPolicy} content={children} />
<WrappedRoute path={['/home', '/timelines/home']} component={HomeTimeline} content={children} />
<Redirect from='/timelines/public' to='/public' exact />
<Redirect from='/timelines/public/local' to='/public/local' exact />
<WrappedRoute path='/public' exact component={Firehose} componentParams={{ feedType: 'public' }} content={children} />
<WrappedRoute path='/public/local' exact component={Firehose} componentParams={{ feedType: 'community' }} content={children} />
<WrappedRoute path='/public/remote' exact component={Firehose} componentParams={{ feedType: 'public:remote' }} content={children} />
<WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} />
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
<WrappedRoute path='/notifications' component={Notifications} content={children} />
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
<WrappedRoute path={['/home', '/timelines/home']} component={HomeTimeline} content={children} />
<Redirect from='/timelines/public' to='/public' exact />
<Redirect from='/timelines/public/local' to='/public/local' exact />
<WrappedRoute path='/public' exact component={Firehose} componentParams={{ feedType: 'public' }} content={children} />
<WrappedRoute path='/public/local' exact component={Firehose} componentParams={{ feedType: 'community' }} content={children} />
<WrappedRoute path='/public/remote' exact component={Firehose} componentParams={{ feedType: 'public:remote' }} content={children} />
<WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} />
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
<WrappedRoute path='/notifications' component={Notifications} content={children} />
<WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
<WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
<WrappedRoute path='/start' exact component={Onboarding} content={children} />
<WrappedRoute path='/directory' component={Directory} content={children} />
<WrappedRoute path={['/explore', '/search']} component={Explore} content={children} />
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
<WrappedRoute path='/start' exact component={Onboarding} content={children} />
<WrappedRoute path='/directory' component={Directory} content={children} />
<WrappedRoute path={['/explore', '/search']} component={Explore} content={children} />
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
<WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
<WrappedRoute path='/@:acct/tagged/:tagged?' exact component={AccountTimeline} content={children} />
<WrappedRoute path={['/@:acct/with_replies', '/accounts/:id/with_replies']} component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
<WrappedRoute path={['/accounts/:id/followers', '/users/:acct/followers', '/@:acct/followers']} component={Followers} content={children} />
<WrappedRoute path={['/accounts/:id/following', '/users/:acct/following', '/@:acct/following']} component={Following} content={children} />
<WrappedRoute path={['/@:acct/media', '/accounts/:id/media']} component={AccountGallery} content={children} />
<WrappedRoute path='/@:acct/:statusId' exact component={Status} content={children} />
<WrappedRoute path='/@:acct/:statusId/reblogs' component={Reblogs} content={children} />
<WrappedRoute path='/@:acct/:statusId/favourites' component={Favourites} content={children} />
<WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
<WrappedRoute path='/@:acct/tagged/:tagged?' exact component={AccountTimeline} content={children} />
<WrappedRoute path={['/@:acct/with_replies', '/accounts/:id/with_replies']} component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
<WrappedRoute path={['/accounts/:id/followers', '/users/:acct/followers', '/@:acct/followers']} component={Followers} content={children} />
<WrappedRoute path={['/accounts/:id/following', '/users/:acct/following', '/@:acct/following']} component={Following} content={children} />
<WrappedRoute path={['/@:acct/media', '/accounts/:id/media']} component={AccountGallery} content={children} />
<WrappedRoute path='/@:acct/:statusId' exact component={Status} content={children} />
<WrappedRoute path='/@:acct/:statusId/reblogs' component={Reblogs} content={children} />
<WrappedRoute path='/@:acct/:statusId/favourites' component={Favourites} content={children} />
{/* Legacy routes, cannot be easily factored with other routes because they share a param name */}
<WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/timelines/list/:id' component={ListTimeline} content={children} />
<WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
<WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
<WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
{/* Legacy routes, cannot be easily factored with other routes because they share a param name */}
<WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/timelines/list/:id' component={ListTimeline} content={children} />
<WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
<WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
<WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
<WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
<WrappedRoute path='/blocks' component={Blocks} content={children} />
<WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} />
<WrappedRoute path='/followed_tags' component={FollowedTags} content={children} />
<WrappedRoute path='/mutes' component={Mutes} content={children} />
<WrappedRoute path='/lists' component={Lists} content={children} />
<WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
<WrappedRoute path='/blocks' component={Blocks} content={children} />
<WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} />
<WrappedRoute path='/followed_tags' component={FollowedTags} content={children} />
<WrappedRoute path='/mutes' component={Mutes} content={children} />
<WrappedRoute path='/lists' component={Lists} content={children} />
<Route component={BundleColumnError} />
</WrappedSwitch>
</ColumnsAreaContainer>
<Route component={BundleColumnError} />
</WrappedSwitch>
</ColumnsAreaContainer>
</ColumnsContextProvider>
);
}

View file

@ -0,0 +1,51 @@
import type { ReactElement } from 'react';
import { createContext, useContext, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
export const ColumnsContext = createContext<{
tabsBarElement: HTMLElement | null;
setTabsBarElement: (element: HTMLElement) => void;
multiColumn: boolean;
}>({
tabsBarElement: null,
multiColumn: false,
setTabsBarElement: () => undefined, // no-op
});
export function useColumnsContext() {
return useContext(ColumnsContext);
}
export const ButtonInTabsBar: React.FC<{
children: ReactElement | string | undefined;
}> = ({ children }) => {
const { multiColumn, tabsBarElement } = useColumnsContext();
if (multiColumn) {
return children;
} else if (!tabsBarElement) {
return children;
} else {
return createPortal(children, tabsBarElement);
}
};
type ContextValue = React.ContextType<typeof ColumnsContext>;
export const ColumnsContextProvider: React.FC<
React.PropsWithChildren<{ multiColumn: boolean }>
> = ({ multiColumn, children }) => {
const [tabsBarElement, setTabsBarElement] =
useState<ContextValue['tabsBarElement']>(null);
const contextValue = useMemo<ContextValue>(
() => ({ multiColumn, tabsBarElement, setTabsBarElement }),
[multiColumn, tabsBarElement],
);
return (
<ColumnsContext.Provider value={contextValue}>
{children}
</ColumnsContext.Provider>
);
};

View file

@ -10,11 +10,11 @@ import { is } from 'immutable';
import { ReactComponent as FullscreenIcon } from '@material-symbols/svg-600/outlined/fullscreen.svg';
import { ReactComponent as FullscreenExitIcon } from '@material-symbols/svg-600/outlined/fullscreen_exit.svg';
import { ReactComponent as PauseIcon } from '@material-symbols/svg-600/outlined/pause.svg';
import { ReactComponent as PlayArrowIcon } from '@material-symbols/svg-600/outlined/play_arrow.svg';
import { ReactComponent as PlayArrowIcon } from '@material-symbols/svg-600/outlined/play_arrow-fill.svg';
import { ReactComponent as RectangleIcon } from '@material-symbols/svg-600/outlined/rectangle.svg';
import { ReactComponent as VisibilityOffIcon } from '@material-symbols/svg-600/outlined/visibility_off.svg';
import { ReactComponent as VolumeOffIcon } from '@material-symbols/svg-600/outlined/volume_off.svg';
import { ReactComponent as VolumeUpIcon } from '@material-symbols/svg-600/outlined/volume_up.svg';
import { ReactComponent as VolumeOffIcon } from '@material-symbols/svg-600/outlined/volume_off-fill.svg';
import { ReactComponent as VolumeUpIcon } from '@material-symbols/svg-600/outlined/volume_up-fill.svg';
import { throttle } from 'lodash';
import { Blurhash } from 'mastodon/components/blurhash';

View file

@ -1,5 +1,5 @@
{
"about.blocks": "Valvotut palvelimet",
"about.blocks": "Moderoidut palvelimet",
"about.contact": "Ota yhteyttä:",
"about.disclaimer": "Mastodon on vapaa avoimen lähdekoodin ohjelmisto ja Mastodon gGmbH:n tavaramerkki.",
"about.domain_blocks.no_reason_available": "Syytä ei ole ilmoitettu",

View file

@ -20,7 +20,7 @@
"account.block_short": "Bloquear",
"account.blocked": "Bloqueada",
"account.browse_more_on_origin_server": "Busca máis no perfil orixinal",
"account.cancel_follow_request": "Retirar solicitude de seguimento",
"account.cancel_follow_request": "Cancelar a solicitude de seguimento",
"account.direct": "Mencionar de xeito privado a @{name}",
"account.disable_notifications": "Deixar de notificarme cando @{name} publica",
"account.domain_blocked": "Dominio agochado",

View file

@ -590,7 +590,7 @@
"search.quick_action.open_url": "마스토돈에서 URL 열기",
"search.quick_action.status_search": "{x}에 맞는 게시물",
"search.search_or_paste": "검색하거나 URL 붙여넣기",
"search_popout.full_text_search_disabled_message": "{domain}에서는 용할 수 없습니다.",
"search_popout.full_text_search_disabled_message": "{domain}에서는 용할 수 없습니다.",
"search_popout.language_code": "ISO 언어코드",
"search_popout.options": "검색 옵션",
"search_popout.quick_actions": "빠른 작업",

View file

@ -81,7 +81,7 @@
"admin.impact_report.instance_follows": "Obserwujący, których straciliby ich użytkownicy",
"admin.impact_report.title": "Podsumowanie wpływu",
"alert.rate_limited.message": "Spróbuj ponownie po {retry_time, time, medium}.",
"alert.rate_limited.title": "Ograniczony czasowo",
"alert.rate_limited.title": "Ograniczenie liczby zapytań",
"alert.unexpected.message": "Wystąpił nieoczekiwany błąd.",
"alert.unexpected.title": "Ups!",
"announcement.announcement": "Ogłoszenie",

View file

@ -0,0 +1,62 @@
import PropTypes from 'prop-types';
import type { PropsWithChildren } from 'react';
import { Component } from 'react';
import { IntlProvider } from 'react-intl';
import { MemoryRouter } from 'react-router';
// eslint-disable-next-line import/no-extraneous-dependencies
import { render as rtlRender } from '@testing-library/react';
class FakeIdentityWrapper extends Component<
PropsWithChildren<{ signedIn: boolean }>
> {
static childContextTypes = {
identity: PropTypes.shape({
signedIn: PropTypes.bool.isRequired,
accountId: PropTypes.string,
disabledAccountId: PropTypes.string,
accessToken: PropTypes.string,
}).isRequired,
};
getChildContext() {
return {
identity: {
signedIn: this.props.signedIn,
accountId: '123',
accessToken: 'test-access-token',
},
};
}
render() {
return this.props.children;
}
}
function render(
ui: React.ReactElement,
{ locale = 'en', signedIn = true, ...renderOptions } = {},
) {
const Wrapper = (props: { children: React.ReactElement }) => {
return (
<MemoryRouter>
<IntlProvider locale={locale}>
<FakeIdentityWrapper signedIn={signedIn}>
{props.children}
</FakeIdentityWrapper>
</IntlProvider>
</MemoryRouter>
);
};
return rtlRender(ui, { wrapper: Wrapper, ...renderOptions });
}
// re-export everything
// eslint-disable-next-line import/no-extraneous-dependencies
export * from '@testing-library/react';
// override render method
export { render };

View file

@ -3137,20 +3137,6 @@ $ui-header-height: 55px;
margin-inline-end: 5px;
}
.column-back-button--slim {
position: relative;
}
.column-back-button--slim-button {
cursor: pointer;
flex: 0 0 auto;
font-size: 16px;
padding: 15px;
position: absolute;
inset-inline-end: 0;
top: -50px;
}
.react-toggle {
display: inline-block;
position: relative;
@ -7434,7 +7420,12 @@ noscript {
border: 1px solid lighten($ui-base-color, 12%);
border-radius: 4px;
box-sizing: content-box;
padding: 2px;
padding: 5px;
.icon {
width: 24px;
height: 24px;
}
}
}
@ -7517,6 +7508,11 @@ noscript {
color: lighten($ui-highlight-color, 8%);
}
.icon {
width: 18px;
height: 18px;
}
.verified {
border: 1px solid rgba($valid-value-color, 0.5);
margin-top: -1px;
@ -7537,6 +7533,16 @@ noscript {
color: $valid-value-color;
}
dd {
display: flex;
align-items: center;
gap: 4px;
span {
display: flex;
}
}
a {
color: $valid-value-color;
}