Profile redesign: Show yourself in follower list (#37813)
This commit is contained in:
parent
20fedab093
commit
bbd88d356d
@ -0,0 +1,64 @@
|
|||||||
|
import type { FC, ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { LimitedAccountHint } from '@/mastodon/features/account_timeline/components/limited_account_hint';
|
||||||
|
import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
|
||||||
|
import type { Account } from '@/mastodon/models/account';
|
||||||
|
|
||||||
|
import { RemoteHint } from './remote';
|
||||||
|
|
||||||
|
interface BaseEmptyMessageProps {
|
||||||
|
account?: Account;
|
||||||
|
defaultMessage: ReactNode;
|
||||||
|
}
|
||||||
|
export type EmptyMessageProps = Omit<BaseEmptyMessageProps, 'defaultMessage'>;
|
||||||
|
|
||||||
|
export const BaseEmptyMessage: FC<BaseEmptyMessageProps> = ({
|
||||||
|
account,
|
||||||
|
defaultMessage,
|
||||||
|
}) => {
|
||||||
|
const { blockedBy, hidden, suspended } = useAccountVisibility(account?.id);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (suspended) {
|
||||||
|
return (
|
||||||
|
<FormattedMessage
|
||||||
|
id='empty_column.account_suspended'
|
||||||
|
defaultMessage='Account suspended'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hidden) {
|
||||||
|
return <LimitedAccountHint accountId={account.id} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blockedBy) {
|
||||||
|
return (
|
||||||
|
<FormattedMessage
|
||||||
|
id='empty_column.account_unavailable'
|
||||||
|
defaultMessage='Profile unavailable'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (account.hide_collections) {
|
||||||
|
return (
|
||||||
|
<FormattedMessage
|
||||||
|
id='empty_column.account_hides_collections'
|
||||||
|
defaultMessage='This user has chosen to not make this information available'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = account.acct.split('@')[1];
|
||||||
|
if (domain) {
|
||||||
|
return <RemoteHint domain={domain} url={account.url} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultMessage;
|
||||||
|
};
|
||||||
104
app/javascript/mastodon/features/followers/components/list.tsx
Normal file
104
app/javascript/mastodon/features/followers/components/list.tsx
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { FC, ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { Account } from '@/mastodon/components/account';
|
||||||
|
import { Column } from '@/mastodon/components/column';
|
||||||
|
import { ColumnBackButton } from '@/mastodon/components/column_back_button';
|
||||||
|
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||||
|
import ScrollableList from '@/mastodon/components/scrollable_list';
|
||||||
|
import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
|
||||||
|
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||||
|
import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
|
||||||
|
import { useLayout } from '@/mastodon/hooks/useLayout';
|
||||||
|
|
||||||
|
import { AccountHeader } from '../../account_timeline/components/account_header';
|
||||||
|
|
||||||
|
import { RemoteHint } from './remote';
|
||||||
|
|
||||||
|
export interface AccountList {
|
||||||
|
hasMore: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
items: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccountListProps {
|
||||||
|
accountId?: string | null;
|
||||||
|
append?: ReactNode;
|
||||||
|
emptyMessage: ReactNode;
|
||||||
|
footer?: ReactNode;
|
||||||
|
list?: AccountList | null;
|
||||||
|
loadMore: () => void;
|
||||||
|
prependAccountId?: string | null;
|
||||||
|
scrollKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AccountList: FC<AccountListProps> = ({
|
||||||
|
accountId,
|
||||||
|
append,
|
||||||
|
emptyMessage,
|
||||||
|
footer,
|
||||||
|
list,
|
||||||
|
loadMore,
|
||||||
|
prependAccountId,
|
||||||
|
scrollKey,
|
||||||
|
}) => {
|
||||||
|
const account = useAccount(accountId);
|
||||||
|
|
||||||
|
const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
|
||||||
|
const forceEmptyState = blockedBy || hidden || suspended;
|
||||||
|
|
||||||
|
const children = useMemo(() => {
|
||||||
|
if (forceEmptyState) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const children =
|
||||||
|
list?.items.map((followerId) => (
|
||||||
|
<Account key={followerId} id={followerId} />
|
||||||
|
)) ?? [];
|
||||||
|
|
||||||
|
if (prependAccountId) {
|
||||||
|
children.unshift(
|
||||||
|
<Account key={prependAccountId} id={prependAccountId} minimal />,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
}, [prependAccountId, list, forceEmptyState]);
|
||||||
|
|
||||||
|
const { multiColumn } = useLayout();
|
||||||
|
|
||||||
|
// Null means accountId does not exist (e.g. invalid acct). Undefined means loading.
|
||||||
|
if (accountId === null) {
|
||||||
|
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accountId || !account) {
|
||||||
|
return (
|
||||||
|
<Column bindToDocument={!multiColumn}>
|
||||||
|
<LoadingIndicator />
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = account.acct.split('@')[1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column>
|
||||||
|
<ColumnBackButton />
|
||||||
|
|
||||||
|
<ScrollableList
|
||||||
|
scrollKey={scrollKey}
|
||||||
|
hasMore={!forceEmptyState && list?.hasMore}
|
||||||
|
isLoading={list?.isLoading ?? true}
|
||||||
|
onLoadMore={loadMore}
|
||||||
|
prepend={<AccountHeader accountId={accountId} hideTabs />}
|
||||||
|
alwaysPrepend
|
||||||
|
append={append ?? <RemoteHint domain={domain} url={account.url} />}
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
|
bindToDocument={!multiColumn}
|
||||||
|
footer={footer}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollableList>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { TimelineHint } from '@/mastodon/components/timeline_hint';
|
||||||
|
|
||||||
|
export const RemoteHint: FC<{ domain?: string; url: string }> = ({
|
||||||
|
domain,
|
||||||
|
url,
|
||||||
|
}) => {
|
||||||
|
if (!domain) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<TimelineHint
|
||||||
|
url={url}
|
||||||
|
message={
|
||||||
|
<FormattedMessage
|
||||||
|
id='hints.profiles.followers_may_be_missing'
|
||||||
|
defaultMessage='Followers for this profile may be missing.'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id='hints.profiles.see_more_followers'
|
||||||
|
defaultMessage='See more followers on {domain}'
|
||||||
|
values={{ domain: <strong>{domain}</strong> }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,187 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { debounce } from 'lodash';
|
|
||||||
|
|
||||||
import { Account } from 'mastodon/components/account';
|
|
||||||
import { TimelineHint } from 'mastodon/components/timeline_hint';
|
|
||||||
import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header';
|
|
||||||
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
|
||||||
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
|
|
||||||
import { getAccountHidden } from 'mastodon/selectors/accounts';
|
|
||||||
import { useAppSelector } from 'mastodon/store';
|
|
||||||
|
|
||||||
import {
|
|
||||||
lookupAccount,
|
|
||||||
fetchAccount,
|
|
||||||
fetchFollowers,
|
|
||||||
expandFollowers,
|
|
||||||
} from '../../actions/accounts';
|
|
||||||
import { ColumnBackButton } from '../../components/column_back_button';
|
|
||||||
import { LoadingIndicator } from '../../components/loading_indicator';
|
|
||||||
import ScrollableList from '../../components/scrollable_list';
|
|
||||||
import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint';
|
|
||||||
import Column from '../ui/components/column';
|
|
||||||
|
|
||||||
const mapStateToProps = (state, { params: { acct, id } }) => {
|
|
||||||
const accountId = id || state.accounts_map[normalizeForLookup(acct)];
|
|
||||||
|
|
||||||
if (!accountId) {
|
|
||||||
return {
|
|
||||||
isLoading: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
accountId,
|
|
||||||
remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
|
|
||||||
remoteUrl: state.getIn(['accounts', accountId, 'url']),
|
|
||||||
isAccount: !!state.getIn(['accounts', accountId]),
|
|
||||||
accountIds: state.getIn(['user_lists', 'followers', accountId, 'items']),
|
|
||||||
hasMore: !!state.getIn(['user_lists', 'followers', accountId, 'next']),
|
|
||||||
isLoading: state.getIn(['user_lists', 'followers', accountId, 'isLoading'], true),
|
|
||||||
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
|
|
||||||
hideCollections: state.getIn(['accounts', accountId, 'hide_collections'], false),
|
|
||||||
hidden: getAccountHidden(state, accountId),
|
|
||||||
blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const RemoteHint = ({ accountId, url }) => {
|
|
||||||
const acct = useAppSelector(state => state.accounts.get(accountId)?.acct);
|
|
||||||
const domain = acct ? acct.split('@')[1] : undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TimelineHint
|
|
||||||
url={url}
|
|
||||||
message={<FormattedMessage id='hints.profiles.followers_may_be_missing' defaultMessage='Followers for this profile may be missing.' />}
|
|
||||||
label={<FormattedMessage id='hints.profiles.see_more_followers' defaultMessage='See more followers on {domain}' values={{ domain: <strong>{domain}</strong> }} />}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
RemoteHint.propTypes = {
|
|
||||||
url: PropTypes.string.isRequired,
|
|
||||||
accountId: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
class Followers extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
params: PropTypes.shape({
|
|
||||||
acct: PropTypes.string,
|
|
||||||
id: PropTypes.string,
|
|
||||||
}).isRequired,
|
|
||||||
accountId: PropTypes.string,
|
|
||||||
dispatch: PropTypes.func.isRequired,
|
|
||||||
accountIds: ImmutablePropTypes.list,
|
|
||||||
hasMore: PropTypes.bool,
|
|
||||||
isLoading: PropTypes.bool,
|
|
||||||
blockedBy: PropTypes.bool,
|
|
||||||
isAccount: PropTypes.bool,
|
|
||||||
suspended: PropTypes.bool,
|
|
||||||
hidden: PropTypes.bool,
|
|
||||||
remote: PropTypes.bool,
|
|
||||||
remoteUrl: PropTypes.string,
|
|
||||||
multiColumn: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
_load () {
|
|
||||||
const { accountId, isAccount, dispatch } = this.props;
|
|
||||||
|
|
||||||
if (!isAccount) dispatch(fetchAccount(accountId));
|
|
||||||
dispatch(fetchFollowers(accountId));
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount () {
|
|
||||||
const { params: { acct }, accountId, dispatch } = this.props;
|
|
||||||
|
|
||||||
if (accountId) {
|
|
||||||
this._load();
|
|
||||||
} else {
|
|
||||||
dispatch(lookupAccount(acct));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate (prevProps) {
|
|
||||||
const { params: { acct }, accountId, dispatch } = this.props;
|
|
||||||
|
|
||||||
if (prevProps.accountId !== accountId && accountId) {
|
|
||||||
this._load();
|
|
||||||
} else if (prevProps.params.acct !== acct) {
|
|
||||||
dispatch(lookupAccount(acct));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleLoadMore = debounce(() => {
|
|
||||||
this.props.dispatch(expandFollowers(this.props.accountId));
|
|
||||||
}, 300, { leading: true });
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { accountId, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl, hideCollections } = this.props;
|
|
||||||
|
|
||||||
if (!isAccount) {
|
|
||||||
return (
|
|
||||||
<BundleColumnError multiColumn={multiColumn} errorType='routing' />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!accountIds) {
|
|
||||||
return (
|
|
||||||
<Column>
|
|
||||||
<LoadingIndicator />
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let emptyMessage;
|
|
||||||
|
|
||||||
const forceEmptyState = blockedBy || suspended || hidden;
|
|
||||||
|
|
||||||
if (suspended) {
|
|
||||||
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
|
|
||||||
} else if (hidden) {
|
|
||||||
emptyMessage = <LimitedAccountHint accountId={accountId} />;
|
|
||||||
} else if (blockedBy) {
|
|
||||||
emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
|
|
||||||
} else if (hideCollections && accountIds.isEmpty()) {
|
|
||||||
emptyMessage = <FormattedMessage id='empty_column.account_hides_collections' defaultMessage='This user has chosen to not make this information available' />;
|
|
||||||
} else if (remote && accountIds.isEmpty()) {
|
|
||||||
emptyMessage = <RemoteHint accountId={accountId} url={remoteUrl} />;
|
|
||||||
} else {
|
|
||||||
emptyMessage = <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remoteMessage = remote ? <RemoteHint accountId={accountId} url={remoteUrl} /> : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Column>
|
|
||||||
<ColumnBackButton />
|
|
||||||
|
|
||||||
<ScrollableList
|
|
||||||
scrollKey='followers'
|
|
||||||
hasMore={!forceEmptyState && hasMore}
|
|
||||||
isLoading={isLoading}
|
|
||||||
onLoadMore={this.handleLoadMore}
|
|
||||||
prepend={<AccountHeader accountId={this.props.accountId} hideTabs />}
|
|
||||||
alwaysPrepend
|
|
||||||
append={remoteMessage}
|
|
||||||
emptyMessage={emptyMessage}
|
|
||||||
bindToDocument={!multiColumn}
|
|
||||||
>
|
|
||||||
{forceEmptyState ? [] : accountIds.map(id =>
|
|
||||||
<Account key={id} id={id} />,
|
|
||||||
)}
|
|
||||||
</ScrollableList>
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(mapStateToProps)(Followers);
|
|
||||||
90
app/javascript/mastodon/features/followers/index.tsx
Normal file
90
app/javascript/mastodon/features/followers/index.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
|
|
||||||
|
import { expandFollowers, fetchFollowers } from '@/mastodon/actions/accounts';
|
||||||
|
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||||
|
import { useAccountId } from '@/mastodon/hooks/useAccountId';
|
||||||
|
import { useRelationship } from '@/mastodon/hooks/useRelationship';
|
||||||
|
import { selectUserListWithoutMe } from '@/mastodon/selectors/user_lists';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||||
|
|
||||||
|
import type { EmptyMessageProps } from './components/empty';
|
||||||
|
import { BaseEmptyMessage } from './components/empty';
|
||||||
|
import { AccountList } from './components/list';
|
||||||
|
|
||||||
|
const Followers: FC = () => {
|
||||||
|
const accountId = useAccountId();
|
||||||
|
const account = useAccount(accountId);
|
||||||
|
const currentAccountId = useAppSelector(
|
||||||
|
(state) => (state.meta.get('me') as string | null) ?? null,
|
||||||
|
);
|
||||||
|
const followerList = useAppSelector((state) =>
|
||||||
|
selectUserListWithoutMe(state, 'followers', accountId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!followerList && accountId) {
|
||||||
|
dispatch(fetchFollowers(accountId));
|
||||||
|
}
|
||||||
|
}, [accountId, dispatch, followerList]);
|
||||||
|
|
||||||
|
const loadMore = useDebouncedCallback(
|
||||||
|
() => {
|
||||||
|
if (accountId) {
|
||||||
|
dispatch(expandFollowers(accountId));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
300,
|
||||||
|
{ leading: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const relationship = useRelationship(accountId);
|
||||||
|
|
||||||
|
const followerId = relationship?.following ? currentAccountId : null;
|
||||||
|
const followersExceptMeHidden = !!(
|
||||||
|
account?.hide_collections &&
|
||||||
|
followerList?.items.length === 0 &&
|
||||||
|
followerId
|
||||||
|
);
|
||||||
|
|
||||||
|
const footer = followersExceptMeHidden && (
|
||||||
|
<div className='empty-column-indicator'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='followers.hide_other_followers'
|
||||||
|
defaultMessage='This user has chosen to not make their other followers visible'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccountList
|
||||||
|
accountId={accountId}
|
||||||
|
footer={footer}
|
||||||
|
emptyMessage={<EmptyMessage account={account} />}
|
||||||
|
list={followerList}
|
||||||
|
loadMore={loadMore}
|
||||||
|
prependAccountId={followerId}
|
||||||
|
scrollKey='followers'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EmptyMessage: FC<EmptyMessageProps> = (props) => (
|
||||||
|
<BaseEmptyMessage
|
||||||
|
{...props}
|
||||||
|
defaultMessage={
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.followers.empty'
|
||||||
|
defaultMessage='No one follows this user yet.'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-default-export -- Used by async components.
|
||||||
|
export default Followers;
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { TimelineHint } from '@/mastodon/components/timeline_hint';
|
||||||
|
|
||||||
|
export const RemoteHint: FC<{ domain?: string; url: string }> = ({
|
||||||
|
domain,
|
||||||
|
url,
|
||||||
|
}) => {
|
||||||
|
if (!domain) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<TimelineHint
|
||||||
|
url={url}
|
||||||
|
message={
|
||||||
|
<FormattedMessage
|
||||||
|
id='hints.profiles.follows_may_be_missing'
|
||||||
|
defaultMessage='Follows for this profile may be missing.'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<FormattedMessage
|
||||||
|
id='hints.profiles.see_more_follows'
|
||||||
|
defaultMessage='See more follows on {domain}'
|
||||||
|
values={{ domain: <strong>{domain}</strong> }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -1,187 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { debounce } from 'lodash';
|
|
||||||
|
|
||||||
import { Account } from 'mastodon/components/account';
|
|
||||||
import { TimelineHint } from 'mastodon/components/timeline_hint';
|
|
||||||
import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header';
|
|
||||||
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
|
||||||
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
|
|
||||||
import { getAccountHidden } from 'mastodon/selectors/accounts';
|
|
||||||
import { useAppSelector } from 'mastodon/store';
|
|
||||||
|
|
||||||
import {
|
|
||||||
lookupAccount,
|
|
||||||
fetchAccount,
|
|
||||||
fetchFollowing,
|
|
||||||
expandFollowing,
|
|
||||||
} from '../../actions/accounts';
|
|
||||||
import { ColumnBackButton } from '../../components/column_back_button';
|
|
||||||
import { LoadingIndicator } from '../../components/loading_indicator';
|
|
||||||
import ScrollableList from '../../components/scrollable_list';
|
|
||||||
import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint';
|
|
||||||
import Column from '../ui/components/column';
|
|
||||||
|
|
||||||
const mapStateToProps = (state, { params: { acct, id } }) => {
|
|
||||||
const accountId = id || state.accounts_map[normalizeForLookup(acct)];
|
|
||||||
|
|
||||||
if (!accountId) {
|
|
||||||
return {
|
|
||||||
isLoading: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
accountId,
|
|
||||||
remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
|
|
||||||
remoteUrl: state.getIn(['accounts', accountId, 'url']),
|
|
||||||
isAccount: !!state.getIn(['accounts', accountId]),
|
|
||||||
accountIds: state.getIn(['user_lists', 'following', accountId, 'items']),
|
|
||||||
hasMore: !!state.getIn(['user_lists', 'following', accountId, 'next']),
|
|
||||||
isLoading: state.getIn(['user_lists', 'following', accountId, 'isLoading'], true),
|
|
||||||
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
|
|
||||||
hideCollections: state.getIn(['accounts', accountId, 'hide_collections'], false),
|
|
||||||
hidden: getAccountHidden(state, accountId),
|
|
||||||
blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const RemoteHint = ({ accountId, url }) => {
|
|
||||||
const acct = useAppSelector(state => state.accounts.get(accountId)?.acct);
|
|
||||||
const domain = acct ? acct.split('@')[1] : undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TimelineHint
|
|
||||||
url={url}
|
|
||||||
message={<FormattedMessage id='hints.profiles.follows_may_be_missing' defaultMessage='Follows for this profile may be missing.' />}
|
|
||||||
label={<FormattedMessage id='hints.profiles.see_more_follows' defaultMessage='See more follows on {domain}' values={{ domain: <strong>{domain}</strong> }} />}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
RemoteHint.propTypes = {
|
|
||||||
url: PropTypes.string.isRequired,
|
|
||||||
accountId: PropTypes.string.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
class Following extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
params: PropTypes.shape({
|
|
||||||
acct: PropTypes.string,
|
|
||||||
id: PropTypes.string,
|
|
||||||
}).isRequired,
|
|
||||||
accountId: PropTypes.string,
|
|
||||||
dispatch: PropTypes.func.isRequired,
|
|
||||||
accountIds: ImmutablePropTypes.list,
|
|
||||||
hasMore: PropTypes.bool,
|
|
||||||
isLoading: PropTypes.bool,
|
|
||||||
blockedBy: PropTypes.bool,
|
|
||||||
isAccount: PropTypes.bool,
|
|
||||||
suspended: PropTypes.bool,
|
|
||||||
hidden: PropTypes.bool,
|
|
||||||
remote: PropTypes.bool,
|
|
||||||
remoteUrl: PropTypes.string,
|
|
||||||
multiColumn: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
_load () {
|
|
||||||
const { accountId, isAccount, dispatch } = this.props;
|
|
||||||
|
|
||||||
if (!isAccount) dispatch(fetchAccount(accountId));
|
|
||||||
dispatch(fetchFollowing(accountId));
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount () {
|
|
||||||
const { params: { acct }, accountId, dispatch } = this.props;
|
|
||||||
|
|
||||||
if (accountId) {
|
|
||||||
this._load();
|
|
||||||
} else {
|
|
||||||
dispatch(lookupAccount(acct));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate (prevProps) {
|
|
||||||
const { params: { acct }, accountId, dispatch } = this.props;
|
|
||||||
|
|
||||||
if (prevProps.accountId !== accountId && accountId) {
|
|
||||||
this._load();
|
|
||||||
} else if (prevProps.params.acct !== acct) {
|
|
||||||
dispatch(lookupAccount(acct));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleLoadMore = debounce(() => {
|
|
||||||
this.props.dispatch(expandFollowing(this.props.accountId));
|
|
||||||
}, 300, { leading: true });
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { accountId, accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading, suspended, hidden, remote, remoteUrl, hideCollections } = this.props;
|
|
||||||
|
|
||||||
if (!isAccount) {
|
|
||||||
return (
|
|
||||||
<BundleColumnError multiColumn={multiColumn} errorType='routing' />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!accountIds) {
|
|
||||||
return (
|
|
||||||
<Column>
|
|
||||||
<LoadingIndicator />
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let emptyMessage;
|
|
||||||
|
|
||||||
const forceEmptyState = blockedBy || suspended || hidden;
|
|
||||||
|
|
||||||
if (suspended) {
|
|
||||||
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
|
|
||||||
} else if (hidden) {
|
|
||||||
emptyMessage = <LimitedAccountHint accountId={accountId} />;
|
|
||||||
} else if (blockedBy) {
|
|
||||||
emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
|
|
||||||
} else if (hideCollections && accountIds.isEmpty()) {
|
|
||||||
emptyMessage = <FormattedMessage id='empty_column.account_hides_collections' defaultMessage='This user has chosen to not make this information available' />;
|
|
||||||
} else if (remote && accountIds.isEmpty()) {
|
|
||||||
emptyMessage = <RemoteHint accountId={accountId} url={remoteUrl} />;
|
|
||||||
} else {
|
|
||||||
emptyMessage = <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remoteMessage = remote ? <RemoteHint accountId={accountId} url={remoteUrl} /> : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Column>
|
|
||||||
<ColumnBackButton />
|
|
||||||
|
|
||||||
<ScrollableList
|
|
||||||
scrollKey='following'
|
|
||||||
hasMore={!forceEmptyState && hasMore}
|
|
||||||
isLoading={isLoading}
|
|
||||||
onLoadMore={this.handleLoadMore}
|
|
||||||
prepend={<AccountHeader accountId={this.props.accountId} hideTabs />}
|
|
||||||
alwaysPrepend
|
|
||||||
append={remoteMessage}
|
|
||||||
emptyMessage={emptyMessage}
|
|
||||||
bindToDocument={!multiColumn}
|
|
||||||
>
|
|
||||||
{forceEmptyState ? [] : accountIds.map(id =>
|
|
||||||
<Account key={id} id={id} />,
|
|
||||||
)}
|
|
||||||
</ScrollableList>
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(mapStateToProps)(Following);
|
|
||||||
94
app/javascript/mastodon/features/following/index.tsx
Normal file
94
app/javascript/mastodon/features/following/index.tsx
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { useDebouncedCallback } from 'use-debounce';
|
||||||
|
|
||||||
|
import { expandFollowing, fetchFollowing } from '@/mastodon/actions/accounts';
|
||||||
|
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||||
|
import { useAccountId } from '@/mastodon/hooks/useAccountId';
|
||||||
|
import { useRelationship } from '@/mastodon/hooks/useRelationship';
|
||||||
|
import { selectUserListWithoutMe } from '@/mastodon/selectors/user_lists';
|
||||||
|
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||||
|
|
||||||
|
import type { EmptyMessageProps } from '../followers/components/empty';
|
||||||
|
import { BaseEmptyMessage } from '../followers/components/empty';
|
||||||
|
import { AccountList } from '../followers/components/list';
|
||||||
|
|
||||||
|
import { RemoteHint } from './components/remote';
|
||||||
|
|
||||||
|
const Followers: FC = () => {
|
||||||
|
const accountId = useAccountId();
|
||||||
|
const account = useAccount(accountId);
|
||||||
|
const currentAccountId = useAppSelector(
|
||||||
|
(state) => (state.meta.get('me') as string | null) ?? null,
|
||||||
|
);
|
||||||
|
const followingList = useAppSelector((state) =>
|
||||||
|
selectUserListWithoutMe(state, 'following', accountId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!followingList && accountId) {
|
||||||
|
dispatch(fetchFollowing(accountId));
|
||||||
|
}
|
||||||
|
}, [accountId, dispatch, followingList]);
|
||||||
|
|
||||||
|
const loadMore = useDebouncedCallback(
|
||||||
|
() => {
|
||||||
|
if (accountId) {
|
||||||
|
dispatch(expandFollowing(accountId));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
300,
|
||||||
|
{ leading: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const relationship = useRelationship(accountId);
|
||||||
|
|
||||||
|
const followedId = relationship?.followed_by ? currentAccountId : null;
|
||||||
|
const followingExceptMeHidden = !!(
|
||||||
|
account?.hide_collections &&
|
||||||
|
followingList?.items.length === 0 &&
|
||||||
|
followedId
|
||||||
|
);
|
||||||
|
|
||||||
|
const footer = followingExceptMeHidden && (
|
||||||
|
<div className='empty-column-indicator'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='following.hide_other_following'
|
||||||
|
defaultMessage='This user has chosen to not make the rest of who they follow visible'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const domain = account?.acct.split('@')[1];
|
||||||
|
return (
|
||||||
|
<AccountList
|
||||||
|
accountId={accountId}
|
||||||
|
append={domain && <RemoteHint domain={domain} url={account.url} />}
|
||||||
|
emptyMessage={<EmptyMessage account={account} />}
|
||||||
|
footer={footer}
|
||||||
|
list={followingList}
|
||||||
|
loadMore={loadMore}
|
||||||
|
prependAccountId={currentAccountId}
|
||||||
|
scrollKey='following'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EmptyMessage: FC<EmptyMessageProps> = (props) => (
|
||||||
|
<BaseEmptyMessage
|
||||||
|
{...props}
|
||||||
|
defaultMessage={
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.follows.empty'
|
||||||
|
defaultMessage="This user doesn't follow anyone yet."
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-default-export -- Used by async components.
|
||||||
|
export default Followers;
|
||||||
@ -8,6 +8,7 @@ export const useLayout = () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
singleColumn: layout === 'single-column' || layout === 'mobile',
|
singleColumn: layout === 'single-column' || layout === 'mobile',
|
||||||
|
multiColumn: layout === 'multi-column',
|
||||||
layout,
|
layout,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
19
app/javascript/mastodon/hooks/useRelationship.ts
Normal file
19
app/javascript/mastodon/hooks/useRelationship.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { fetchRelationships } from '../actions/accounts';
|
||||||
|
import { useAppDispatch, useAppSelector } from '../store';
|
||||||
|
|
||||||
|
export function useRelationship(accountId?: string | null) {
|
||||||
|
const relationship = useAppSelector((state) =>
|
||||||
|
accountId ? state.relationships.get(accountId) : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
useEffect(() => {
|
||||||
|
if (accountId && !relationship) {
|
||||||
|
dispatch(fetchRelationships([accountId]));
|
||||||
|
}
|
||||||
|
}, [accountId, dispatch, relationship]);
|
||||||
|
|
||||||
|
return relationship;
|
||||||
|
}
|
||||||
@ -535,6 +535,8 @@
|
|||||||
"follow_suggestions.view_all": "View all",
|
"follow_suggestions.view_all": "View all",
|
||||||
"follow_suggestions.who_to_follow": "Who to follow",
|
"follow_suggestions.who_to_follow": "Who to follow",
|
||||||
"followed_tags": "Followed hashtags",
|
"followed_tags": "Followed hashtags",
|
||||||
|
"followers.hide_other_followers": "This user has chosen to not make their other followers visible",
|
||||||
|
"following.hide_other_following": "This user has chosen to not make the rest of who they follow visible",
|
||||||
"footer.about": "About",
|
"footer.about": "About",
|
||||||
"footer.about_mastodon": "About Mastodon",
|
"footer.about_mastodon": "About Mastodon",
|
||||||
"footer.about_server": "About {domain}",
|
"footer.about_server": "About {domain}",
|
||||||
|
|||||||
@ -47,5 +47,5 @@ export function toTypedTimeline(timeline?: ImmutableMap<string, unknown>) {
|
|||||||
emptyList,
|
emptyList,
|
||||||
) as ImmutableList<string>,
|
) as ImmutableList<string>,
|
||||||
items: timeline.get('items', emptyList) as ImmutableList<string>,
|
items: timeline.get('items', emptyList) as ImmutableList<string>,
|
||||||
} as TimelineShape;
|
} satisfies TimelineShape;
|
||||||
}
|
}
|
||||||
|
|||||||
35
app/javascript/mastodon/selectors/user_lists.ts
Normal file
35
app/javascript/mastodon/selectors/user_lists.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import type { Map as ImmutableMap } from 'immutable';
|
||||||
|
import { List as ImmutableList } from 'immutable';
|
||||||
|
|
||||||
|
import { createAppSelector } from '../store';
|
||||||
|
|
||||||
|
export const selectUserListWithoutMe = createAppSelector(
|
||||||
|
[
|
||||||
|
(state) => state.user_lists,
|
||||||
|
(state) => (state.meta.get('me') as string | null) ?? null,
|
||||||
|
(_state, listName: string) => listName,
|
||||||
|
(_state, _listName, accountId?: string | null) => accountId ?? null,
|
||||||
|
],
|
||||||
|
(lists, currentAccountId, listName, accountId) => {
|
||||||
|
if (!accountId || !listName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const list = lists.getIn([listName, accountId]) as
|
||||||
|
| ImmutableMap<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
if (!list) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the list except the current account.
|
||||||
|
return {
|
||||||
|
items: (
|
||||||
|
list.get('items', ImmutableList<string>()) as ImmutableList<string>
|
||||||
|
)
|
||||||
|
.filter((id) => id !== currentAccountId)
|
||||||
|
.toArray(),
|
||||||
|
isLoading: !!list.get('isLoading', true),
|
||||||
|
hasMore: !!list.get('hasMore', false),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
Loading…
x
Reference in New Issue
Block a user