mirror of
https://github.com/mastodon/mastodon.git
synced 2025-11-29 10:53:39 +00:00
370 lines
10 KiB
TypeScript
370 lines
10 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import { FormattedMessage } from 'react-intl';
|
|
|
|
import type { Map as ImmutableMap } from 'immutable';
|
|
|
|
import { LearnMoreLink } from 'mastodon/components/learn_more_link';
|
|
import StatusContainer from 'mastodon/containers/status_container';
|
|
import { domain } from 'mastodon/initial_state';
|
|
import type { Account } from 'mastodon/models/account';
|
|
import type { Status } from 'mastodon/models/status';
|
|
import type { RootState } from 'mastodon/store';
|
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
|
|
|
import { fetchRelationships } from '../actions/accounts';
|
|
import { revealAccount } from '../actions/accounts_typed';
|
|
import { fetchStatus } from '../actions/statuses';
|
|
import { makeGetStatusWithExtraInfo } from '../selectors';
|
|
import { getAccountHidden } from '../selectors/accounts';
|
|
|
|
import { Button } from './button';
|
|
|
|
const MAX_QUOTE_POSTS_NESTING_LEVEL = 1;
|
|
|
|
const NestedQuoteLink: React.FC<{ status: Status }> = ({ status }) => {
|
|
const accountObjectOrId = status.get('account') as string | Account;
|
|
const accountId =
|
|
typeof accountObjectOrId === 'string'
|
|
? accountObjectOrId
|
|
: accountObjectOrId.id;
|
|
|
|
const account = useAppSelector((state) =>
|
|
accountId ? state.accounts.get(accountId) : undefined,
|
|
);
|
|
|
|
const quoteAuthorName = account?.acct;
|
|
|
|
if (!quoteAuthorName) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className='status__quote-author-button'>
|
|
<FormattedMessage
|
|
id='status.quote_post_author'
|
|
defaultMessage='Quoted a post by @{name}'
|
|
values={{ name: quoteAuthorName }}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
type GetStatusSelector = (
|
|
state: RootState,
|
|
props: { id?: string | null; contextType?: string },
|
|
) => {
|
|
status: Status | null;
|
|
loadingState: 'not-found' | 'loading' | 'filtered' | 'complete';
|
|
};
|
|
|
|
type QuoteMap = ImmutableMap<'state' | 'quoted_status', string | null>;
|
|
|
|
const LimitedAccountHint: React.FC<{ accountId: string }> = ({ accountId }) => {
|
|
const dispatch = useAppDispatch();
|
|
const reveal = useCallback(() => {
|
|
dispatch(revealAccount({ id: accountId }));
|
|
}, [dispatch, accountId]);
|
|
|
|
return (
|
|
<>
|
|
<FormattedMessage
|
|
id='status.quote_error.limited_account_hint.title'
|
|
defaultMessage='This account has been hidden by the moderators of {domain}.'
|
|
values={{ domain }}
|
|
/>
|
|
<button onClick={reveal} className='link-button' type='button'>
|
|
<FormattedMessage
|
|
id='status.quote_error.limited_account_hint.action'
|
|
defaultMessage='Show anyway'
|
|
/>
|
|
</button>
|
|
</>
|
|
);
|
|
};
|
|
|
|
const FilteredQuote: React.FC<{
|
|
reveal: VoidFunction;
|
|
quotedAccountId: string;
|
|
quoteState: string;
|
|
}> = ({ reveal, quotedAccountId, quoteState }) => {
|
|
const account = useAppSelector((state) =>
|
|
quotedAccountId ? state.accounts.get(quotedAccountId) : undefined,
|
|
);
|
|
|
|
const quoteAuthorName = account?.acct;
|
|
const domain = quoteAuthorName?.split('@')[1];
|
|
|
|
let message;
|
|
|
|
switch (quoteState) {
|
|
case 'blocked_account':
|
|
message = (
|
|
<FormattedMessage
|
|
id='status.quote_error.blocked_account_hint.title'
|
|
defaultMessage="This post is hidden because you've blocked @{name}."
|
|
values={{ name: quoteAuthorName }}
|
|
/>
|
|
);
|
|
break;
|
|
case 'blocked_domain':
|
|
message = (
|
|
<FormattedMessage
|
|
id='status.quote_error.blocked_domain_hint.title'
|
|
defaultMessage="This post is hidden because you've blocked {domain}."
|
|
values={{ domain }}
|
|
/>
|
|
);
|
|
break;
|
|
case 'muted_account':
|
|
message = (
|
|
<FormattedMessage
|
|
id='status.quote_error.muted_account_hint.title'
|
|
defaultMessage="This post is hidden because you've muted @{name}."
|
|
values={{ name: quoteAuthorName }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{message}
|
|
<button onClick={reveal} className='link-button' type='button'>
|
|
<FormattedMessage
|
|
id='status.quote_error.limited_account_hint.action'
|
|
defaultMessage='Show anyway'
|
|
/>
|
|
</button>
|
|
</>
|
|
);
|
|
};
|
|
|
|
interface QuotedStatusProps {
|
|
quote: QuoteMap;
|
|
contextType?: string;
|
|
parentQuotePostId?: string | null;
|
|
variant?: 'full' | 'link';
|
|
nestingLevel?: number;
|
|
onQuoteCancel?: () => void; // Used for composer.
|
|
}
|
|
|
|
export const QuotedStatus: React.FC<QuotedStatusProps> = ({
|
|
quote,
|
|
contextType,
|
|
parentQuotePostId,
|
|
nestingLevel = 1,
|
|
variant = 'full',
|
|
onQuoteCancel,
|
|
}) => {
|
|
const dispatch = useAppDispatch();
|
|
const quoteState = useAppSelector((state) =>
|
|
parentQuotePostId
|
|
? state.statuses.getIn([parentQuotePostId, 'quote', 'state'])
|
|
: quote.get('state'),
|
|
);
|
|
|
|
const quotedStatusId = quote.get('quoted_status');
|
|
const getStatusSelector = useMemo(
|
|
() => makeGetStatusWithExtraInfo() as GetStatusSelector,
|
|
[],
|
|
);
|
|
const { status, loadingState } = useAppSelector((state) =>
|
|
getStatusSelector(state, { id: quotedStatusId, contextType }),
|
|
);
|
|
|
|
const accountId: string | null = status?.get('account')
|
|
? (status.get('account') as Account).id
|
|
: null;
|
|
const hiddenAccount = useAppSelector(
|
|
(state) => accountId && getAccountHidden(state, accountId),
|
|
);
|
|
|
|
const shouldFetchQuote =
|
|
!status?.get('isLoading') &&
|
|
quoteState !== 'deleted' &&
|
|
loadingState === 'not-found';
|
|
const isLoaded = loadingState === 'complete';
|
|
|
|
const isFetchingQuoteRef = useRef(false);
|
|
const [revealed, setRevealed] = useState(false);
|
|
|
|
const reveal = useCallback(() => {
|
|
setRevealed(true);
|
|
}, [setRevealed]);
|
|
|
|
useEffect(() => {
|
|
if (isLoaded) {
|
|
isFetchingQuoteRef.current = false;
|
|
}
|
|
}, [isLoaded]);
|
|
|
|
useEffect(() => {
|
|
if (shouldFetchQuote && quotedStatusId && !isFetchingQuoteRef.current) {
|
|
dispatch(
|
|
fetchStatus(quotedStatusId, {
|
|
parentQuotePostId,
|
|
alsoFetchContext: false,
|
|
}),
|
|
);
|
|
isFetchingQuoteRef.current = true;
|
|
}
|
|
}, [shouldFetchQuote, quotedStatusId, parentQuotePostId, dispatch]);
|
|
|
|
useEffect(() => {
|
|
if (accountId && hiddenAccount) dispatch(fetchRelationships([accountId]));
|
|
}, [accountId, hiddenAccount, dispatch]);
|
|
|
|
const isFilteredAndHidden = loadingState === 'filtered';
|
|
|
|
let quoteError: React.ReactNode = null;
|
|
|
|
if (isFilteredAndHidden) {
|
|
quoteError = (
|
|
<FormattedMessage
|
|
id='status.quote_error.filtered'
|
|
defaultMessage='Hidden due to one of your filters'
|
|
/>
|
|
);
|
|
} else if (quoteState === 'pending') {
|
|
quoteError = (
|
|
<>
|
|
<FormattedMessage
|
|
id='status.quote_error.pending_approval'
|
|
defaultMessage='Post pending'
|
|
/>
|
|
|
|
<LearnMoreLink>
|
|
<p>
|
|
<FormattedMessage
|
|
id='status.quote_error.pending_approval_popout.body'
|
|
defaultMessage="On Mastodon, you can control whether someone can quote you. This post is pending while we're getting the original author's approval."
|
|
/>
|
|
</p>
|
|
</LearnMoreLink>
|
|
</>
|
|
);
|
|
} else if (quoteState === 'revoked') {
|
|
quoteError = (
|
|
<FormattedMessage
|
|
id='status.quote_error.revoked'
|
|
defaultMessage='Post removed by author'
|
|
/>
|
|
);
|
|
} else if (
|
|
(quoteState === 'blocked_account' ||
|
|
quoteState === 'blocked_domain' ||
|
|
quoteState === 'muted_account') &&
|
|
!revealed &&
|
|
accountId
|
|
) {
|
|
quoteError = (
|
|
<FilteredQuote
|
|
quoteState={quoteState}
|
|
reveal={reveal}
|
|
quotedAccountId={accountId}
|
|
/>
|
|
);
|
|
} else if (
|
|
!status ||
|
|
!quotedStatusId ||
|
|
quoteState === 'deleted' ||
|
|
quoteState === 'rejected' ||
|
|
quoteState === 'unauthorized'
|
|
) {
|
|
quoteError = (
|
|
<FormattedMessage
|
|
id='status.quote_error.not_available'
|
|
defaultMessage='Post unavailable'
|
|
/>
|
|
);
|
|
} else if (hiddenAccount && accountId) {
|
|
quoteError = <LimitedAccountHint accountId={accountId} />;
|
|
}
|
|
|
|
if (quoteError) {
|
|
const hasRemoveButton = contextType === 'composer' && !!onQuoteCancel;
|
|
|
|
return (
|
|
<div className='status__quote status__quote--error'>
|
|
{quoteError}
|
|
{hasRemoveButton && (
|
|
<Button compact plain onClick={onQuoteCancel}>
|
|
<FormattedMessage
|
|
id='status.remove_quote'
|
|
defaultMessage='Remove'
|
|
/>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (variant === 'link' && status) {
|
|
return <NestedQuoteLink status={status} />;
|
|
}
|
|
|
|
const childQuote = status?.get('quote') as QuoteMap | undefined;
|
|
const canRenderChildQuote =
|
|
childQuote && nestingLevel <= MAX_QUOTE_POSTS_NESTING_LEVEL;
|
|
|
|
return (
|
|
<div className='status__quote'>
|
|
{/* @ts-expect-error Status is not yet typed */}
|
|
<StatusContainer
|
|
isQuotedPost
|
|
id={quotedStatusId}
|
|
contextType={contextType}
|
|
avatarSize={32}
|
|
onQuoteCancel={onQuoteCancel}
|
|
>
|
|
{canRenderChildQuote && (
|
|
<QuotedStatus
|
|
quote={childQuote}
|
|
parentQuotePostId={quotedStatusId}
|
|
contextType={contextType}
|
|
variant={
|
|
nestingLevel === MAX_QUOTE_POSTS_NESTING_LEVEL ? 'link' : 'full'
|
|
}
|
|
nestingLevel={nestingLevel + 1}
|
|
/>
|
|
)}
|
|
</StatusContainer>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface StatusQuoteManagerProps {
|
|
id: string;
|
|
contextType?: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
/**
|
|
* This wrapper component takes a status ID and, if the associated status
|
|
* is a quote post, it renders the quote into `StatusContainer` as a child.
|
|
* It passes all other props through to `StatusContainer`.
|
|
*/
|
|
|
|
export const StatusQuoteManager = (props: StatusQuoteManagerProps) => {
|
|
const status = useAppSelector((state) => {
|
|
const status = state.statuses.get(props.id);
|
|
const reblogId = status?.get('reblog') as string | undefined;
|
|
return reblogId ? state.statuses.get(reblogId) : status;
|
|
});
|
|
const quote = status?.get('quote') as QuoteMap | undefined;
|
|
|
|
if (quote) {
|
|
return (
|
|
<StatusContainer {...props}>
|
|
<QuotedStatus
|
|
quote={quote}
|
|
parentQuotePostId={status?.get('id') as string}
|
|
contextType={props.contextType}
|
|
/>
|
|
</StatusContainer>
|
|
);
|
|
}
|
|
|
|
return <StatusContainer {...props} />;
|
|
};
|