Merge remote-tracking branch 'origin/develop' into mastodon-pagination
This commit is contained in:
commit
debfcaeb1e
20 changed files with 142 additions and 187 deletions
|
@ -6,6 +6,9 @@ export const CUSTOM_EMOJIS_FETCH_FAIL = 'CUSTOM_EMOJIS_FETCH_FAIL';
|
||||||
|
|
||||||
export function fetchCustomEmojis() {
|
export function fetchCustomEmojis() {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
|
const me = getState().get('me');
|
||||||
|
if (!me) return;
|
||||||
|
|
||||||
dispatch(fetchCustomEmojisRequest());
|
dispatch(fetchCustomEmojisRequest());
|
||||||
|
|
||||||
api(getState).get('/api/v1/custom_emojis').then(response => {
|
api(getState).get('/api/v1/custom_emojis').then(response => {
|
||||||
|
|
|
@ -1,30 +0,0 @@
|
||||||
import api from '../api';
|
|
||||||
|
|
||||||
export const IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST = 'IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST';
|
|
||||||
export const IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS = 'IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS';
|
|
||||||
export const IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL = 'IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL';
|
|
||||||
|
|
||||||
export const fetchAccountIdentityProofs = accountId => (dispatch, getState) => {
|
|
||||||
dispatch(fetchAccountIdentityProofsRequest(accountId));
|
|
||||||
|
|
||||||
api(getState).get(`/api/v1/accounts/${accountId}/identity_proofs`)
|
|
||||||
.then(({ data }) => dispatch(fetchAccountIdentityProofsSuccess(accountId, data)))
|
|
||||||
.catch(err => dispatch(fetchAccountIdentityProofsFail(accountId, err)));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchAccountIdentityProofsRequest = id => ({
|
|
||||||
type: IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST,
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchAccountIdentityProofsSuccess = (accountId, identity_proofs) => ({
|
|
||||||
type: IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS,
|
|
||||||
accountId,
|
|
||||||
identity_proofs,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const fetchAccountIdentityProofsFail = (accountId, error) => ({
|
|
||||||
type: IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL,
|
|
||||||
accountId,
|
|
||||||
error,
|
|
||||||
});
|
|
|
@ -8,13 +8,13 @@ import { importFetchedAccounts } from './importer';
|
||||||
|
|
||||||
export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
|
export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
|
||||||
export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';
|
export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';
|
||||||
export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL';
|
export const SUGGESTIONS_FETCH_FAIL = 'SUGGESTIONS_FETCH_FAIL';
|
||||||
|
|
||||||
export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS';
|
export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS';
|
||||||
|
|
||||||
export const SUGGESTIONS_V2_FETCH_REQUEST = 'SUGGESTIONS_V2_FETCH_REQUEST';
|
export const SUGGESTIONS_V2_FETCH_REQUEST = 'SUGGESTIONS_V2_FETCH_REQUEST';
|
||||||
export const SUGGESTIONS_V2_FETCH_SUCCESS = 'SUGGESTIONS_V2_FETCH_SUCCESS';
|
export const SUGGESTIONS_V2_FETCH_SUCCESS = 'SUGGESTIONS_V2_FETCH_SUCCESS';
|
||||||
export const SUGGESTIONS_V2_FETCH_FAIL = 'SUGGESTIONS_V2_FETCH_FAIL';
|
export const SUGGESTIONS_V2_FETCH_FAIL = 'SUGGESTIONS_V2_FETCH_FAIL';
|
||||||
|
|
||||||
export function fetchSuggestionsV1(params = {}) {
|
export function fetchSuggestionsV1(params = {}) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
|
@ -48,23 +48,26 @@ export function fetchSuggestionsV2(params = {}) {
|
||||||
export function fetchSuggestions(params = { limit: 50 }) {
|
export function fetchSuggestions(params = { limit: 50 }) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
const state = getState();
|
const state = getState();
|
||||||
|
const me = state.get('me');
|
||||||
const instance = state.get('instance');
|
const instance = state.get('instance');
|
||||||
const features = getFeatures(instance);
|
const features = getFeatures(instance);
|
||||||
|
|
||||||
|
if (!me) return;
|
||||||
|
|
||||||
if (features.suggestionsV2) {
|
if (features.suggestionsV2) {
|
||||||
dispatch(fetchSuggestionsV2(params))
|
dispatch(fetchSuggestionsV2(params))
|
||||||
.then(suggestions => {
|
.then(suggestions => {
|
||||||
const accountIds = suggestions.map(({ account }) => account.id);
|
const accountIds = suggestions.map(({ account }) => account.id);
|
||||||
dispatch(fetchRelationships(accountIds));
|
dispatch(fetchRelationships(accountIds));
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => { });
|
||||||
} else if (features.suggestions) {
|
} else if (features.suggestions) {
|
||||||
dispatch(fetchSuggestionsV1(params))
|
dispatch(fetchSuggestionsV1(params))
|
||||||
.then(accounts => {
|
.then(accounts => {
|
||||||
const accountIds = accounts.map(({ id }) => id);
|
const accountIds = accounts.map(({ id }) => id);
|
||||||
dispatch(fetchRelationships(accountIds));
|
dispatch(fetchRelationships(accountIds));
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => { });
|
||||||
} else {
|
} else {
|
||||||
// Do nothing
|
// Do nothing
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Virtuoso, Components } from 'react-virtuoso';
|
import { Virtuoso, Components, VirtuosoProps, VirtuosoHandle } from 'react-virtuoso';
|
||||||
|
|
||||||
import PullToRefresh from 'soapbox/components/pull-to-refresh';
|
import PullToRefresh from 'soapbox/components/pull-to-refresh';
|
||||||
import { useSettings } from 'soapbox/hooks';
|
import { useSettings } from 'soapbox/hooks';
|
||||||
|
@ -25,7 +25,7 @@ const List: Components<Context>['List'] = React.forwardRef((props, ref) => {
|
||||||
return <div ref={ref} className={context?.listClassName} {...rest} />;
|
return <div ref={ref} className={context?.listClassName} {...rest} />;
|
||||||
});
|
});
|
||||||
|
|
||||||
interface IScrollableList {
|
interface IScrollableList extends VirtuosoProps<any, any> {
|
||||||
scrollKey?: string,
|
scrollKey?: string,
|
||||||
onLoadMore?: () => void,
|
onLoadMore?: () => void,
|
||||||
isLoading?: boolean,
|
isLoading?: boolean,
|
||||||
|
@ -45,7 +45,7 @@ interface IScrollableList {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legacy ScrollableList with Virtuoso for backwards-compatibility */
|
/** Legacy ScrollableList with Virtuoso for backwards-compatibility */
|
||||||
const ScrollableList: React.FC<IScrollableList> = ({
|
const ScrollableList = React.forwardRef<VirtuosoHandle, IScrollableList>(({
|
||||||
prepend = null,
|
prepend = null,
|
||||||
alwaysPrepend,
|
alwaysPrepend,
|
||||||
children,
|
children,
|
||||||
|
@ -61,7 +61,9 @@ const ScrollableList: React.FC<IScrollableList> = ({
|
||||||
hasMore,
|
hasMore,
|
||||||
placeholderComponent: Placeholder,
|
placeholderComponent: Placeholder,
|
||||||
placeholderCount = 0,
|
placeholderCount = 0,
|
||||||
}) => {
|
initialTopMostItemIndex = 0,
|
||||||
|
scrollerRef,
|
||||||
|
}, ref) => {
|
||||||
const settings = useSettings();
|
const settings = useSettings();
|
||||||
const autoloadMore = settings.get('autoloadMore');
|
const autoloadMore = settings.get('autoloadMore');
|
||||||
|
|
||||||
|
@ -126,6 +128,7 @@ const ScrollableList: React.FC<IScrollableList> = ({
|
||||||
/** Render the actual Virtuoso list */
|
/** Render the actual Virtuoso list */
|
||||||
const renderFeed = (): JSX.Element => (
|
const renderFeed = (): JSX.Element => (
|
||||||
<Virtuoso
|
<Virtuoso
|
||||||
|
ref={ref}
|
||||||
useWindowScroll
|
useWindowScroll
|
||||||
className={className}
|
className={className}
|
||||||
data={data}
|
data={data}
|
||||||
|
@ -133,6 +136,7 @@ const ScrollableList: React.FC<IScrollableList> = ({
|
||||||
endReached={handleEndReached}
|
endReached={handleEndReached}
|
||||||
isScrolling={isScrolling => isScrolling && onScroll && onScroll()}
|
isScrolling={isScrolling => isScrolling && onScroll && onScroll()}
|
||||||
itemContent={renderItem}
|
itemContent={renderItem}
|
||||||
|
initialTopMostItemIndex={showLoading ? 0 : initialTopMostItemIndex}
|
||||||
context={{
|
context={{
|
||||||
listClassName: className,
|
listClassName: className,
|
||||||
itemClassName,
|
itemClassName,
|
||||||
|
@ -145,6 +149,7 @@ const ScrollableList: React.FC<IScrollableList> = ({
|
||||||
Item,
|
Item,
|
||||||
Footer: loadMore,
|
Footer: loadMore,
|
||||||
}}
|
}}
|
||||||
|
scrollerRef={scrollerRef}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -162,6 +167,6 @@ const ScrollableList: React.FC<IScrollableList> = ({
|
||||||
{renderBody()}
|
{renderBody()}
|
||||||
</PullToRefresh>
|
</PullToRefresh>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export default ScrollableList;
|
export default ScrollableList;
|
||||||
|
|
|
@ -15,6 +15,10 @@ interface IModal {
|
||||||
cancelAction?: () => void,
|
cancelAction?: () => void,
|
||||||
/** Cancel button text. */
|
/** Cancel button text. */
|
||||||
cancelText?: string,
|
cancelText?: string,
|
||||||
|
/** URL to an SVG icon for the close button. */
|
||||||
|
closeIcon?: string,
|
||||||
|
/** Position of the close button. */
|
||||||
|
closePosition?: 'left' | 'right',
|
||||||
/** Callback when the modal is confirmed. */
|
/** Callback when the modal is confirmed. */
|
||||||
confirmationAction?: () => void,
|
confirmationAction?: () => void,
|
||||||
/** Whether the confirmation button is disabled. */
|
/** Whether the confirmation button is disabled. */
|
||||||
|
@ -40,6 +44,8 @@ const Modal: React.FC<IModal> = ({
|
||||||
cancelAction,
|
cancelAction,
|
||||||
cancelText,
|
cancelText,
|
||||||
children,
|
children,
|
||||||
|
closeIcon = require('@tabler/icons/icons/x.svg'),
|
||||||
|
closePosition = 'right',
|
||||||
confirmationAction,
|
confirmationAction,
|
||||||
confirmationDisabled,
|
confirmationDisabled,
|
||||||
confirmationText,
|
confirmationText,
|
||||||
|
@ -63,14 +69,18 @@ const Modal: React.FC<IModal> = ({
|
||||||
<div data-testid='modal' className='block w-full max-w-xl p-6 mx-auto overflow-hidden text-left align-middle transition-all transform bg-white dark:bg-slate-800 text-black dark:text-white shadow-xl rounded-2xl pointer-events-auto'>
|
<div data-testid='modal' className='block w-full max-w-xl p-6 mx-auto overflow-hidden text-left align-middle transition-all transform bg-white dark:bg-slate-800 text-black dark:text-white shadow-xl rounded-2xl pointer-events-auto'>
|
||||||
<div className='sm:flex sm:items-start w-full justify-between'>
|
<div className='sm:flex sm:items-start w-full justify-between'>
|
||||||
<div className='w-full'>
|
<div className='w-full'>
|
||||||
<div className='w-full flex flex-row justify-between items-center'>
|
<div
|
||||||
<h3 className='text-lg leading-6 font-medium text-gray-900 dark:text-white'>
|
className={classNames('w-full flex items-center gap-2', {
|
||||||
|
'flex-row-reverse': closePosition === 'left',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<h3 className='flex-grow text-lg leading-6 font-medium text-gray-900 dark:text-white'>
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<IconButton
|
<IconButton
|
||||||
src={require('@tabler/icons/icons/x.svg')}
|
src={closeIcon}
|
||||||
title={intl.formatMessage(messages.close)}
|
title={intl.formatMessage(messages.close)}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className='text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-200'
|
className='text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-gray-200'
|
||||||
|
|
|
@ -86,7 +86,6 @@ class Header extends ImmutablePureComponent {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
account: ImmutablePropTypes.record,
|
account: ImmutablePropTypes.record,
|
||||||
meaccount: ImmutablePropTypes.record,
|
meaccount: ImmutablePropTypes.record,
|
||||||
identity_props: ImmutablePropTypes.list,
|
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
username: PropTypes.string,
|
username: PropTypes.string,
|
||||||
features: PropTypes.object,
|
features: PropTypes.object,
|
||||||
|
|
|
@ -13,7 +13,6 @@ class Header extends ImmutablePureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
account: ImmutablePropTypes.record,
|
account: ImmutablePropTypes.record,
|
||||||
identity_proofs: ImmutablePropTypes.list,
|
|
||||||
onFollow: PropTypes.func.isRequired,
|
onFollow: PropTypes.func.isRequired,
|
||||||
onBlock: PropTypes.func.isRequired,
|
onBlock: PropTypes.func.isRequired,
|
||||||
onMention: PropTypes.func.isRequired,
|
onMention: PropTypes.func.isRequired,
|
||||||
|
@ -143,7 +142,7 @@ class Header extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { account, identity_proofs } = this.props;
|
const { account } = this.props;
|
||||||
const moved = (account) ? account.get('moved') : false;
|
const moved = (account) ? account.get('moved') : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -152,7 +151,6 @@ class Header extends ImmutablePureComponent {
|
||||||
|
|
||||||
<InnerHeader
|
<InnerHeader
|
||||||
account={account}
|
account={account}
|
||||||
identity_proofs={identity_proofs}
|
|
||||||
onFollow={this.handleFollow}
|
onFollow={this.handleFollow}
|
||||||
onBlock={this.handleBlock}
|
onBlock={this.handleBlock}
|
||||||
onMention={this.handleMention}
|
onMention={this.handleMention}
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { List as ImmutableList } from 'immutable';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
@ -64,7 +63,6 @@ const makeMapStateToProps = () => {
|
||||||
|
|
||||||
const mapStateToProps = (state, { accountId }) => ({
|
const mapStateToProps = (state, { accountId }) => ({
|
||||||
account: getAccount(state, accountId),
|
account: getAccount(state, accountId),
|
||||||
identity_proofs: state.getIn(['identity_proofs', accountId], ImmutableList()),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return mapStateToProps;
|
return mapStateToProps;
|
||||||
|
|
|
@ -13,7 +13,6 @@ import { makeGetStatusIds, findAccountByUsername } from 'soapbox/selectors';
|
||||||
import { getFeatures } from 'soapbox/utils/features';
|
import { getFeatures } from 'soapbox/utils/features';
|
||||||
|
|
||||||
import { fetchAccount, fetchAccountByUsername } from '../../actions/accounts';
|
import { fetchAccount, fetchAccountByUsername } from '../../actions/accounts';
|
||||||
import { fetchAccountIdentityProofs } from '../../actions/identity_proofs';
|
|
||||||
import { fetchPatronAccount } from '../../actions/patron';
|
import { fetchPatronAccount } from '../../actions/patron';
|
||||||
import { expandAccountFeaturedTimeline, expandAccountTimeline } from '../../actions/timelines';
|
import { expandAccountFeaturedTimeline, expandAccountTimeline } from '../../actions/timelines';
|
||||||
import StatusList from '../../components/status_list';
|
import StatusList from '../../components/status_list';
|
||||||
|
@ -84,11 +83,10 @@ class AccountTimeline extends ImmutablePureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
const { params: { username }, accountId, accountApId, withReplies, me, patronEnabled } = this.props;
|
const { params: { username }, accountId, accountApId, withReplies, patronEnabled } = this.props;
|
||||||
|
|
||||||
if (accountId && accountId !== -1) {
|
if (accountId && accountId !== -1) {
|
||||||
this.props.dispatch(fetchAccount(accountId));
|
this.props.dispatch(fetchAccount(accountId));
|
||||||
if (me) this.props.dispatch(fetchAccountIdentityProofs(accountId));
|
|
||||||
|
|
||||||
if (!withReplies) {
|
if (!withReplies) {
|
||||||
this.props.dispatch(expandAccountFeaturedTimeline(accountId));
|
this.props.dispatch(expandAccountFeaturedTimeline(accountId));
|
||||||
|
@ -105,11 +103,10 @@ class AccountTimeline extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
componentDidUpdate(prevProps) {
|
||||||
const { params: { username }, me, accountId, withReplies, accountApId, patronEnabled } = this.props;
|
const { params: { username }, accountId, withReplies, accountApId, patronEnabled } = this.props;
|
||||||
|
|
||||||
if (accountId && (accountId !== -1) && (accountId !== prevProps.accountId) || withReplies !== prevProps.withReplies) {
|
if (accountId && (accountId !== -1) && (accountId !== prevProps.accountId) || withReplies !== prevProps.withReplies) {
|
||||||
this.props.dispatch(fetchAccount(accountId));
|
this.props.dispatch(fetchAccount(accountId));
|
||||||
if (me) this.props.dispatch(fetchAccountIdentityProofs(accountId));
|
|
||||||
|
|
||||||
if (!withReplies) {
|
if (!withReplies) {
|
||||||
this.props.dispatch(expandAccountFeaturedTimeline(accountId));
|
this.props.dispatch(expandAccountFeaturedTimeline(accountId));
|
||||||
|
|
|
@ -55,7 +55,7 @@ const AuthToken: React.FC<IAuthToken> = ({ token }) => {
|
||||||
const AuthTokenList: React.FC = () =>{
|
const AuthTokenList: React.FC = () =>{
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const tokens = useAppSelector(state => state.security.get('tokens'));
|
const tokens = useAppSelector(state => state.security.get('tokens').reverse());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(fetchOAuthTokens());
|
dispatch(fetchOAuthTokens());
|
||||||
|
|
|
@ -6,7 +6,6 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
import { defineMessages, injectIntl } from 'react-intl';
|
||||||
|
|
||||||
import Pullable from 'soapbox/components/pullable';
|
|
||||||
import ScrollableList from 'soapbox/components/scrollable_list';
|
import ScrollableList from 'soapbox/components/scrollable_list';
|
||||||
import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder_account';
|
import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder_account';
|
||||||
import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder_hashtag';
|
import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder_hashtag';
|
||||||
|
@ -151,24 +150,22 @@ class SearchResults extends ImmutablePureComponent {
|
||||||
{this.renderFilterBar()}
|
{this.renderFilterBar()}
|
||||||
|
|
||||||
{noResultsMessage || (
|
{noResultsMessage || (
|
||||||
<Pullable>
|
<ScrollableList
|
||||||
<ScrollableList
|
key={selectedFilter}
|
||||||
key={selectedFilter}
|
scrollKey={`${selectedFilter}:${value}`}
|
||||||
scrollKey={`${selectedFilter}:${value}`}
|
isLoading={submitted && !loaded}
|
||||||
isLoading={submitted && !loaded}
|
showLoading={submitted && !loaded && results.isEmpty()}
|
||||||
showLoading={submitted && !loaded && results.isEmpty()}
|
hasMore={hasMore}
|
||||||
hasMore={hasMore}
|
onLoadMore={this.handleLoadMore}
|
||||||
onLoadMore={this.handleLoadMore}
|
placeholderComponent={placeholderComponent}
|
||||||
placeholderComponent={placeholderComponent}
|
placeholderCount={20}
|
||||||
placeholderCount={20}
|
className={classNames({
|
||||||
className={classNames({
|
'divide-gray-200 dark:divide-slate-700 divide-solid divide-y': selectedFilter === 'statuses',
|
||||||
'divide-gray-200 dark:divide-slate-700 divide-solid divide-y': selectedFilter === 'statuses',
|
})}
|
||||||
'space-y-4': selectedFilter === 'accounts',
|
itemClassName={classNames({ 'pb-4': selectedFilter === 'accounts' })}
|
||||||
})}
|
>
|
||||||
>
|
{searchResults}
|
||||||
{searchResults}
|
</ScrollableList>
|
||||||
</ScrollableList>
|
|
||||||
</Pullable>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -76,24 +76,27 @@ const Header = () => {
|
||||||
<nav className='max-w-7xl mx-auto px-4 sm:px-6 lg:px-8' aria-label='Header'>
|
<nav className='max-w-7xl mx-auto px-4 sm:px-6 lg:px-8' aria-label='Header'>
|
||||||
<div className='w-full py-6 flex items-center justify-between border-b border-indigo-500 lg:border-none'>
|
<div className='w-full py-6 flex items-center justify-between border-b border-indigo-500 lg:border-none'>
|
||||||
<div className='flex items-center sm:justify-center relative w-36'>
|
<div className='flex items-center sm:justify-center relative w-36'>
|
||||||
<div className='hidden sm:block absolute z-0 -top-24 -left-6'>
|
<div className='hidden md:block absolute z-0 -top-24 -left-6'>
|
||||||
<Sonar />
|
<Sonar />
|
||||||
</div>
|
</div>
|
||||||
<Link to='/' className='z-10'>
|
|
||||||
<SiteLogo alt='Logo' className='h-6 w-auto cursor-pointer' />
|
|
||||||
<span className='hidden'>{intl.formatMessage(messages.home)}</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className='ml-10 flex space-x-6 items-center relative z-10'>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
title='Open Menu'
|
title='Open Menu'
|
||||||
src={require('@tabler/icons/icons/menu-2.svg')}
|
src={require('@tabler/icons/icons/menu-2.svg')}
|
||||||
onClick={open}
|
onClick={open}
|
||||||
className='md:hidden bg-transparent text-gray-400 hover:text-gray-600'
|
className='md:hidden mr-4 bg-transparent text-gray-400 hover:text-gray-600'
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className='hidden md:flex items-center space-x-6'>
|
<Link to='/' className='z-10'>
|
||||||
<HStack space={6} alignItems='center'>
|
<SiteLogo alt='Logo' className='h-6 w-auto cursor-pointer' />
|
||||||
|
<span className='hidden'>{intl.formatMessage(messages.home)}</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='ml-10 flex space-x-6 items-center relative z-10'>
|
||||||
|
<HStack alignItems='center'>
|
||||||
|
<HStack space={6} alignItems='center' className='hidden md:flex md:mr-6'>
|
||||||
{links.get('help') && (
|
{links.get('help') && (
|
||||||
<a
|
<a
|
||||||
href={links.get('help')}
|
href={links.get('help')}
|
||||||
|
@ -119,7 +122,7 @@ const Header = () => {
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
</div>
|
</HStack>
|
||||||
|
|
||||||
<Form className='hidden xl:flex space-x-2 items-center' onSubmit={handleSubmit}>
|
<Form className='hidden xl:flex space-x-2 items-center' onSubmit={handleSubmit}>
|
||||||
<Input
|
<Input
|
||||||
|
|
|
@ -65,6 +65,7 @@ import ThreadStatus from './components/thread-status';
|
||||||
|
|
||||||
import type { AxiosError } from 'axios';
|
import type { AxiosError } from 'axios';
|
||||||
import type { History } from 'history';
|
import type { History } from 'history';
|
||||||
|
import type { VirtuosoHandle } from 'react-virtuoso';
|
||||||
import type { AnyAction } from 'redux';
|
import type { AnyAction } from 'redux';
|
||||||
import type { ThunkDispatch } from 'redux-thunk';
|
import type { ThunkDispatch } from 'redux-thunk';
|
||||||
import type { RootState } from 'soapbox/store';
|
import type { RootState } from 'soapbox/store';
|
||||||
|
@ -212,6 +213,7 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
|
||||||
|
|
||||||
node: HTMLDivElement | null = null;
|
node: HTMLDivElement | null = null;
|
||||||
status: HTMLDivElement | null = null;
|
status: HTMLDivElement | null = null;
|
||||||
|
scroller: VirtuosoHandle | null = null;
|
||||||
_scrolledIntoView: boolean = false;
|
_scrolledIntoView: boolean = false;
|
||||||
|
|
||||||
fetchData = async() => {
|
fetchData = async() => {
|
||||||
|
@ -617,11 +619,10 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps: IStatus, prevState: IStatusState) {
|
componentDidUpdate(prevProps: IStatus, prevState: IStatusState) {
|
||||||
const { params, status, displayMedia } = this.props;
|
const { params, status, displayMedia, ancestorsIds } = this.props;
|
||||||
const { ancestorsIds } = prevProps;
|
const { isLoaded } = this.state;
|
||||||
|
|
||||||
if (params.statusId !== prevProps.params.statusId) {
|
if (params.statusId !== prevProps.params.statusId) {
|
||||||
this._scrolledIntoView = false;
|
|
||||||
this.fetchData();
|
this.fetchData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -629,17 +630,11 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
|
||||||
this.setState({ showMedia: defaultMediaVisibility(status, displayMedia), loadedStatusId: status.id });
|
this.setState({ showMedia: defaultMediaVisibility(status, displayMedia), loadedStatusId: status.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._scrolledIntoView) {
|
if (params.statusId !== prevProps.params.statusId || status?.id !== prevProps.status?.id || ancestorsIds.size > prevProps.ancestorsIds.size || isLoaded !== prevState.isLoaded) {
|
||||||
return;
|
this.scroller?.scrollToIndex({
|
||||||
}
|
index: this.props.ancestorsIds.size,
|
||||||
|
offset: -80,
|
||||||
if (prevProps.status && ancestorsIds && ancestorsIds.size > 0 && this.node) {
|
|
||||||
const element = this.node.querySelector('.detailed-status');
|
|
||||||
|
|
||||||
window.requestAnimationFrame(() => {
|
|
||||||
element?.scrollIntoView(true);
|
|
||||||
});
|
});
|
||||||
this._scrolledIntoView = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -674,6 +669,10 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setScrollerRef = (c: VirtuosoHandle) => {
|
||||||
|
this.scroller = c;
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { me, status, ancestorsIds, descendantsIds, intl } = this.props;
|
const { me, status, ancestorsIds, descendantsIds, intl } = this.props;
|
||||||
|
|
||||||
|
@ -791,10 +790,12 @@ class Status extends ImmutablePureComponent<IStatus, IStatusState> {
|
||||||
<Stack space={2}>
|
<Stack space={2}>
|
||||||
<div ref={this.setRef} className='thread'>
|
<div ref={this.setRef} className='thread'>
|
||||||
<ScrollableList
|
<ScrollableList
|
||||||
|
ref={this.setScrollerRef}
|
||||||
onRefresh={this.handleRefresh}
|
onRefresh={this.handleRefresh}
|
||||||
hasMore={!!this.state.next}
|
hasMore={!!this.state.next}
|
||||||
onLoadMore={this.handleLoadMore}
|
onLoadMore={this.handleLoadMore}
|
||||||
placeholderComponent={() => <PlaceholderStatus thread />}
|
placeholderComponent={() => <PlaceholderStatus thread />}
|
||||||
|
initialTopMostItemIndex={ancestorsIds.size}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ScrollableList>
|
</ScrollableList>
|
||||||
|
|
|
@ -30,13 +30,21 @@ const messages = defineMessages({
|
||||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||||
});
|
});
|
||||||
|
|
||||||
interface iActionButton {
|
interface IActionButton {
|
||||||
|
/** Target account for the action. */
|
||||||
account: AccountEntity
|
account: AccountEntity
|
||||||
|
/** Type of action to prioritize, eg on Blocks and Mutes pages. */
|
||||||
actionType?: 'muting' | 'blocking'
|
actionType?: 'muting' | 'blocking'
|
||||||
|
/** Displays shorter text on the "Awaiting approval" button. */
|
||||||
small?: boolean
|
small?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const ActionButton = ({ account, actionType, small }: iActionButton) => {
|
/**
|
||||||
|
* Circumstantial action button (usually "Follow") to display on accounts.
|
||||||
|
* May say "Unblock" or something else, depending on the relationship and
|
||||||
|
* `actionType` prop.
|
||||||
|
*/
|
||||||
|
const ActionButton: React.FC<IActionButton> = ({ account, actionType, small }) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const features = useFeatures();
|
const features = useFeatures();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
@ -45,40 +53,41 @@ const ActionButton = ({ account, actionType, small }: iActionButton) => {
|
||||||
|
|
||||||
const handleFollow = () => {
|
const handleFollow = () => {
|
||||||
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
|
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
|
||||||
dispatch(unfollowAccount(account.get('id')));
|
dispatch(unfollowAccount(account.id));
|
||||||
} else {
|
} else {
|
||||||
dispatch(followAccount(account.get('id')));
|
dispatch(followAccount(account.id));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBlock = () => {
|
const handleBlock = () => {
|
||||||
if (account.getIn(['relationship', 'blocking'])) {
|
if (account.getIn(['relationship', 'blocking'])) {
|
||||||
dispatch(unblockAccount(account.get('id')));
|
dispatch(unblockAccount(account.id));
|
||||||
} else {
|
} else {
|
||||||
dispatch(blockAccount(account.get('id')));
|
dispatch(blockAccount(account.id));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMute = () => {
|
const handleMute = () => {
|
||||||
if (account.getIn(['relationship', 'muting'])) {
|
if (account.getIn(['relationship', 'muting'])) {
|
||||||
dispatch(unmuteAccount(account.get('id')));
|
dispatch(unmuteAccount(account.id));
|
||||||
} else {
|
} else {
|
||||||
dispatch(muteAccount(account.get('id')));
|
dispatch(muteAccount(account.id));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoteFollow = () => {
|
const handleRemoteFollow = () => {
|
||||||
dispatch(openModal('UNAUTHORIZED', {
|
dispatch(openModal('UNAUTHORIZED', {
|
||||||
action: 'FOLLOW',
|
action: 'FOLLOW',
|
||||||
account: account.get('id'),
|
account: account.id,
|
||||||
ap_id: account.get('url'),
|
ap_id: account.url,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Handles actionType='muting' */
|
||||||
const mutingAction = () => {
|
const mutingAction = () => {
|
||||||
const isMuted = account.getIn(['relationship', 'muting']);
|
const isMuted = account.getIn(['relationship', 'muting']);
|
||||||
const messageKey = isMuted ? messages.unmute : messages.mute;
|
const messageKey = isMuted ? messages.unmute : messages.mute;
|
||||||
const text = intl.formatMessage(messageKey, { name: account.get('username') });
|
const text = intl.formatMessage(messageKey, { name: account.username });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
@ -90,10 +99,11 @@ const ActionButton = ({ account, actionType, small }: iActionButton) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Handles actionType='blocking' */
|
||||||
const blockingAction = () => {
|
const blockingAction = () => {
|
||||||
const isBlocked = account.getIn(['relationship', 'blocking']);
|
const isBlocked = account.getIn(['relationship', 'blocking']);
|
||||||
const messageKey = isBlocked ? messages.unblock : messages.block;
|
const messageKey = isBlocked ? messages.unblock : messages.block;
|
||||||
const text = intl.formatMessage(messageKey, { name: account.get('username') });
|
const text = intl.formatMessage(messageKey, { name: account.username });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
@ -105,10 +115,9 @@ const ActionButton = ({ account, actionType, small }: iActionButton) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const empty = <></>;
|
/** Render a remote follow button, depending on features. */
|
||||||
|
const renderRemoteFollow = () => {
|
||||||
if (!me) {
|
// Remote follow through the API.
|
||||||
// Remote follow
|
|
||||||
if (features.remoteInteractionsAPI) {
|
if (features.remoteInteractionsAPI) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
@ -117,18 +126,34 @@ const ActionButton = ({ account, actionType, small }: iActionButton) => {
|
||||||
text={intl.formatMessage(messages.follow)}
|
text={intl.formatMessage(messages.follow)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
// Pleroma's classic remote follow form.
|
||||||
|
} else if (features.pleromaRemoteFollow) {
|
||||||
|
return (
|
||||||
|
<form method='POST' action='/main/ostatus'>
|
||||||
|
<input type='hidden' name='nickname' value={account.acct} />
|
||||||
|
<input type='hidden' name='profile' value='' />
|
||||||
|
<Button text={intl.formatMessage(messages.remote_follow)} type='submit' />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return null;
|
||||||
<form method='POST' action='/main/ostatus'>
|
};
|
||||||
<input type='hidden' name='nickname' value={account.get('acct')} />
|
|
||||||
<input type='hidden' name='profile' value='' />
|
/** Render remote follow if federating, otherwise hide the button. */
|
||||||
<Button text={intl.formatMessage(messages.remote_follow)} type='submit' />
|
const renderLoggedOut = () => {
|
||||||
</form>
|
if (features.federating) {
|
||||||
);
|
return renderRemoteFollow();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!me) {
|
||||||
|
return renderLoggedOut();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (me !== account.get('id')) {
|
if (me !== account.id) {
|
||||||
const isFollowing = account.getIn(['relationship', 'following']);
|
const isFollowing = account.getIn(['relationship', 'following']);
|
||||||
const blockedBy = account.getIn(['relationship', 'blocked_by']) as boolean;
|
const blockedBy = account.getIn(['relationship', 'blocked_by']) as boolean;
|
||||||
|
|
||||||
|
@ -140,9 +165,9 @@ const ActionButton = ({ account, actionType, small }: iActionButton) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!account.get('relationship')) {
|
if (account.relationship.isEmpty()) {
|
||||||
// Wait until the relationship is loaded
|
// Wait until the relationship is loaded
|
||||||
return empty;
|
return null;
|
||||||
} else if (account.getIn(['relationship', 'requested'])) {
|
} else if (account.getIn(['relationship', 'requested'])) {
|
||||||
// Awaiting acceptance
|
// Awaiting acceptance
|
||||||
return (
|
return (
|
||||||
|
@ -176,7 +201,7 @@ const ActionButton = ({ account, actionType, small }: iActionButton) => {
|
||||||
<Button
|
<Button
|
||||||
theme='danger'
|
theme='danger'
|
||||||
size='sm'
|
size='sm'
|
||||||
text={intl.formatMessage(messages.unblock, { name: account.get('username') })}
|
text={intl.formatMessage(messages.unblock, { name: account.username })}
|
||||||
onClick={handleBlock}
|
onClick={handleBlock}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -193,7 +218,7 @@ const ActionButton = ({ account, actionType, small }: iActionButton) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return empty;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ActionButton;
|
export default ActionButton;
|
||||||
|
|
|
@ -29,6 +29,8 @@ const ReplyMentionsModal: React.FC<IReplyMentionsModal> = ({ onClose }) => {
|
||||||
<Modal
|
<Modal
|
||||||
title={<FormattedMessage id='navigation_bar.in_reply_to' defaultMessage='In reply to' />}
|
title={<FormattedMessage id='navigation_bar.in_reply_to' defaultMessage='In reply to' />}
|
||||||
onClose={onClickClose}
|
onClose={onClickClose}
|
||||||
|
closeIcon={require('@tabler/icons/icons/arrow-left.svg')}
|
||||||
|
closePosition='left'
|
||||||
>
|
>
|
||||||
<div className='reply-mentions-modal__accounts'>
|
<div className='reply-mentions-modal__accounts'>
|
||||||
{mentions.map(accountId => <Account key={accountId} accountId={accountId} added author={author === accountId} />)}
|
{mentions.map(accountId => <Account key={accountId} accountId={accountId} added author={author === accountId} />)}
|
||||||
|
|
|
@ -1,32 +0,0 @@
|
||||||
import { Map as ImmutableMap } from 'immutable';
|
|
||||||
|
|
||||||
import * as actions from 'soapbox/actions/identity_proofs';
|
|
||||||
|
|
||||||
import reducer from '../identity_proofs';
|
|
||||||
|
|
||||||
describe('identity_proofs reducer', () => {
|
|
||||||
it('should return the initial state', () => {
|
|
||||||
expect(reducer(undefined, {})).toEqual(ImmutableMap());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST', () => {
|
|
||||||
const state = ImmutableMap({ isLoading: false });
|
|
||||||
const action = {
|
|
||||||
type: actions.IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST,
|
|
||||||
};
|
|
||||||
expect(reducer(state, action).toJS()).toMatchObject({
|
|
||||||
isLoading: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL', () => {
|
|
||||||
const state = ImmutableMap({ isLoading: true });
|
|
||||||
const action = {
|
|
||||||
type: actions.IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL,
|
|
||||||
};
|
|
||||||
expect(reducer(state, action).toJS()).toMatchObject({
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
|
@ -1,26 +0,0 @@
|
||||||
import { Map as ImmutableMap, fromJS } from 'immutable';
|
|
||||||
|
|
||||||
import {
|
|
||||||
IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST,
|
|
||||||
IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS,
|
|
||||||
IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL,
|
|
||||||
} from '../actions/identity_proofs';
|
|
||||||
|
|
||||||
const initialState = ImmutableMap();
|
|
||||||
|
|
||||||
export default function identityProofsReducer(state = initialState, action) {
|
|
||||||
switch (action.type) {
|
|
||||||
case IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST:
|
|
||||||
return state.set('isLoading', true);
|
|
||||||
case IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL:
|
|
||||||
return state.set('isLoading', false);
|
|
||||||
case IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS:
|
|
||||||
return state.update(identity_proofs => identity_proofs.withMutations(map => {
|
|
||||||
map.set('isLoading', false);
|
|
||||||
map.set('loaded', true);
|
|
||||||
map.set(action.accountId, fromJS(action.identity_proofs));
|
|
||||||
}));
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -29,7 +29,6 @@ import group_lists from './group_lists';
|
||||||
import group_relationships from './group_relationships';
|
import group_relationships from './group_relationships';
|
||||||
import groups from './groups';
|
import groups from './groups';
|
||||||
import history from './history';
|
import history from './history';
|
||||||
import identity_proofs from './identity_proofs';
|
|
||||||
import instance from './instance';
|
import instance from './instance';
|
||||||
import listAdder from './list_adder';
|
import listAdder from './list_adder';
|
||||||
import listEditor from './list_editor';
|
import listEditor from './list_editor';
|
||||||
|
@ -86,7 +85,6 @@ const reducers = {
|
||||||
search,
|
search,
|
||||||
notifications,
|
notifications,
|
||||||
custom_emojis,
|
custom_emojis,
|
||||||
identity_proofs,
|
|
||||||
lists,
|
lists,
|
||||||
listEditor,
|
listEditor,
|
||||||
listAdder,
|
listAdder,
|
||||||
|
|
|
@ -356,6 +356,12 @@ const getInstanceFeatures = (instance: Instance) => {
|
||||||
*/
|
*/
|
||||||
paginatedContext: v.software === TRUTHSOCIAL,
|
paginatedContext: v.software === TRUTHSOCIAL,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays a form to follow a user when logged out.
|
||||||
|
* @see POST /main/ostatus
|
||||||
|
*/
|
||||||
|
pleromaRemoteFollow: v.software === PLEROMA,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Can add polls to statuses.
|
* Can add polls to statuses.
|
||||||
* @see POST /api/v1/statuses
|
* @see POST /api/v1/statuses
|
||||||
|
|
|
@ -691,8 +691,6 @@ If it's not documented, it's because I inherited it from Mastodon and I don't kn
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
- `identity_proofs`
|
|
||||||
|
|
||||||
- `lists`
|
- `lists`
|
||||||
|
|
||||||
Sample:
|
Sample:
|
||||||
|
|
Loading…
Reference in a new issue