Merge tag 'v4.2.0-beta3'

This commit is contained in:
bgme 2023-09-05 07:31:32 +08:00
commit 5464814968
197 changed files with 3596 additions and 1332 deletions

View file

@ -84,6 +84,7 @@ const messages = defineMessages({
uploadErrorPoll: { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
open: { id: 'compose.published.open', defaultMessage: 'Open' },
published: { id: 'compose.published.body', defaultMessage: 'Post published.' },
saved: { id: 'compose.saved.body', defaultMessage: 'Post saved.' },
});
export const ensureComposeIsVisible = (getState, routerHistory) => {
@ -244,7 +245,7 @@ export function submitCompose(routerHistory) {
}
dispatch(showAlert({
message: messages.published,
message: statusId === null ? messages.published : messages.saved,
action: messages.open,
dismissAfter: 10000,
onClick: () => routerHistory.push(`/@${response.data.account.username}/${response.data.id}`),

View file

@ -1,11 +1,16 @@
import api from '../api';
import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts, importFetchedStatus } from './importer';
export const REBLOG_REQUEST = 'REBLOG_REQUEST';
export const REBLOG_SUCCESS = 'REBLOG_SUCCESS';
export const REBLOG_FAIL = 'REBLOG_FAIL';
export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST';
export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS';
export const REBLOGS_EXPAND_FAIL = 'REBLOGS_EXPAND_FAIL';
export const FAVOURITE_REQUEST = 'FAVOURITE_REQUEST';
export const FAVOURITE_SUCCESS = 'FAVOURITE_SUCCESS';
export const FAVOURITE_FAIL = 'FAVOURITE_FAIL';
@ -26,6 +31,10 @@ export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST';
export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS';
export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL';
export const FAVOURITES_EXPAND_REQUEST = 'FAVOURITES_EXPAND_REQUEST';
export const FAVOURITES_EXPAND_SUCCESS = 'FAVOURITES_EXPAND_SUCCESS';
export const FAVOURITES_EXPAND_FAIL = 'FAVOURITES_EXPAND_FAIL';
export const PIN_REQUEST = 'PIN_REQUEST';
export const PIN_SUCCESS = 'PIN_SUCCESS';
export const PIN_FAIL = 'PIN_FAIL';
@ -273,8 +282,10 @@ export function fetchReblogs(id) {
dispatch(fetchReblogsRequest(id));
api(getState).get(`/api/v1/statuses/${id}/reblogged_by`).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data));
dispatch(fetchReblogsSuccess(id, response.data));
dispatch(fetchReblogsSuccess(id, response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => {
dispatch(fetchReblogsFail(id, error));
});
@ -288,17 +299,62 @@ export function fetchReblogsRequest(id) {
};
}
export function fetchReblogsSuccess(id, accounts) {
export function fetchReblogsSuccess(id, accounts, next) {
return {
type: REBLOGS_FETCH_SUCCESS,
id,
accounts,
next,
};
}
export function fetchReblogsFail(id, error) {
return {
type: REBLOGS_FETCH_FAIL,
id,
error,
};
}
export function expandReblogs(id) {
return (dispatch, getState) => {
const url = getState().getIn(['user_lists', 'reblogged_by', id, 'next']);
if (url === null) {
return;
}
dispatch(expandReblogsRequest(id));
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data));
dispatch(expandReblogsSuccess(id, response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => dispatch(expandReblogsFail(id, error)));
};
}
export function expandReblogsRequest(id) {
return {
type: REBLOGS_EXPAND_REQUEST,
id,
};
}
export function expandReblogsSuccess(id, accounts, next) {
return {
type: REBLOGS_EXPAND_SUCCESS,
id,
accounts,
next,
};
}
export function expandReblogsFail(id, error) {
return {
type: REBLOGS_EXPAND_FAIL,
id,
error,
};
}
@ -308,8 +364,10 @@ export function fetchFavourites(id) {
dispatch(fetchFavouritesRequest(id));
api(getState).get(`/api/v1/statuses/${id}/favourited_by`).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data));
dispatch(fetchFavouritesSuccess(id, response.data));
dispatch(fetchFavouritesSuccess(id, response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => {
dispatch(fetchFavouritesFail(id, error));
});
@ -323,17 +381,62 @@ export function fetchFavouritesRequest(id) {
};
}
export function fetchFavouritesSuccess(id, accounts) {
export function fetchFavouritesSuccess(id, accounts, next) {
return {
type: FAVOURITES_FETCH_SUCCESS,
id,
accounts,
next,
};
}
export function fetchFavouritesFail(id, error) {
return {
type: FAVOURITES_FETCH_FAIL,
id,
error,
};
}
export function expandFavourites(id) {
return (dispatch, getState) => {
const url = getState().getIn(['user_lists', 'favourited_by', id, 'next']);
if (url === null) {
return;
}
dispatch(expandFavouritesRequest(id));
api(getState).get(url).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data));
dispatch(expandFavouritesSuccess(id, response.data, next ? next.uri : null));
dispatch(fetchRelationships(response.data.map(item => item.id)));
}).catch(error => dispatch(expandFavouritesFail(id, error)));
};
}
export function expandFavouritesRequest(id) {
return {
type: FAVOURITES_EXPAND_REQUEST,
id,
};
}
export function expandFavouritesSuccess(id, accounts, next) {
return {
type: FAVOURITES_EXPAND_SUCCESS,
id,
accounts,
next,
};
}
export function expandFavouritesFail(id, error) {
return {
type: FAVOURITES_EXPAND_FAIL,
id,
error,
};
}

