diff --git a/app/javascript/flavours/glitch/features/account/components/profile_column_header.jsx b/app/javascript/flavours/glitch/features/account/components/profile_column_header.jsx
deleted file mode 100644
index 0628773497..0000000000
--- a/app/javascript/flavours/glitch/features/account/components/profile_column_header.jsx
+++ /dev/null
@@ -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 (
-
- );
- }
-
-}
-
-export default injectIntl(ProfileColumnHeader);
diff --git a/app/javascript/flavours/glitch/features/account/components/profile_column_header.tsx b/app/javascript/flavours/glitch/features/account/components/profile_column_header.tsx
new file mode 100644
index 0000000000..8ccc44e290
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account/components/profile_column_header.tsx
@@ -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 = ({
+ onClick,
+ multiColumn,
+}) => {
+ const intl = useIntl();
+
+ return (
+
+ );
+};
diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.jsx b/app/javascript/flavours/glitch/features/account_timeline/index.jsx
index 554e266075..65b9c38803 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/index.jsx
+++ b/app/javascript/flavours/glitch/features/account_timeline/index.jsx
@@ -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';
diff --git a/app/javascript/flavours/glitch/features/followers/components/empty.tsx b/app/javascript/flavours/glitch/features/followers/components/empty.tsx
new file mode 100644
index 0000000000..8999ddb5ba
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/followers/components/empty.tsx
@@ -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;
+
+export const BaseEmptyMessage: FC = ({
+ account,
+ defaultMessage,
+}) => {
+ const { blockedBy, hidden, suspended } = useAccountVisibility(account?.id);
+
+ if (!account) {
+ return null;
+ }
+
+ if (suspended) {
+ return (
+
+ );
+ }
+
+ if (hidden) {
+ return ;
+ }
+
+ if (blockedBy) {
+ return (
+
+ );
+ }
+
+ if (account.hide_collections) {
+ return (
+
+ );
+ }
+
+ const domain = account.acct.split('@')[1];
+ if (domain) {
+ return ;
+ }
+
+ return defaultMessage;
+};
diff --git a/app/javascript/flavours/glitch/features/followers/components/list.tsx b/app/javascript/flavours/glitch/features/followers/components/list.tsx
new file mode 100644
index 0000000000..359192393c
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/followers/components/list.tsx
@@ -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 = ({
+ 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) => (
+
+ )) ?? [];
+
+ if (prependAccountId) {
+ children.unshift(
+ ,
+ );
+ }
+ return children;
+ }, [prependAccountId, list, forceEmptyState]);
+
+ const columnRef = useRef(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 ;
+ }
+
+ if (!accountId || !account) {
+ return (
+
+
+
+ );
+ }
+
+ const domain = account.acct.split('@')[1];
+
+ return (
+
+
+
+ }
+ alwaysPrepend
+ append={append ?? }
+ emptyMessage={emptyMessage}
+ bindToDocument={!multiColumn}
+ footer={footer}
+ >
+ {children}
+
+
+ );
+};
diff --git a/app/javascript/flavours/glitch/features/followers/components/remote.tsx b/app/javascript/flavours/glitch/features/followers/components/remote.tsx
new file mode 100644
index 0000000000..29e9d647f0
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/followers/components/remote.tsx
@@ -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 (
+
+ }
+ label={
+ {domain} }}
+ />
+ }
+ />
+ );
+};
diff --git a/app/javascript/flavours/glitch/features/followers/index.jsx b/app/javascript/flavours/glitch/features/followers/index.jsx
deleted file mode 100644
index b9f16c7715..0000000000
--- a/app/javascript/flavours/glitch/features/followers/index.jsx
+++ /dev/null
@@ -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 (
- }
- label={{domain} }} />}
- />
- );
-};
-
-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 (
-
- );
- }
-
- if (!accountIds) {
- return (
-
-
-
- );
- }
-
- let emptyMessage;
-
- const forceEmptyState = suspended || hidden;
-
- if (suspended) {
- emptyMessage = ;
- } else if (hidden) {
- emptyMessage = ;
- } else if (hideCollections && accountIds.isEmpty()) {
- emptyMessage = ;
- } else if (remote && accountIds.isEmpty()) {
- emptyMessage = ;
- } else {
- emptyMessage = ;
- }
-
- const remoteMessage = remote ? : null;
-
- return (
-
-
-
- }
- alwaysPrepend
- append={remoteMessage}
- emptyMessage={emptyMessage}
- bindToDocument={!multiColumn}
- >
- {accountIds.map(id =>
- ,
- )}
-
-
- );
- }
-
-}
-
-export default connect(mapStateToProps)(Followers);
diff --git a/app/javascript/flavours/glitch/features/followers/index.tsx b/app/javascript/flavours/glitch/features/followers/index.tsx
new file mode 100644
index 0000000000..109f161f28
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/followers/index.tsx
@@ -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 && (
+
+
+
+ );
+
+ return (
+ }
+ list={followerList}
+ loadMore={loadMore}
+ prependAccountId={followerId}
+ scrollKey='followers'
+ />
+ );
+};
+
+const EmptyMessage: FC = (props) => (
+
+ }
+ />
+);
+
+// eslint-disable-next-line import/no-default-export -- Used by async components.
+export default Followers;
diff --git a/app/javascript/flavours/glitch/features/following/components/remote.tsx b/app/javascript/flavours/glitch/features/following/components/remote.tsx
new file mode 100644
index 0000000000..1576c4b020
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/following/components/remote.tsx
@@ -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 (
+
+ }
+ label={
+ {domain} }}
+ />
+ }
+ />
+ );
+};
diff --git a/app/javascript/flavours/glitch/features/following/index.jsx b/app/javascript/flavours/glitch/features/following/index.jsx
deleted file mode 100644
index d673e3ffc4..0000000000
--- a/app/javascript/flavours/glitch/features/following/index.jsx
+++ /dev/null
@@ -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 (
- }
- label={{domain} }} />}
- />
- );
-};
-
-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 (
-
- );
- }
-
- if (!accountIds) {
- return (
-
-
-
- );
- }
-
- let emptyMessage;
-
- const forceEmptyState = suspended || hidden;
-
- if (suspended) {
- emptyMessage = ;
- } else if (hidden) {
- emptyMessage = ;
- } else if (hideCollections && accountIds.isEmpty()) {
- emptyMessage = ;
- } else if (remote && accountIds.isEmpty()) {
- emptyMessage = ;
- } else {
- emptyMessage = ;
- }
-
- const remoteMessage = remote ? : null;
-
- return (
-
-
-
- }
- alwaysPrepend
- append={remoteMessage}
- emptyMessage={emptyMessage}
- bindToDocument={!multiColumn}
- >
- {accountIds.map(id =>
- ,
- )}
-
-
- );
- }
-
-}
-
-export default connect(mapStateToProps)(Following);
diff --git a/app/javascript/flavours/glitch/features/following/index.tsx b/app/javascript/flavours/glitch/features/following/index.tsx
new file mode 100644
index 0000000000..6198cca38e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/following/index.tsx
@@ -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 && (
+
+
+
+ );
+
+ const domain = account?.acct.split('@')[1];
+ return (
+ }
+ emptyMessage={}
+ footer={footer}
+ list={followingList}
+ loadMore={loadMore}
+ prependAccountId={currentAccountId}
+ scrollKey='following'
+ />
+ );
+};
+
+const EmptyMessage: FC = (props) => (
+
+ }
+ />
+);
+
+// eslint-disable-next-line import/no-default-export -- Used by async components.
+export default Followers;
diff --git a/app/javascript/flavours/glitch/hooks/useLayout.ts b/app/javascript/flavours/glitch/hooks/useLayout.ts
index fc1cf136bf..51f3e0122a 100644
--- a/app/javascript/flavours/glitch/hooks/useLayout.ts
+++ b/app/javascript/flavours/glitch/hooks/useLayout.ts
@@ -8,6 +8,7 @@ export const useLayout = () => {
return {
singleColumn: layout === 'single-column' || layout === 'mobile',
+ multiColumn: layout === 'multi-column',
layout,
};
};
diff --git a/app/javascript/flavours/glitch/hooks/useRelationship.ts b/app/javascript/flavours/glitch/hooks/useRelationship.ts
new file mode 100644
index 0000000000..b1e8f5174f
--- /dev/null
+++ b/app/javascript/flavours/glitch/hooks/useRelationship.ts
@@ -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;
+}
diff --git a/app/javascript/flavours/glitch/selectors/timelines.ts b/app/javascript/flavours/glitch/selectors/timelines.ts
index 5db50ea894..af1a4ebb21 100644
--- a/app/javascript/flavours/glitch/selectors/timelines.ts
+++ b/app/javascript/flavours/glitch/selectors/timelines.ts
@@ -47,5 +47,5 @@ export function toTypedTimeline(timeline?: ImmutableMap) {
emptyList,
) as ImmutableList,
items: timeline.get('items', emptyList) as ImmutableList,
- } as TimelineShape;
+ } satisfies TimelineShape;
}
diff --git a/app/javascript/flavours/glitch/selectors/user_lists.ts b/app/javascript/flavours/glitch/selectors/user_lists.ts
new file mode 100644
index 0000000000..9d681aa255
--- /dev/null
+++ b/app/javascript/flavours/glitch/selectors/user_lists.ts
@@ -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
+ | undefined;
+ if (!list) {
+ return null;
+ }
+
+ // Returns the list except the current account.
+ return {
+ items: (
+ list.get('items', ImmutableList()) as ImmutableList
+ )
+ .filter((id) => id !== currentAccountId)
+ .toArray(),
+ isLoading: !!list.get('isLoading', true),
+ hasMore: !!list.get('hasMore', false),
+ };
+ },
+);