mirror of
https://github.com/glitch-soc/mastodon.git
synced 2026-03-29 03:00:33 +02:00
[Glitch] Profile redesign: Show yourself in follower list
Port bbd88d356d to glitch-soc
Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
@@ -1,39 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { injectIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import PersonIcon from '@/material-icons/400-24px/person.svg?react';
|
||||
|
||||
import ColumnHeader from '../../../components/column_header';
|
||||
|
||||
const messages = defineMessages({
|
||||
profile: { id: 'column_header.profile', defaultMessage: 'Profile' },
|
||||
});
|
||||
|
||||
class ProfileColumnHeader extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
onClick: PropTypes.func,
|
||||
multiColumn: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { onClick, intl, multiColumn } = this.props;
|
||||
|
||||
return (
|
||||
<ColumnHeader
|
||||
icon='user-circle'
|
||||
iconComponent={PersonIcon}
|
||||
title={intl.formatMessage(messages.profile)}
|
||||
onClick={onClick}
|
||||
showBackButton
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(ProfileColumnHeader);
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import PersonIcon from '@/material-icons/400-24px/person.svg?react';
|
||||
|
||||
import { ColumnHeader } from '../../../components/column_header';
|
||||
|
||||
const messages = defineMessages({
|
||||
profile: { id: 'column_header.profile', defaultMessage: 'Profile' },
|
||||
});
|
||||
|
||||
interface ProfileColumnHeaderProps {
|
||||
onClick: () => void;
|
||||
multiColumn: boolean;
|
||||
}
|
||||
|
||||
export const ProfileColumnHeader: FC<ProfileColumnHeaderProps> = ({
|
||||
onClick,
|
||||
multiColumn,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<ColumnHeader
|
||||
icon='user-circle'
|
||||
iconComponent={PersonIcon}
|
||||
title={intl.formatMessage(messages.profile)}
|
||||
onClick={onClick}
|
||||
showBackButton
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -7,7 +7,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header';
|
||||
import { ProfileColumnHeader } from 'flavours/glitch/features/account/components/profile_column_header';
|
||||
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
|
||||
import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map';
|
||||
import { getAccountHidden } from 'flavours/glitch/selectors/accounts';
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { LimitedAccountHint } from '@/flavours/glitch/features/account_timeline/components/limited_account_hint';
|
||||
import { useAccountVisibility } from '@/flavours/glitch/hooks/useAccountVisibility';
|
||||
import type { Account } from '@/flavours/glitch/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;
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import type { FC, ReactNode } from 'react';
|
||||
|
||||
import { Account } from '@/flavours/glitch/components/account';
|
||||
import type { ColumnRef } from '@/flavours/glitch/components/column';
|
||||
import { Column } from '@/flavours/glitch/components/column';
|
||||
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
|
||||
import ScrollableList from '@/flavours/glitch/components/scrollable_list';
|
||||
import BundleColumnError from '@/flavours/glitch/features/ui/components/bundle_column_error';
|
||||
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
|
||||
import { useAccountVisibility } from '@/flavours/glitch/hooks/useAccountVisibility';
|
||||
import { useLayout } from '@/flavours/glitch/hooks/useLayout';
|
||||
|
||||
import { ProfileColumnHeader } from '../../account/components/profile_column_header';
|
||||
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 columnRef = useRef<ColumnRef>(null);
|
||||
const handleHeaderClick = useCallback(() => {
|
||||
columnRef.current?.scrollTop();
|
||||
}, []);
|
||||
|
||||
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 ref={columnRef}>
|
||||
<ProfileColumnHeader
|
||||
onClick={handleHeaderClick}
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<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 '@/flavours/glitch/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,191 +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 'flavours/glitch/components/account';
|
||||
import { TimelineHint } from 'flavours/glitch/components/timeline_hint';
|
||||
import { AccountHeader } from 'flavours/glitch/features/account_timeline/components/account_header';
|
||||
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
|
||||
import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map';
|
||||
import { getAccountHidden } from 'flavours/glitch/selectors/accounts';
|
||||
import { useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
import {
|
||||
lookupAccount,
|
||||
fetchAccount,
|
||||
fetchFollowers,
|
||||
expandFollowers,
|
||||
} from '../../actions/accounts';
|
||||
import { LoadingIndicator } from '../../components/loading_indicator';
|
||||
import ScrollableList from '../../components/scrollable_list';
|
||||
import ProfileColumnHeader from '../account/components/profile_column_header';
|
||||
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),
|
||||
};
|
||||
};
|
||||
|
||||
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,
|
||||
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 });
|
||||
|
||||
setRef = c => {
|
||||
this.column = c;
|
||||
};
|
||||
|
||||
handleHeaderClick = () => {
|
||||
this.column.scrollTop();
|
||||
};
|
||||
|
||||
render () {
|
||||
const { accountId, accountIds, hasMore, 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 = suspended || hidden;
|
||||
|
||||
if (suspended) {
|
||||
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
|
||||
} else if (hidden) {
|
||||
emptyMessage = <LimitedAccountHint accountId={accountId} />;
|
||||
} 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 ref={this.setRef}>
|
||||
<ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
|
||||
|
||||
<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}
|
||||
>
|
||||
{accountIds.map(id =>
|
||||
<Account key={id} id={id} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(Followers);
|
||||
93
app/javascript/flavours/glitch/features/followers/index.tsx
Normal file
93
app/javascript/flavours/glitch/features/followers/index.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import {
|
||||
expandFollowers,
|
||||
fetchFollowers,
|
||||
} from '@/flavours/glitch/actions/accounts';
|
||||
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
|
||||
import { useAccountId } from '@/flavours/glitch/hooks/useAccountId';
|
||||
import { useRelationship } from '@/flavours/glitch/hooks/useRelationship';
|
||||
import { selectUserListWithoutMe } from '@/flavours/glitch/selectors/user_lists';
|
||||
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/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 '@/flavours/glitch/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,191 +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 'flavours/glitch/components/account';
|
||||
import { TimelineHint } from 'flavours/glitch/components/timeline_hint';
|
||||
import { AccountHeader } from 'flavours/glitch/features/account_timeline/components/account_header';
|
||||
import BundleColumnError from 'flavours/glitch/features/ui/components/bundle_column_error';
|
||||
import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map';
|
||||
import { getAccountHidden } from 'flavours/glitch/selectors/accounts';
|
||||
import { useAppSelector } from 'flavours/glitch/store';
|
||||
|
||||
import {
|
||||
lookupAccount,
|
||||
fetchAccount,
|
||||
fetchFollowing,
|
||||
expandFollowing,
|
||||
} from '../../actions/accounts';
|
||||
import { LoadingIndicator } from '../../components/loading_indicator';
|
||||
import ScrollableList from '../../components/scrollable_list';
|
||||
import ProfileColumnHeader from '../account/components/profile_column_header';
|
||||
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),
|
||||
};
|
||||
};
|
||||
|
||||
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,
|
||||
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 });
|
||||
|
||||
setRef = c => {
|
||||
this.column = c;
|
||||
};
|
||||
|
||||
handleHeaderClick = () => {
|
||||
this.column.scrollTop();
|
||||
};
|
||||
|
||||
render () {
|
||||
const { accountId, accountIds, hasMore, 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 = suspended || hidden;
|
||||
|
||||
if (suspended) {
|
||||
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
|
||||
} else if (hidden) {
|
||||
emptyMessage = <LimitedAccountHint accountId={accountId} />;
|
||||
} 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 ref={this.setRef}>
|
||||
<ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
|
||||
|
||||
<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}
|
||||
>
|
||||
{accountIds.map(id =>
|
||||
<Account key={id} id={id} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(Following);
|
||||
97
app/javascript/flavours/glitch/features/following/index.tsx
Normal file
97
app/javascript/flavours/glitch/features/following/index.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import {
|
||||
expandFollowing,
|
||||
fetchFollowing,
|
||||
} from '@/flavours/glitch/actions/accounts';
|
||||
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
|
||||
import { useAccountId } from '@/flavours/glitch/hooks/useAccountId';
|
||||
import { useRelationship } from '@/flavours/glitch/hooks/useRelationship';
|
||||
import { selectUserListWithoutMe } from '@/flavours/glitch/selectors/user_lists';
|
||||
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/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 {
|
||||
singleColumn: layout === 'single-column' || layout === 'mobile',
|
||||
multiColumn: layout === 'multi-column',
|
||||
layout,
|
||||
};
|
||||
};
|
||||
|
||||
19
app/javascript/flavours/glitch/hooks/useRelationship.ts
Normal file
19
app/javascript/flavours/glitch/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;
|
||||
}
|
||||
@@ -47,5 +47,5 @@ export function toTypedTimeline(timeline?: ImmutableMap<string, unknown>) {
|
||||
emptyList,
|
||||
) as ImmutableList<string>,
|
||||
items: timeline.get('items', emptyList) as ImmutableList<string>,
|
||||
} as TimelineShape;
|
||||
} satisfies TimelineShape;
|
||||
}
|
||||
|
||||
35
app/javascript/flavours/glitch/selectors/user_lists.ts
Normal file
35
app/javascript/flavours/glitch/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),
|
||||
};
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user