View file

@ -105,6 +105,21 @@ describe('computeHashtagBarForStatus', () => {
);
});
it('handles server-side normalized tags with accentuated characters', () => {
const status = createStatus(
'<p>Text</p><p><a href="test">#éaa</a> <a href="test">#Éaa</a></p>',
['eaa'], // The server may normalize the hashtags in the `tags` attribute
);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual(['Éaa']);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
`"<p>Text</p>"`,
);
});
it('does not display in bar a hashtag in content with a case difference', () => {
const status = createStatus(
'<p>Text <a href="test">#Éaa</a></p><p><a href="test">#éaa</a></p>',

View file

@ -16,7 +16,19 @@ export default class Column extends PureComponent {
};
scrollTop () {
const scrollable = this.props.bindToDocument ? document.scrollingElement : this.node.querySelector('.scrollable');
let scrollable = null;
if (this.props.bindToDocument) {
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) {
return;

View file

@ -10,8 +10,8 @@ import { groupBy, minBy } from 'lodash';
import { getStatusContent } from './status_content';
// About two lines on desktop
const VISIBLE_HASHTAGS = 7;
// Fit on a single line on desktop
const VISIBLE_HASHTAGS = 3;
// Those types are not correct, they need to be replaced once this part of the state is typed
export type TagLike = Record<{ name: string }>;
@ -23,8 +23,9 @@ export type StatusLike = Record<{
}>;
function normalizeHashtag(hashtag: string) {
if (hashtag && hashtag.startsWith('#')) return hashtag.slice(1);
else return hashtag;
return (
hashtag && hashtag.startsWith('#') ? hashtag.slice(1) : hashtag
).normalize('NFKC');
}
function isNodeLinkHashtag(element: Node): element is HTMLLinkElement {
@ -70,9 +71,16 @@ function uniqueHashtagsWithCaseHandling(hashtags: string[]) {
}
// Create the collator once, this is much more efficient
const collator = new Intl.Collator(undefined, { sensitivity: 'accent' });
const collator = new Intl.Collator(undefined, {
sensitivity: 'base', // we use this to emulate the ASCII folding done on the server-side, hopefuly more efficiently
});
function localeAwareInclude(collection: string[], value: string) {
return collection.find((item) => collator.compare(item, value) === 0);
const normalizedValue = value.normalize('NFKC');
return !!collection.find(
(item) => collator.compare(item.normalize('NFKC'), normalizedValue) === 0,
);
}
// We use an intermediate function here to make it easier to test
@ -121,11 +129,13 @@ export function computeHashtagBarForStatus(status: StatusLike): {
// try to see if the last line is only hashtags
let onlyHashtags = true;
const normalizedTagNames = tagNames.map((tag) => tag.normalize('NFKC'));
Array.from(lastChild.childNodes).forEach((node) => {
if (isNodeLinkHashtag(node) && node.textContent) {
const normalized = normalizeHashtag(node.textContent);
if (!localeAwareInclude(tagNames, normalized)) {
if (!localeAwareInclude(normalizedTagNames, normalized)) {
// stop here, this is not a real hashtag, so consider it as text
onlyHashtags = false;
return;
@ -140,12 +150,14 @@ export function computeHashtagBarForStatus(status: StatusLike): {
}
});
const hashtagsInBar = tagNames.filter(
(tag) =>
// the tag does not appear at all in the status content, it is an out-of-band tag
!localeAwareInclude(contentHashtags, tag) &&
!localeAwareInclude(lastLineHashtags, tag),
);
const hashtagsInBar = tagNames.filter((tag) => {
const normalizedTag = tag.normalize('NFKC');
// the tag does not appear at all in the status content, it is an out-of-band tag
return (
!localeAwareInclude(contentHashtags, normalizedTag) &&
!localeAwareInclude(lastLineHashtags, normalizedTag)
);
});
const isOnlyOneLine = contentWithoutLastLine.content.childElementCount === 0;
const hasMedia = status.get('media_attachments').size > 0;
@ -198,13 +210,13 @@ const HashtagBar: React.FC<{
const revealedHashtags = expanded
? hashtags
: hashtags.slice(0, VISIBLE_HASHTAGS - 1);
: hashtags.slice(0, VISIBLE_HASHTAGS);
return (
<div className='hashtag-bar'>
{revealedHashtags.map((hashtag) => (
<Link key={hashtag} to={`/tags/${hashtag}`}>
#{hashtag}
#<span>{hashtag}</span>
</Link>
))}

View file

@ -546,10 +546,11 @@ class Status extends ImmutablePureComponent {
const visibilityIcon = visibilityIconInfo[status.get('visibility')];
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
const expanded = !status.get('hidden')
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}>
<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}
<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')}>
@ -574,7 +575,7 @@ class Status extends ImmutablePureComponent {
<StatusContent
status={status}
onClick={this.handleClick}
expanded={!status.get('hidden')}
expanded={expanded}
onExpandedToggle={this.handleExpandedToggle}
onTranslate={this.handleTranslate}
collapsible
@ -584,7 +585,7 @@ class Status extends ImmutablePureComponent {
{media}
{hashtagBar}
{expanded && hashtagBar}
<StatusActionBar scrollKey={scrollKey} status={status} account={account} onFilter={matchedFilters ? this.handleFilterClick : null} {...other} />
</div>

View file

@ -1,7 +1,7 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { defineMessages, injectIntl, FormattedMessage, FormattedList } from 'react-intl';
import classNames from 'classnames';
@ -45,6 +45,16 @@ class Search extends PureComponent {
options: [],
};
defaultOptions = [
{ label: <><mark>has:</mark> <FormattedList type='disjunction' value={['media', 'poll', 'embed']} /></>, action: e => { e.preventDefault(); this._insertText('has:') } },
{ label: <><mark>is:</mark> <FormattedList type='disjunction' value={['reply', 'sensitive']} /></>, action: e => { e.preventDefault(); this._insertText('is:') } },
{ label: <><mark>language:</mark> <FormattedMessage id='search_popout.language_code' defaultMessage='ISO language code' /></>, action: e => { e.preventDefault(); this._insertText('language:') } },
{ label: <><mark>from:</mark> <FormattedMessage id='search_popout.user' defaultMessage='user' /></>, action: e => { e.preventDefault(); this._insertText('from:') } },
{ label: <><mark>before:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('before:') } },
{ label: <><mark>during:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('during:') } },
{ label: <><mark>after:</mark> <FormattedMessage id='search_popout.specific_date' defaultMessage='specific date' /></>, action: e => { e.preventDefault(); this._insertText('after:') } },
];
setRef = c => {
this.searchForm = c;
};
@ -70,7 +80,7 @@ class Search extends PureComponent {
handleKeyDown = (e) => {
const { selectedOption } = this.state;
const options = this._getOptions();
const options = searchEnabled ? this._getOptions().concat(this.defaultOptions) : this._getOptions();
switch(e.key) {
case 'Escape':
@ -100,11 +110,9 @@ class Search extends PureComponent {
if (selectedOption === -1) {
this._submit();
} else if (options.length > 0) {
options[selectedOption].action();
options[selectedOption].action(e);
}
this._unfocus();
break;
case 'Delete':
if (selectedOption > -1 && options.length > 0) {
@ -147,6 +155,7 @@ class Search extends PureComponent {
router.history.push(`/tags/${query}`);
onClickSearchResult(query, 'hashtag');
this._unfocus();
};
handleAccountClick = () => {
@ -157,6 +166,7 @@ class Search extends PureComponent {
router.history.push(`/@${query}`);
onClickSearchResult(query, 'account');
this._unfocus();
};
handleURLClick = () => {
@ -164,6 +174,7 @@ class Search extends PureComponent {
const { value, onOpenURL } = this.props;
onOpenURL(value, router.history);
this._unfocus();
};
handleStatusSearch = () => {
@ -182,6 +193,8 @@ class Search extends PureComponent {
} else if (search.get('type') === 'hashtag') {
router.history.push(`/tags/${search.get('q')}`);
}
this._unfocus();
};
handleForgetRecentSearchClick = search => {
@ -194,6 +207,18 @@ class Search extends PureComponent {
document.querySelector('.ui').parentElement.focus();
}
_insertText (text) {
const { value, onChange } = this.props;
if (value === '') {
onChange(text);
} else if (value[value.length - 1] === ' ') {
onChange(`${value}${text}`);
} else {
onChange(`${value} ${text}`);
}
}
_submit (type) {
const { onSubmit, openInRoute } = this.props;
const { router } = this.context;
@ -203,6 +228,8 @@ class Search extends PureComponent {
if (openInRoute) {
router.history.push('/search');
}
this._unfocus();
}
_getOptions () {
@ -325,6 +352,20 @@ class Search extends PureComponent {
</div>
</>
)}
{searchEnabled && (
<>
<h4><FormattedMessage id='search_popout.options' defaultMessage='Search options' /></h4>
<div className='search__popout__menu'>
{this.defaultOptions.map(({ key, label, action }, i) => (
<button key={key} onMouseDown={action} className={classNames('search__popout__menu__item', { selected: selectedOption === (options.length + i) })}>
{label}
</button>
))}
</div>
</>
)}
</div>
</div>
);

View file

@ -108,10 +108,10 @@ class Results extends PureComponent {
return (
<>
<div className='account__section-headline'>
<button onClick={this.handleSelectAll} className={type === 'all' && 'active'}><FormattedMessage id='search_results.all' defaultMessage='All' /></button>
<button onClick={this.handleSelectAccounts} className={type === 'accounts' && 'active'}><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></button>
<button onClick={this.handleSelectHashtags} className={type === 'hashtags' && 'active'}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button>
<button onClick={this.handleSelectStatuses} className={type === 'statuses' && 'active'}><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></button>
<button onClick={this.handleSelectAll} className={type === 'all' ? 'active' : undefined}><FormattedMessage id='search_results.all' defaultMessage='All' /></button>
<button onClick={this.handleSelectAccounts} className={type === 'accounts' ? 'active' : undefined}><FormattedMessage id='search_results.accounts' defaultMessage='Profiles' /></button>
<button onClick={this.handleSelectHashtags} className={type === 'hashtags' ? 'active' : undefined}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button>
<button onClick={this.handleSelectStatuses} className={type === 'statuses' ? 'active' : undefined}><FormattedMessage id='search_results.statuses' defaultMessage='Posts' /></button>
</div>
<div className='explore__search-results'>

View file

@ -8,7 +8,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { fetchFavourites } from 'mastodon/actions/interactions';
import { debounce } from 'lodash';
import { fetchFavourites, expandFavourites } from 'mastodon/actions/interactions';
import ColumnHeader from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
@ -21,7 +23,9 @@ const messages = defineMessages({
});
const mapStateToProps = (state, props) => ({
accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]),
accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'items']),
hasMore: !!state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'next']),
isLoading: state.getIn(['user_lists', 'favourited_by', props.params.statusId, 'isLoading'], true),
});
class Favourites extends ImmutablePureComponent {
@ -30,6 +34,8 @@ class Favourites extends ImmutablePureComponent {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
multiColumn: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
@ -40,18 +46,16 @@ class Favourites extends ImmutablePureComponent {
}
}
UNSAFE_componentWillReceiveProps (nextProps) {
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
this.props.dispatch(fetchFavourites(nextProps.params.statusId));
}
}
handleRefresh = () => {
this.props.dispatch(fetchFavourites(this.props.params.statusId));
};
handleLoadMore = debounce(() => {
this.props.dispatch(expandFavourites(this.props.params.statusId));
}, 300, { leading: true });
render () {
const { intl, accountIds, multiColumn } = this.props;
const { intl, accountIds, hasMore, isLoading, multiColumn } = this.props;
if (!accountIds) {
return (
@ -75,6 +79,9 @@ class Favourites extends ImmutablePureComponent {
<ScrollableList
scrollKey='favourites'
onLoadMore={this.handleLoadMore}
hasMore={hasMore}
isLoading={isLoading}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>

View file

@ -0,0 +1,26 @@
import { FormattedMessage } from 'react-intl';
export const CriticalUpdateBanner = () => (
<div className='warning-banner'>
<div className='warning-banner__message'>
<h1>
<FormattedMessage
id='home.pending_critical_update.title'
defaultMessage='Critical security update available!'
/>
</h1>
<p>
<FormattedMessage
id='home.pending_critical_update.body'
defaultMessage='Please update your Mastodon server as soon as possible!'
/>{' '}
<a href='/admin/software_updates'>
<FormattedMessage
id='home.pending_critical_update.link'
defaultMessage='See updates'
/>
</a>
</p>
</div>
</div>
);

View file

@ -14,7 +14,7 @@ import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/an
import { IconWithBadge } from 'mastodon/components/icon_with_badge';
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
import { me } from 'mastodon/initial_state';
import { me, criticalUpdatesPending } from 'mastodon/initial_state';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { expandHomeTimeline } from '../../actions/timelines';
@ -23,6 +23,7 @@ import ColumnHeader from '../../components/column_header';
import StatusListContainer from '../ui/containers/status_list_container';
import { ColumnSettings } from './components/column_settings';
import { CriticalUpdateBanner } from './components/critical_update_banner';
import { ExplorePrompt } from './components/explore_prompt';
const messages = defineMessages({
@ -156,8 +157,9 @@ class HomeTimeline extends PureComponent {
const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
const pinned = !!columnId;
const { signedIn } = this.context.identity;
const banners = [];
let announcementsButton, banner;
let announcementsButton;
if (hasAnnouncements) {
announcementsButton = (
@ -173,8 +175,12 @@ class HomeTimeline extends PureComponent {
);
}
if (criticalUpdatesPending) {
banners.push(<CriticalUpdateBanner key='critical-update-banner' />);
}
if (tooSlow) {
banner = <ExplorePrompt />;
banners.push(<ExplorePrompt key='explore-prompt' />);
}
return (
@ -196,7 +202,7 @@ class HomeTimeline extends PureComponent {
{signedIn ? (
<StatusListContainer
prepend={banner}
prepend={banners}
alwaysPrepend
trackScroll={!pinned}
scrollKey={`home_timeline-${columnId}`}

View file

@ -8,9 +8,11 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import { Icon } from 'mastodon/components/icon';
import { fetchReblogs } from '../../actions/interactions';
import { fetchReblogs, expandReblogs } from '../../actions/interactions';
import ColumnHeader from '../../components/column_header';
import { LoadingIndicator } from '../../components/loading_indicator';
import ScrollableList from '../../components/scrollable_list';
@ -22,7 +24,9 @@ const messages = defineMessages({
});
const mapStateToProps = (state, props) => ({
accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]),
accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId, 'items']),
hasMore: !!state.getIn(['user_lists', 'reblogged_by', props.params.statusId, 'next']),
isLoading: state.getIn(['user_lists', 'reblogged_by', props.params.statusId, 'isLoading'], true),
});
class Reblogs extends ImmutablePureComponent {
@ -31,6 +35,8 @@ class Reblogs extends ImmutablePureComponent {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
accountIds: ImmutablePropTypes.list,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
multiColumn: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
@ -39,20 +45,18 @@ class Reblogs extends ImmutablePureComponent {
if (!this.props.accountIds) {
this.props.dispatch(fetchReblogs(this.props.params.statusId));
}
}
UNSAFE_componentWillReceiveProps(nextProps) {
if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
this.props.dispatch(fetchReblogs(nextProps.params.statusId));
}
}
};
handleRefresh = () => {
this.props.dispatch(fetchReblogs(this.props.params.statusId));
};
handleLoadMore = debounce(() => {
this.props.dispatch(expandReblogs(this.props.params.statusId));
}, 300, { leading: true });
render () {
const { intl, accountIds, multiColumn } = this.props;
const { intl, accountIds, hasMore, isLoading, multiColumn } = this.props;
if (!accountIds) {
return (
@ -76,6 +80,9 @@ class Reblogs extends ImmutablePureComponent {
<ScrollableList
scrollKey='reblogs'
onLoadMore={this.handleLoadMore}
hasMore={hasMore}
isLoading={isLoading}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>

View file

@ -293,6 +293,7 @@ class DetailedStatus extends ImmutablePureComponent {
}
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
const expanded = !status.get('hidden')
return (
<div style={outerStyle}>
@ -318,7 +319,7 @@ class DetailedStatus extends ImmutablePureComponent {
{media}
{hashtagBar}
{expanded && hashtagBar}
<div className='detailed-status__meta'>
<a className='detailed-status__datetime' href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} target='_blank' rel='noopener noreferrer'>

View file

@ -568,7 +568,7 @@ class Status extends ImmutablePureComponent {
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType='thread'
previousId={i > 0 && list.get(i - 1)}
previousId={i > 0 ? list.get(i - 1) : undefined}
nextId={list.get(i + 1) || (ancestors && statusId)}
rootId={statusId}
/>

View file

@ -115,7 +115,10 @@ export default class ModalRoot extends PureComponent {
{visible && (
<>
<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
{(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={this.setModalRef} />}
{(SpecificComponent) => {
const ref = typeof SpecificComponent !== 'function' ? this.setModalRef : undefined;
return <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={ref} />
}}
</BundleContainer>
<Helmet>

View file

@ -31,6 +31,7 @@ const messages = defineMessages({
about: { id: 'navigation_bar.about', defaultMessage: 'About' },
search: { id: 'navigation_bar.search', defaultMessage: 'Search' },
advancedInterface: { id: 'navigation_bar.advanced_interface', defaultMessage: 'Open in advanced web interface' },
openedInClassicInterface: { id: 'navigation_bar.opened_in_classic_interface', defaultMessage: 'Posts, accounts, and other specific pages are opened by default in the classic web interface.' },
});
class NavigationPanel extends Component {
@ -57,12 +58,17 @@ class NavigationPanel extends Component {
<div className='navigation-panel__logo'>
<Link to='/' className='column-link column-link--logo'><WordmarkLogo /></Link>
{transientSingleColumn && (
<a href={`/deck${location.pathname}`} className='button button--block'>
{intl.formatMessage(messages.advancedInterface)}
</a>
{transientSingleColumn ? (
<div class='switch-to-advanced'>
{intl.formatMessage(messages.openedInClassicInterface)}
{" "}
<a href={`/deck${location.pathname}`} class='switch-to-advanced__toggle'>
{intl.formatMessage(messages.advancedInterface)}
</a>
</div>
) : (
<hr />
)}
<hr />
</div>
{signedIn && (

View file

@ -62,7 +62,7 @@ class ReportModal extends ImmutablePureComponent {
dispatch(submitReport({
account_id: accountId,
status_ids: selectedStatusIds.toArray(),
selected_domains: selectedDomains.toArray(),
forward_to_domains: selectedDomains.toArray(),
comment,
forward: selectedDomains.size > 0,
category,

View file

@ -87,6 +87,7 @@
* @typedef InitialState
* @property {Record<string, Account>} accounts
* @property {InitialStateLanguage[]} languages
* @property {boolean=} critical_updates_pending
* @property {InitialStateMeta} meta
*/
@ -140,6 +141,7 @@ export const useBlurhash = getMeta('use_blurhash');
export const usePendingItems = getMeta('use_pending_items');
export const version = getMeta('version');
export const languages = initialState?.languages;
export const criticalUpdatesPending = initialState?.critical_updates_pending;
// @ts-expect-error
export const statusPageUrl = getMeta('status_page_url');
export const sso_redirect = getMeta('sso_redirect');

View file

@ -137,6 +137,7 @@
"compose.language.search": "Search languages...",
"compose.published.body": "Post published.",
"compose.published.open": "Open",
"compose.saved.body": "Post saved.",
"compose_form.direct_message_warning_learn_more": "Learn more",
"compose_form.encryption_warning": "Posts on Mastodon are not end-to-end encrypted. Do not share any sensitive information over Mastodon.",
"compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is not public. Only public posts can be searched by hashtag.",
@ -309,6 +310,9 @@
"home.explore_prompt.body": "Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. If that feels too quiet, you may want to:",
"home.explore_prompt.title": "This is your home base within Mastodon.",
"home.hide_announcements": "Hide announcements",
"home.pending_critical_update.body": "Please update your Mastodon server as soon as possible!",
"home.pending_critical_update.link": "See updates",
"home.pending_critical_update.title": "Critical security update available!",
"home.show_announcements": "Show announcements",
"interaction_modal.description.favourite": "With an account on Mastodon, you can favorite this post to let the author know you appreciate it and save it for later.",
"interaction_modal.description.follow": "With an account on Mastodon, you can follow {name} to receive their posts in your home feed.",
@ -410,6 +414,7 @@
"navigation_bar.lists": "Lists",
"navigation_bar.logout": "Logout",
"navigation_bar.mutes": "Muted users",
"navigation_bar.opened_in_classic_interface": "Posts, accounts, and other specific pages are opened by default in the classic web interface.",
"navigation_bar.personal": "Personal",
"navigation_bar.pins": "Pinned posts",
"navigation_bar.preferences": "Preferences",
@ -585,8 +590,12 @@
"search.quick_action.open_url": "Open URL in Mastodon",
"search.quick_action.status_search": "Posts matching {x}",
"search.search_or_paste": "Search or paste URL",
"search_popout.language_code": "ISO language code",
"search_popout.options": "Search options",
"search_popout.quick_actions": "Quick actions",
"search_popout.recent": "Recent searches",
"search_popout.specific_date": "specific date",
"search_popout.user": "user",
"search_results.accounts": "Profiles",
"search_results.all": "All",
"search_results.hashtags": "Hashtags",

View file

@ -409,6 +409,7 @@
"navigation_bar.lists": "Listes",
"navigation_bar.logout": "Déconnexion",
"navigation_bar.mutes": "Comptes masqués",
"navigation_bar.opened_in_classic_interface": "Les messages, les comptes et les pages spécifiques sont ouvertes dans linterface classique.",
"navigation_bar.personal": "Personnel",
"navigation_bar.pins": "Messages épinglés",
"navigation_bar.preferences": "Préférences",

View file

@ -45,8 +45,18 @@ import {
BLOCKS_EXPAND_FAIL,
} from '../actions/blocks';
import {
REBLOGS_FETCH_REQUEST,
REBLOGS_FETCH_SUCCESS,
REBLOGS_FETCH_FAIL,
REBLOGS_EXPAND_REQUEST,
REBLOGS_EXPAND_SUCCESS,
REBLOGS_EXPAND_FAIL,
FAVOURITES_FETCH_REQUEST,
FAVOURITES_FETCH_SUCCESS,
FAVOURITES_FETCH_FAIL,
FAVOURITES_EXPAND_REQUEST,
FAVOURITES_EXPAND_SUCCESS,
FAVOURITES_EXPAND_FAIL,
} from '../actions/interactions';
import {
MUTES_FETCH_REQUEST,
@ -134,9 +144,25 @@ export default function userLists(state = initialState, action) {
case FOLLOWING_EXPAND_FAIL:
return state.setIn(['following', action.id, 'isLoading'], false);
case REBLOGS_FETCH_SUCCESS:
return state.setIn(['reblogged_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
return normalizeList(state, ['reblogged_by', action.id], action.accounts, action.next);
case REBLOGS_EXPAND_SUCCESS:
return appendToList(state, ['reblogged_by', action.id], action.accounts, action.next);
case REBLOGS_FETCH_REQUEST:
case REBLOGS_EXPAND_REQUEST:
return state.setIn(['reblogged_by', action.id, 'isLoading'], true);
case REBLOGS_FETCH_FAIL:
case REBLOGS_EXPAND_FAIL:
return state.setIn(['reblogged_by', action.id, 'isLoading'], false);
case FAVOURITES_FETCH_SUCCESS:
return state.setIn(['favourited_by', action.id], ImmutableList(action.accounts.map(item => item.id)));
return normalizeList(state, ['favourited_by', action.id], action.accounts, action.next);
case FAVOURITES_EXPAND_SUCCESS:
return appendToList(state, ['favourited_by', action.id], action.accounts, action.next);
case FAVOURITES_FETCH_REQUEST:
case FAVOURITES_EXPAND_REQUEST:
return state.setIn(['favourited_by', action.id, 'isLoading'], true);
case FAVOURITES_FETCH_FAIL:
case FAVOURITES_EXPAND_FAIL:
return state.setIn(['favourited_by', action.id, 'isLoading'], false);
case NOTIFICATIONS_UPDATE:
return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state;
case FOLLOW_REQUESTS_FETCH_SUCCESS:

View file

@ -1 +1 @@
import '@testing-library/jest-dom/extend-expect';
import '@testing-library/jest-dom';

View file

@ -7,8 +7,6 @@ import { defineMessages } from 'react-intl';
import { delegate } from '@rails/ujs';
import axios from 'axios';
import escapeTextContentForBrowser from 'escape-html';
import { createBrowserHistory } from 'history';
import { throttle } from 'lodash';
import { start } from '../mastodon/common';
@ -48,23 +46,6 @@ window.addEventListener('message', e => {
function loaded() {
const { messages: localeData } = getLocale();
const scrollToDetailedStatus = () => {
const history = createBrowserHistory();
const detailedStatuses = document.querySelectorAll('.public-layout .detailed-status');
const location = history.location;
if (detailedStatuses.length === 1 && (!location.state || !location.state.scrolledToDetailedStatus)) {
detailedStatuses[0].scrollIntoView();
history.replace(location.pathname, { ...location.state, scrolledToDetailedStatus: true });
}
};
const getEmojiAnimationHandler = (swapTo) => {
return ({ target }) => {
target.src = target.getAttribute(swapTo);
};
};
const locale = document.documentElement.lang;
const dateTimeFormat = new Intl.DateTimeFormat(locale, {
@ -158,27 +139,21 @@ function loaded() {
const root = createRoot(content);
root.render(<MediaContainer locale={locale} components={reactComponents} />);
document.body.appendChild(content);
scrollToDetailedStatus();
})
.catch(error => {
console.error(error);
scrollToDetailedStatus();
});
} else {
scrollToDetailedStatus();
}
delegate(document, '#user_account_attributes_username', 'input', throttle(() => {
const username = document.getElementById('user_account_attributes_username');
if (username.value && username.value.length > 0) {
axios.get('/api/v1/accounts/lookup', { params: { acct: username.value } }).then(() => {
username.setCustomValidity(formatMessage(messages.usernameTaken));
delegate(document, '#user_account_attributes_username', 'input', throttle(({ target }) => {
if (target.value && target.value.length > 0) {
axios.get('/api/v1/accounts/lookup', { params: { acct: target.value } }).then(() => {
target.setCustomValidity(formatMessage(messages.usernameTaken));
}).catch(() => {
username.setCustomValidity('');
target.setCustomValidity('');
});
} else {
username.setCustomValidity('');
target.setCustomValidity('');
}
}, 500, { leading: false, trailing: true }));
@ -196,9 +171,6 @@ function loaded() {
}
});
delegate(document, '.custom-emoji', 'mouseover', getEmojiAnimationHandler('data-original'));
delegate(document, '.custom-emoji', 'mouseout', getEmojiAnimationHandler('data-static'));
delegate(document, '.status__content__spoiler-link', 'click', function() {
const statusEl = this.parentNode.parentNode;
@ -220,17 +192,6 @@ function loaded() {
});
}
delegate(document, '#account_display_name', 'input', ({ target }) => {
const name = document.querySelector('.card .display-name strong');
if (name) {
if (target.value) {
name.innerHTML = emojify(escapeTextContentForBrowser(target.value));
} else {
name.textContent = target.dataset.default;
}
}
});
delegate(document, '#edit_profile input[type=file]', 'change', ({ target }) => {
const avatar = document.getElementById(target.id + '-preview');
const [file] = target.files || [];
@ -239,33 +200,6 @@ delegate(document, '#edit_profile input[type=file]', 'change', ({ target }) => {
avatar.src = url;
});
const getProfileAvatarAnimationHandler = (swapTo) => {
//animate avatar gifs on the profile page when moused over
return ({ target }) => {
const swapSrc = target.getAttribute(swapTo);
//only change the img source if autoplay is off and the image src is actually different
if(target.getAttribute('data-autoplay') !== 'true' && target.src !== swapSrc) {
target.src = swapSrc;
}
};
};
delegate(document, 'img#profile_page_avatar', 'mouseover', getProfileAvatarAnimationHandler('data-original'));
delegate(document, 'img#profile_page_avatar', 'mouseout', getProfileAvatarAnimationHandler('data-static'));
delegate(document, '#account_locked', 'change', ({ target }) => {
const lock = document.querySelector('.card .display-name i');
if (lock) {
if (target.checked) {
delete lock.dataset.hidden;
} else {
lock.dataset.hidden = 'true';
}
}
});
delegate(document, '.input-copy input', 'click', ({ target }) => {
target.focus();
target.select();
@ -325,6 +259,9 @@ delegate(document, '.sidebar__toggle__icon', 'keydown', e => {
}
});
delegate(document, '.custom-emoji', 'mouseover', ({ target }) => target.src = target.getAttribute('data-original'));
delegate(document, '.custom-emoji', 'mouseout', ({ target }) => target.src = target.getAttribute('data-static'));
// Empty the honeypot fields in JS in case something like an extension
// automatically filled them.
delegate(document, '#registration_new_user,#new_user', 'submit', () => {

View file

@ -188,6 +188,7 @@
}
.information-badge,
.simple_form .overridden,
.simple_form .recommended,
.simple_form .not_recommended {
display: inline-block;
@ -204,6 +205,7 @@
}
.information-badge,
.simple_form .overridden,
.simple_form .recommended,
.simple_form .not_recommended {
background-color: rgba($ui-secondary-color, 0.1);

View file

@ -143,6 +143,11 @@ $content-width: 840px;
}
}
.warning a {
color: $gold-star;
font-weight: 700;
}
.simple-navigation-active-leaf a {
color: $primary-text-color;
background-color: $ui-highlight-color;

View file

@ -228,20 +228,20 @@
color: lighten($lighter-text-color, 7%);
background-color: transparent;
}
}
&.active {
&.active {
color: $highlight-text-color;
&:hover,
&:active,
&:focus {
color: $highlight-text-color;
background-color: transparent;
}
&:hover,
&:active,
&:focus {
color: $highlight-text-color;
background-color: transparent;
}
&.disabled {
color: lighten($highlight-text-color, 13%);
}
&.disabled {
color: lighten($highlight-text-color, 13%);
}
}
@ -2381,6 +2381,7 @@ $ui-header-height: 55px;
.filter-form {
display: flex;
flex-wrap: wrap;
}
.autosuggest-textarea__textarea {
@ -3270,6 +3271,22 @@ $ui-header-height: 55px;
border-color: $ui-highlight-color;
}
.switch-to-advanced {
color: $light-text-color;
background-color: $ui-base-color;
padding: 15px;
border-radius: 4px;
margin-top: 4px;
margin-bottom: 12px;
font-size: 13px;
line-height: 18px;
.switch-to-advanced__toggle {
color: $ui-button-tertiary-color;
font-weight: bold;
}
}
.column-link {
background: lighten($ui-base-color, 8%);
color: $primary-text-color;
@ -4991,6 +5008,12 @@ a.status-card {
}
&__menu {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
&__message {
color: $dark-text-color;
padding: 0 10px;
@ -5125,6 +5148,7 @@ a.status-card {
&.active {
transform: rotate(90deg);
opacity: 1;
}
&:hover {
@ -8539,6 +8563,44 @@ noscript {
}
}
&__choices {
display: flex;
gap: 40px;
&__choice {
flex: 1;
box-sizing: border-box;
h3 {
margin-bottom: 20px;
}
p {
color: $darker-text-color;
margin-bottom: 20px;
font-size: 15px;
}
.button {
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
}
}
}
@media screen and (max-width: $no-gap-breakpoint - 1px) {
&__choices {
flex-direction: column;
&__choice {
margin-top: 40px;
}
}
}
.link-button {
font-size: inherit;
display: inline;
@ -8798,7 +8860,8 @@ noscript {
}
}
.dismissable-banner {
.dismissable-banner,
.warning-banner {
position: relative;
margin: 10px;
margin-bottom: 5px;
@ -8876,6 +8939,21 @@ noscript {
}
}
.warning-banner {
border: 1px solid $warning-red;
background: rgba($warning-red, 0.15);
&__message {
h1 {
color: $warning-red;
}
a {
color: $primary-text-color;
}
}
}
.image {
position: relative;
overflow: hidden;
@ -9263,20 +9341,24 @@ noscript {
display: flex;
flex-wrap: wrap;
font-size: 14px;
line-height: 18px;
gap: 4px;
color: $darker-text-color;
a {
display: inline-flex;
border-radius: 4px;
background: rgba($highlight-text-color, 0.2);
color: $highlight-text-color;
padding: 0.4em 0.6em;
color: inherit;
text-decoration: none;
&:hover,
&:focus,
&:active {
background: rgba($highlight-text-color, 0.3);
&:hover span {
text-decoration: underline;
}
}
.link-button {
color: inherit;
font-size: inherit;
line-height: inherit;
padding: 0;
}
}

View file

@ -103,6 +103,7 @@ code {
}
}
.overridden,
.recommended,
.not_recommended {
position: absolute;
@ -1185,14 +1186,14 @@ code {
}
li:first-child .label {
left: auto;
inset-inline-start: 0;
inset-inline-end: auto;
text-align: start;
transform: none;
}
li:last-child .label {
left: auto;
inset-inline-start: auto;
inset-inline-end: 0;
text-align: end;
transform: none;

View file

@ -12,6 +12,11 @@
border-top: 1px solid $ui-base-color;
text-align: start;
background: darken($ui-base-color, 4%);
&.critical {
font-weight: 700;
color: $gold-star;
}
}
& > thead > tr > th {