Adds featured tab to web (#34405)
This commit is contained in:
		@@ -102,7 +102,7 @@ export interface HashtagProps {
 | 
			
		||||
  description?: React.ReactNode;
 | 
			
		||||
  history?: number[];
 | 
			
		||||
  name: string;
 | 
			
		||||
  people: number;
 | 
			
		||||
  people?: number;
 | 
			
		||||
  to: string;
 | 
			
		||||
  uses?: number;
 | 
			
		||||
  withGraph?: boolean;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,25 +1,6 @@
 | 
			
		||||
import { Switch, Route } from 'react-router-dom';
 | 
			
		||||
 | 
			
		||||
import AccountNavigation from 'mastodon/features/account/navigation';
 | 
			
		||||
import Trends from 'mastodon/features/getting_started/containers/trends_container';
 | 
			
		||||
import { showTrends } from 'mastodon/initial_state';
 | 
			
		||||
 | 
			
		||||
const DefaultNavigation: React.FC = () => (showTrends ? <Trends /> : null);
 | 
			
		||||
 | 
			
		||||
export const NavigationPortal: React.FC = () => (
 | 
			
		||||
  <div className='navigation-panel__portal'>
 | 
			
		||||
    <Switch>
 | 
			
		||||
      <Route path='/@:acct' exact component={AccountNavigation} />
 | 
			
		||||
      <Route
 | 
			
		||||
        path='/@:acct/tagged/:tagged?'
 | 
			
		||||
        exact
 | 
			
		||||
        component={AccountNavigation}
 | 
			
		||||
      />
 | 
			
		||||
      <Route path='/@:acct/with_replies' exact component={AccountNavigation} />
 | 
			
		||||
      <Route path='/@:acct/followers' exact component={AccountNavigation} />
 | 
			
		||||
      <Route path='/@:acct/following' exact component={AccountNavigation} />
 | 
			
		||||
      <Route path='/@:acct/media' exact component={AccountNavigation} />
 | 
			
		||||
      <Route component={DefaultNavigation} />
 | 
			
		||||
    </Switch>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div className='navigation-panel__portal'>{showTrends && <Trends />}</div>
 | 
			
		||||
);
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										43
									
								
								app/javascript/mastodon/components/remote_hint.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								app/javascript/mastodon/components/remote_hint.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,43 @@
 | 
			
		||||
import { FormattedMessage } from 'react-intl';
 | 
			
		||||
 | 
			
		||||
import { useAppSelector } from 'mastodon/store';
 | 
			
		||||
 | 
			
		||||
import { TimelineHint } from './timeline_hint';
 | 
			
		||||
 | 
			
		||||
interface RemoteHintProps {
 | 
			
		||||
  accountId?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const RemoteHint: React.FC<RemoteHintProps> = ({ accountId }) => {
 | 
			
		||||
  const account = useAppSelector((state) =>
 | 
			
		||||
    accountId ? state.accounts.get(accountId) : undefined,
 | 
			
		||||
  );
 | 
			
		||||
  const domain = account?.acct ? account.acct.split('@')[1] : undefined;
 | 
			
		||||
  if (
 | 
			
		||||
    !account ||
 | 
			
		||||
    !account.url ||
 | 
			
		||||
    account.acct !== account.username ||
 | 
			
		||||
    !domain
 | 
			
		||||
  ) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <TimelineHint
 | 
			
		||||
      url={account.url}
 | 
			
		||||
      message={
 | 
			
		||||
        <FormattedMessage
 | 
			
		||||
          id='hints.profiles.posts_may_be_missing'
 | 
			
		||||
          defaultMessage='Some posts from this profile may be missing.'
 | 
			
		||||
        />
 | 
			
		||||
      }
 | 
			
		||||
      label={
 | 
			
		||||
        <FormattedMessage
 | 
			
		||||
          id='hints.profiles.see_more_posts'
 | 
			
		||||
          defaultMessage='See more posts on {domain}'
 | 
			
		||||
          values={{ domain: <strong>{domain}</strong> }}
 | 
			
		||||
        />
 | 
			
		||||
      }
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -1,51 +0,0 @@
 | 
			
		||||
import PropTypes from 'prop-types';
 | 
			
		||||
 | 
			
		||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 | 
			
		||||
 | 
			
		||||
import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
			
		||||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
			
		||||
 | 
			
		||||
import { Hashtag } from 'mastodon/components/hashtag';
 | 
			
		||||
 | 
			
		||||
const messages = defineMessages({
 | 
			
		||||
  lastStatusAt: { id: 'account.featured_tags.last_status_at', defaultMessage: 'Last post on {date}' },
 | 
			
		||||
  empty: { id: 'account.featured_tags.last_status_never', defaultMessage: 'No posts' },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
class FeaturedTags extends ImmutablePureComponent {
 | 
			
		||||
 | 
			
		||||
  static propTypes = {
 | 
			
		||||
    account: ImmutablePropTypes.record,
 | 
			
		||||
    featuredTags: ImmutablePropTypes.list,
 | 
			
		||||
    tagged: PropTypes.string,
 | 
			
		||||
    intl: PropTypes.object.isRequired,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  render () {
 | 
			
		||||
    const { account, featuredTags, intl } = this.props;
 | 
			
		||||
 | 
			
		||||
    if (!account || account.get('suspended') || featuredTags.isEmpty()) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div className='getting-started__trends'>
 | 
			
		||||
        <h4><FormattedMessage id='account.featured_tags.title' defaultMessage="{name}'s featured hashtags" values={{ name: <bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} /> }} /></h4>
 | 
			
		||||
 | 
			
		||||
        {featuredTags.take(3).map(featuredTag => (
 | 
			
		||||
          <Hashtag
 | 
			
		||||
            key={featuredTag.get('name')}
 | 
			
		||||
            name={featuredTag.get('name')}
 | 
			
		||||
            to={`/@${account.get('acct')}/tagged/${featuredTag.get('name')}`}
 | 
			
		||||
            uses={featuredTag.get('statuses_count') * 1}
 | 
			
		||||
            withGraph={false}
 | 
			
		||||
            description={((featuredTag.get('statuses_count') * 1) > 0) ? intl.formatMessage(messages.lastStatusAt, { date: intl.formatDate(featuredTag.get('last_status_at'), { month: 'short', day: '2-digit' }) }) : intl.formatMessage(messages.empty)}
 | 
			
		||||
          />
 | 
			
		||||
        ))}
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default injectIntl(FeaturedTags);
 | 
			
		||||
@@ -1,17 +0,0 @@
 | 
			
		||||
import { List as ImmutableList } from 'immutable';
 | 
			
		||||
import { connect } from 'react-redux';
 | 
			
		||||
 | 
			
		||||
import { makeGetAccount } from 'mastodon/selectors';
 | 
			
		||||
 | 
			
		||||
import FeaturedTags from '../components/featured_tags';
 | 
			
		||||
 | 
			
		||||
const mapStateToProps = () => {
 | 
			
		||||
  const getAccount = makeGetAccount();
 | 
			
		||||
 | 
			
		||||
  return (state, { accountId }) => ({
 | 
			
		||||
    account: getAccount(state, accountId),
 | 
			
		||||
    featuredTags: state.getIn(['user_lists', 'featured_tags', accountId, 'items'], ImmutableList()),
 | 
			
		||||
  });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default connect(mapStateToProps)(FeaturedTags);
 | 
			
		||||
@@ -1,52 +0,0 @@
 | 
			
		||||
import PropTypes from 'prop-types';
 | 
			
		||||
import { PureComponent } from 'react';
 | 
			
		||||
 | 
			
		||||
import { connect } from 'react-redux';
 | 
			
		||||
 | 
			
		||||
import FeaturedTags from 'mastodon/features/account/containers/featured_tags_container';
 | 
			
		||||
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
 | 
			
		||||
 | 
			
		||||
const mapStateToProps = (state, { match: { params: { acct } } }) => {
 | 
			
		||||
  const accountId = state.getIn(['accounts_map', normalizeForLookup(acct)]);
 | 
			
		||||
 | 
			
		||||
  if (!accountId) {
 | 
			
		||||
    return {
 | 
			
		||||
      isLoading: true,
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    accountId,
 | 
			
		||||
    isLoading: false,
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
class AccountNavigation extends PureComponent {
 | 
			
		||||
 | 
			
		||||
  static propTypes = {
 | 
			
		||||
    match: PropTypes.shape({
 | 
			
		||||
      params: PropTypes.shape({
 | 
			
		||||
        acct: PropTypes.string,
 | 
			
		||||
        tagged: PropTypes.string,
 | 
			
		||||
      }).isRequired,
 | 
			
		||||
    }).isRequired,
 | 
			
		||||
 | 
			
		||||
    accountId: PropTypes.string,
 | 
			
		||||
    isLoading: PropTypes.bool,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  render () {
 | 
			
		||||
    const { accountId, isLoading, match: { params: { tagged } } } = this.props;
 | 
			
		||||
 | 
			
		||||
    if (isLoading) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <FeaturedTags accountId={accountId} tagged={tagged} />
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default connect(mapStateToProps)(AccountNavigation);
 | 
			
		||||
@@ -0,0 +1,50 @@
 | 
			
		||||
import { FormattedMessage } from 'react-intl';
 | 
			
		||||
 | 
			
		||||
import { LimitedAccountHint } from 'mastodon/features/account_timeline/components/limited_account_hint';
 | 
			
		||||
 | 
			
		||||
interface EmptyMessageProps {
 | 
			
		||||
  suspended: boolean;
 | 
			
		||||
  hidden: boolean;
 | 
			
		||||
  blockedBy: boolean;
 | 
			
		||||
  accountId?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const EmptyMessage: React.FC<EmptyMessageProps> = ({
 | 
			
		||||
  accountId,
 | 
			
		||||
  suspended,
 | 
			
		||||
  hidden,
 | 
			
		||||
  blockedBy,
 | 
			
		||||
}) => {
 | 
			
		||||
  if (!accountId) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let message: React.ReactNode = null;
 | 
			
		||||
 | 
			
		||||
  if (suspended) {
 | 
			
		||||
    message = (
 | 
			
		||||
      <FormattedMessage
 | 
			
		||||
        id='empty_column.account_suspended'
 | 
			
		||||
        defaultMessage='Account suspended'
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  } else if (hidden) {
 | 
			
		||||
    message = <LimitedAccountHint accountId={accountId} />;
 | 
			
		||||
  } else if (blockedBy) {
 | 
			
		||||
    message = (
 | 
			
		||||
      <FormattedMessage
 | 
			
		||||
        id='empty_column.account_unavailable'
 | 
			
		||||
        defaultMessage='Profile unavailable'
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  } else {
 | 
			
		||||
    message = (
 | 
			
		||||
      <FormattedMessage
 | 
			
		||||
        id='empty_column.account_featured'
 | 
			
		||||
        defaultMessage='This list is empty'
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return <div className='empty-column-indicator'>{message}</div>;
 | 
			
		||||
};
 | 
			
		||||
@@ -0,0 +1,51 @@
 | 
			
		||||
import { defineMessages, useIntl } from 'react-intl';
 | 
			
		||||
 | 
			
		||||
import type { Map as ImmutableMap } from 'immutable';
 | 
			
		||||
 | 
			
		||||
import { Hashtag } from 'mastodon/components/hashtag';
 | 
			
		||||
 | 
			
		||||
export type TagMap = ImmutableMap<
 | 
			
		||||
  'id' | 'name' | 'url' | 'statuses_count' | 'last_status_at' | 'accountId',
 | 
			
		||||
  string | null
 | 
			
		||||
>;
 | 
			
		||||
 | 
			
		||||
interface FeaturedTagProps {
 | 
			
		||||
  tag: TagMap;
 | 
			
		||||
  account: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const messages = defineMessages({
 | 
			
		||||
  lastStatusAt: {
 | 
			
		||||
    id: 'account.featured_tags.last_status_at',
 | 
			
		||||
    defaultMessage: 'Last post on {date}',
 | 
			
		||||
  },
 | 
			
		||||
  empty: {
 | 
			
		||||
    id: 'account.featured_tags.last_status_never',
 | 
			
		||||
    defaultMessage: 'No posts',
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const FeaturedTag: React.FC<FeaturedTagProps> = ({ tag, account }) => {
 | 
			
		||||
  const intl = useIntl();
 | 
			
		||||
  const name = tag.get('name') ?? '';
 | 
			
		||||
  const count = Number.parseInt(tag.get('statuses_count') ?? '');
 | 
			
		||||
  return (
 | 
			
		||||
    <Hashtag
 | 
			
		||||
      key={name}
 | 
			
		||||
      name={name}
 | 
			
		||||
      to={`/@${account}/tagged/${name}`}
 | 
			
		||||
      uses={count}
 | 
			
		||||
      withGraph={false}
 | 
			
		||||
      description={
 | 
			
		||||
        count > 0
 | 
			
		||||
          ? intl.formatMessage(messages.lastStatusAt, {
 | 
			
		||||
              date: intl.formatDate(tag.get('last_status_at') ?? '', {
 | 
			
		||||
                month: 'short',
 | 
			
		||||
                day: '2-digit',
 | 
			
		||||
              }),
 | 
			
		||||
            })
 | 
			
		||||
          : intl.formatMessage(messages.empty)
 | 
			
		||||
      }
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										156
									
								
								app/javascript/mastodon/features/account_featured/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										156
									
								
								app/javascript/mastodon/features/account_featured/index.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,156 @@
 | 
			
		||||
import { useEffect } from 'react';
 | 
			
		||||
 | 
			
		||||
import { FormattedMessage } from 'react-intl';
 | 
			
		||||
 | 
			
		||||
import { useParams } from 'react-router';
 | 
			
		||||
 | 
			
		||||
import type { Map as ImmutableMap } from 'immutable';
 | 
			
		||||
import { List as ImmutableList } from 'immutable';
 | 
			
		||||
 | 
			
		||||
import { fetchFeaturedTags } from 'mastodon/actions/featured_tags';
 | 
			
		||||
import { expandAccountFeaturedTimeline } from 'mastodon/actions/timelines';
 | 
			
		||||
import { ColumnBackButton } from 'mastodon/components/column_back_button';
 | 
			
		||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
 | 
			
		||||
import { RemoteHint } from 'mastodon/components/remote_hint';
 | 
			
		||||
import StatusContainer from 'mastodon/containers/status_container';
 | 
			
		||||
import { useAccountId } from 'mastodon/hooks/useAccountId';
 | 
			
		||||
import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility';
 | 
			
		||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
 | 
			
		||||
 | 
			
		||||
import { AccountHeader } from '../account_timeline/components/account_header';
 | 
			
		||||
import Column from '../ui/components/column';
 | 
			
		||||
 | 
			
		||||
import { EmptyMessage } from './components/empty_message';
 | 
			
		||||
import { FeaturedTag } from './components/featured_tag';
 | 
			
		||||
import type { TagMap } from './components/featured_tag';
 | 
			
		||||
 | 
			
		||||
interface Params {
 | 
			
		||||
  acct?: string;
 | 
			
		||||
  id?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const AccountFeatured = () => {
 | 
			
		||||
  const accountId = useAccountId();
 | 
			
		||||
  const { suspended, blockedBy, hidden } = useAccountVisibility(accountId);
 | 
			
		||||
  const forceEmptyState = suspended || blockedBy || hidden;
 | 
			
		||||
  const { acct = '' } = useParams<Params>();
 | 
			
		||||
 | 
			
		||||
  const dispatch = useAppDispatch();
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (accountId) {
 | 
			
		||||
      void dispatch(expandAccountFeaturedTimeline(accountId));
 | 
			
		||||
      dispatch(fetchFeaturedTags(accountId));
 | 
			
		||||
    }
 | 
			
		||||
  }, [accountId, dispatch]);
 | 
			
		||||
 | 
			
		||||
  const isLoading = useAppSelector(
 | 
			
		||||
    (state) =>
 | 
			
		||||
      !accountId ||
 | 
			
		||||
      !!(state.timelines as ImmutableMap<string, unknown>).getIn([
 | 
			
		||||
        `account:${accountId}:pinned`,
 | 
			
		||||
        'isLoading',
 | 
			
		||||
      ]) ||
 | 
			
		||||
      !!state.user_lists.getIn(['featured_tags', accountId, 'isLoading']),
 | 
			
		||||
  );
 | 
			
		||||
  const featuredTags = useAppSelector(
 | 
			
		||||
    (state) =>
 | 
			
		||||
      state.user_lists.getIn(
 | 
			
		||||
        ['featured_tags', accountId, 'items'],
 | 
			
		||||
        ImmutableList(),
 | 
			
		||||
      ) as ImmutableList<TagMap>,
 | 
			
		||||
  );
 | 
			
		||||
  const featuredStatusIds = useAppSelector(
 | 
			
		||||
    (state) =>
 | 
			
		||||
      (state.timelines as ImmutableMap<string, unknown>).getIn(
 | 
			
		||||
        [`account:${accountId}:pinned`, 'items'],
 | 
			
		||||
        ImmutableList(),
 | 
			
		||||
      ) as ImmutableList<string>,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (isLoading) {
 | 
			
		||||
    return (
 | 
			
		||||
      <AccountFeaturedWrapper accountId={accountId}>
 | 
			
		||||
        <div className='scrollable__append'>
 | 
			
		||||
          <LoadingIndicator />
 | 
			
		||||
        </div>
 | 
			
		||||
      </AccountFeaturedWrapper>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (featuredStatusIds.isEmpty() && featuredTags.isEmpty()) {
 | 
			
		||||
    return (
 | 
			
		||||
      <AccountFeaturedWrapper accountId={accountId}>
 | 
			
		||||
        <EmptyMessage
 | 
			
		||||
          blockedBy={blockedBy}
 | 
			
		||||
          hidden={hidden}
 | 
			
		||||
          suspended={suspended}
 | 
			
		||||
          accountId={accountId}
 | 
			
		||||
        />
 | 
			
		||||
        <RemoteHint accountId={accountId} />
 | 
			
		||||
      </AccountFeaturedWrapper>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <Column>
 | 
			
		||||
      <ColumnBackButton />
 | 
			
		||||
 | 
			
		||||
      <div className='scrollable scrollable--flex'>
 | 
			
		||||
        {accountId && (
 | 
			
		||||
          <AccountHeader accountId={accountId} hideTabs={forceEmptyState} />
 | 
			
		||||
        )}
 | 
			
		||||
        {!featuredTags.isEmpty() && (
 | 
			
		||||
          <>
 | 
			
		||||
            <h4 className='column-subheading'>
 | 
			
		||||
              <FormattedMessage
 | 
			
		||||
                id='account.featured.hashtags'
 | 
			
		||||
                defaultMessage='Hashtags'
 | 
			
		||||
              />
 | 
			
		||||
            </h4>
 | 
			
		||||
            {featuredTags.map((tag) => (
 | 
			
		||||
              <FeaturedTag key={tag.get('id')} tag={tag} account={acct} />
 | 
			
		||||
            ))}
 | 
			
		||||
          </>
 | 
			
		||||
        )}
 | 
			
		||||
        {!featuredStatusIds.isEmpty() && (
 | 
			
		||||
          <>
 | 
			
		||||
            <h4 className='column-subheading'>
 | 
			
		||||
              <FormattedMessage
 | 
			
		||||
                id='account.featured.posts'
 | 
			
		||||
                defaultMessage='Posts'
 | 
			
		||||
              />
 | 
			
		||||
            </h4>
 | 
			
		||||
            {featuredStatusIds.map((statusId) => (
 | 
			
		||||
              <StatusContainer
 | 
			
		||||
                key={`f-${statusId}`}
 | 
			
		||||
                // @ts-expect-error inferred props are wrong
 | 
			
		||||
                id={statusId}
 | 
			
		||||
                contextType='account'
 | 
			
		||||
              />
 | 
			
		||||
            ))}
 | 
			
		||||
          </>
 | 
			
		||||
        )}
 | 
			
		||||
        <RemoteHint accountId={accountId} />
 | 
			
		||||
      </div>
 | 
			
		||||
    </Column>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const AccountFeaturedWrapper = ({
 | 
			
		||||
  children,
 | 
			
		||||
  accountId,
 | 
			
		||||
}: React.PropsWithChildren<{ accountId?: string }>) => {
 | 
			
		||||
  return (
 | 
			
		||||
    <Column>
 | 
			
		||||
      <ColumnBackButton />
 | 
			
		||||
      <div className='scrollable scrollable--flex'>
 | 
			
		||||
        {accountId && <AccountHeader accountId={accountId} />}
 | 
			
		||||
        {children}
 | 
			
		||||
      </div>
 | 
			
		||||
    </Column>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// eslint-disable-next-line import/no-default-export
 | 
			
		||||
export default AccountFeatured;
 | 
			
		||||
@@ -2,25 +2,22 @@ import { useEffect, useCallback } from 'react';
 | 
			
		||||
 | 
			
		||||
import { FormattedMessage } from 'react-intl';
 | 
			
		||||
 | 
			
		||||
import { useParams } from 'react-router-dom';
 | 
			
		||||
 | 
			
		||||
import { createSelector } from '@reduxjs/toolkit';
 | 
			
		||||
import type { Map as ImmutableMap } from 'immutable';
 | 
			
		||||
import { List as ImmutableList } from 'immutable';
 | 
			
		||||
 | 
			
		||||
import { lookupAccount, fetchAccount } from 'mastodon/actions/accounts';
 | 
			
		||||
import { openModal } from 'mastodon/actions/modal';
 | 
			
		||||
import { expandAccountMediaTimeline } from 'mastodon/actions/timelines';
 | 
			
		||||
import { ColumnBackButton } from 'mastodon/components/column_back_button';
 | 
			
		||||
import { RemoteHint } from 'mastodon/components/remote_hint';
 | 
			
		||||
import ScrollableList from 'mastodon/components/scrollable_list';
 | 
			
		||||
import { TimelineHint } from 'mastodon/components/timeline_hint';
 | 
			
		||||
import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header';
 | 
			
		||||
import { LimitedAccountHint } from 'mastodon/features/account_timeline/components/limited_account_hint';
 | 
			
		||||
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
 | 
			
		||||
import Column from 'mastodon/features/ui/components/column';
 | 
			
		||||
import { useAccountId } from 'mastodon/hooks/useAccountId';
 | 
			
		||||
import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility';
 | 
			
		||||
import type { MediaAttachment } from 'mastodon/models/media_attachment';
 | 
			
		||||
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
 | 
			
		||||
import { getAccountHidden } from 'mastodon/selectors/accounts';
 | 
			
		||||
import type { RootState } from 'mastodon/store';
 | 
			
		||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
 | 
			
		||||
 | 
			
		||||
@@ -56,53 +53,11 @@ const getAccountGallery = createSelector(
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
interface Params {
 | 
			
		||||
  acct?: string;
 | 
			
		||||
  id?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const RemoteHint: React.FC<{
 | 
			
		||||
  accountId: string;
 | 
			
		||||
}> = ({ accountId }) => {
 | 
			
		||||
  const account = useAppSelector((state) => state.accounts.get(accountId));
 | 
			
		||||
  const acct = account?.acct;
 | 
			
		||||
  const url = account?.url;
 | 
			
		||||
  const domain = acct ? acct.split('@')[1] : undefined;
 | 
			
		||||
 | 
			
		||||
  if (!url) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <TimelineHint
 | 
			
		||||
      url={url}
 | 
			
		||||
      message={
 | 
			
		||||
        <FormattedMessage
 | 
			
		||||
          id='hints.profiles.posts_may_be_missing'
 | 
			
		||||
          defaultMessage='Some posts from this profile may be missing.'
 | 
			
		||||
        />
 | 
			
		||||
      }
 | 
			
		||||
      label={
 | 
			
		||||
        <FormattedMessage
 | 
			
		||||
          id='hints.profiles.see_more_posts'
 | 
			
		||||
          defaultMessage='See more posts on {domain}'
 | 
			
		||||
          values={{ domain: <strong>{domain}</strong> }}
 | 
			
		||||
        />
 | 
			
		||||
      }
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const AccountGallery: React.FC<{
 | 
			
		||||
  multiColumn: boolean;
 | 
			
		||||
}> = ({ multiColumn }) => {
 | 
			
		||||
  const { acct, id } = useParams<Params>();
 | 
			
		||||
  const dispatch = useAppDispatch();
 | 
			
		||||
  const accountId = useAppSelector(
 | 
			
		||||
    (state) =>
 | 
			
		||||
      id ??
 | 
			
		||||
      (state.accounts_map.get(normalizeForLookup(acct)) as string | undefined),
 | 
			
		||||
  );
 | 
			
		||||
  const accountId = useAccountId();
 | 
			
		||||
  const attachments = useAppSelector((state) =>
 | 
			
		||||
    accountId
 | 
			
		||||
      ? getAccountGallery(state, accountId)
 | 
			
		||||
@@ -123,33 +78,15 @@ export const AccountGallery: React.FC<{
 | 
			
		||||
  const account = useAppSelector((state) =>
 | 
			
		||||
    accountId ? state.accounts.get(accountId) : undefined,
 | 
			
		||||
  );
 | 
			
		||||
  const blockedBy = useAppSelector(
 | 
			
		||||
    (state) =>
 | 
			
		||||
      state.relationships.getIn([accountId, 'blocked_by'], false) as boolean,
 | 
			
		||||
  );
 | 
			
		||||
  const suspended = useAppSelector(
 | 
			
		||||
    (state) => state.accounts.getIn([accountId, 'suspended'], false) as boolean,
 | 
			
		||||
  );
 | 
			
		||||
  const isAccount = !!account;
 | 
			
		||||
  const remote = account?.acct !== account?.username;
 | 
			
		||||
  const hidden = useAppSelector((state) =>
 | 
			
		||||
    accountId ? getAccountHidden(state, accountId) : false,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const { suspended, blockedBy, hidden } = useAccountVisibility(accountId);
 | 
			
		||||
 | 
			
		||||
  const maxId = attachments.last()?.getIn(['status', 'id']) as
 | 
			
		||||
    | string
 | 
			
		||||
    | undefined;
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (!accountId) {
 | 
			
		||||
      dispatch(lookupAccount(acct));
 | 
			
		||||
    }
 | 
			
		||||
  }, [dispatch, accountId, acct]);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (accountId && !isAccount) {
 | 
			
		||||
      dispatch(fetchAccount(accountId));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (accountId && isAccount) {
 | 
			
		||||
      void dispatch(expandAccountMediaTimeline(accountId));
 | 
			
		||||
    }
 | 
			
		||||
@@ -233,7 +170,7 @@ export const AccountGallery: React.FC<{
 | 
			
		||||
          defaultMessage='Profile unavailable'
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
    } else if (remote && attachments.isEmpty()) {
 | 
			
		||||
    } else if (attachments.isEmpty()) {
 | 
			
		||||
      emptyMessage = <RemoteHint accountId={accountId} />;
 | 
			
		||||
    } else {
 | 
			
		||||
      emptyMessage = (
 | 
			
		||||
@@ -259,7 +196,7 @@ export const AccountGallery: React.FC<{
 | 
			
		||||
          )
 | 
			
		||||
        }
 | 
			
		||||
        alwaysPrepend
 | 
			
		||||
        append={remote && accountId && <RemoteHint accountId={accountId} />}
 | 
			
		||||
        append={accountId && <RemoteHint accountId={accountId} />}
 | 
			
		||||
        scrollKey='account_gallery'
 | 
			
		||||
        isLoading={isLoading}
 | 
			
		||||
        hasMore={!forceEmptyState && hasMore}
 | 
			
		||||
 
 | 
			
		||||
@@ -956,6 +956,9 @@ export const AccountHeader: React.FC<{
 | 
			
		||||
 | 
			
		||||
      {!(hideTabs || hidden) && (
 | 
			
		||||
        <div className='account__section-headline'>
 | 
			
		||||
          <NavLink exact to={`/@${account.acct}/featured`}>
 | 
			
		||||
            <FormattedMessage id='account.featured' defaultMessage='Featured' />
 | 
			
		||||
          </NavLink>
 | 
			
		||||
          <NavLink exact to={`/@${account.acct}`}>
 | 
			
		||||
            <FormattedMessage id='account.posts' defaultMessage='Posts' />
 | 
			
		||||
          </NavLink>
 | 
			
		||||
 
 | 
			
		||||
@@ -7,12 +7,10 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
			
		||||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
			
		||||
import { connect } from 'react-redux';
 | 
			
		||||
 | 
			
		||||
import { TimelineHint } from 'mastodon/components/timeline_hint';
 | 
			
		||||
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
 | 
			
		||||
import { me } from 'mastodon/initial_state';
 | 
			
		||||
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
 | 
			
		||||
import { getAccountHidden } from 'mastodon/selectors/accounts';
 | 
			
		||||
import { useAppSelector } from 'mastodon/store';
 | 
			
		||||
 | 
			
		||||
import { lookupAccount, fetchAccount } from '../../actions/accounts';
 | 
			
		||||
import { fetchFeaturedTags } from '../../actions/featured_tags';
 | 
			
		||||
@@ -21,6 +19,7 @@ import { ColumnBackButton } from '../../components/column_back_button';
 | 
			
		||||
import { LoadingIndicator } from '../../components/loading_indicator';
 | 
			
		||||
import StatusList from '../../components/status_list';
 | 
			
		||||
import Column from '../ui/components/column';
 | 
			
		||||
import { RemoteHint } from 'mastodon/components/remote_hint';
 | 
			
		||||
 | 
			
		||||
import { AccountHeader } from './components/account_header';
 | 
			
		||||
import { LimitedAccountHint } from './components/limited_account_hint';
 | 
			
		||||
@@ -47,11 +46,8 @@ const mapStateToProps = (state, { params: { acct, id, tagged }, withReplies = fa
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    accountId,
 | 
			
		||||
    remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
 | 
			
		||||
    remoteUrl: state.getIn(['accounts', accountId, 'url']),
 | 
			
		||||
    isAccount: !!state.getIn(['accounts', accountId]),
 | 
			
		||||
    statusIds: state.getIn(['timelines', `account:${path}`, 'items'], emptyList),
 | 
			
		||||
    featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'], emptyList),
 | 
			
		||||
    isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
 | 
			
		||||
    hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']),
 | 
			
		||||
    suspended: state.getIn(['accounts', accountId, 'suspended'], false),
 | 
			
		||||
@@ -60,24 +56,6 @@ const mapStateToProps = (state, { params: { acct, id, tagged }, withReplies = fa
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
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.posts_may_be_missing' defaultMessage='Some posts from this profile may be missing.' />}
 | 
			
		||||
      label={<FormattedMessage id='hints.profiles.see_more_posts' defaultMessage='See more posts on {domain}' values={{ domain: <strong>{domain}</strong> }} />}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
RemoteHint.propTypes = {
 | 
			
		||||
  url: PropTypes.string.isRequired,
 | 
			
		||||
  accountId: PropTypes.string.isRequired,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
class AccountTimeline extends ImmutablePureComponent {
 | 
			
		||||
 | 
			
		||||
  static propTypes = {
 | 
			
		||||
@@ -89,7 +67,6 @@ class AccountTimeline extends ImmutablePureComponent {
 | 
			
		||||
    accountId: PropTypes.string,
 | 
			
		||||
    dispatch: PropTypes.func.isRequired,
 | 
			
		||||
    statusIds: ImmutablePropTypes.list,
 | 
			
		||||
    featuredStatusIds: ImmutablePropTypes.list,
 | 
			
		||||
    isLoading: PropTypes.bool,
 | 
			
		||||
    hasMore: PropTypes.bool,
 | 
			
		||||
    withReplies: PropTypes.bool,
 | 
			
		||||
@@ -97,8 +74,6 @@ class AccountTimeline extends ImmutablePureComponent {
 | 
			
		||||
    isAccount: PropTypes.bool,
 | 
			
		||||
    suspended: PropTypes.bool,
 | 
			
		||||
    hidden: PropTypes.bool,
 | 
			
		||||
    remote: PropTypes.bool,
 | 
			
		||||
    remoteUrl: PropTypes.string,
 | 
			
		||||
    multiColumn: PropTypes.bool,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
@@ -161,7 +136,7 @@ class AccountTimeline extends ImmutablePureComponent {
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  render () {
 | 
			
		||||
    const { accountId, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props;
 | 
			
		||||
    const { accountId, statusIds, isLoading, hasMore, blockedBy, suspended, isAccount, hidden, multiColumn, remote, remoteUrl } = this.props;
 | 
			
		||||
 | 
			
		||||
    if (isLoading && statusIds.isEmpty()) {
 | 
			
		||||
      return (
 | 
			
		||||
@@ -191,8 +166,6 @@ class AccountTimeline extends ImmutablePureComponent {
 | 
			
		||||
      emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No posts found' />;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const remoteMessage = remote ? <RemoteHint accountId={accountId} url={remoteUrl} /> : null;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Column>
 | 
			
		||||
        <ColumnBackButton />
 | 
			
		||||
@@ -200,10 +173,9 @@ class AccountTimeline extends ImmutablePureComponent {
 | 
			
		||||
        <StatusList
 | 
			
		||||
          prepend={<AccountHeader accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={this.props.params.tagged} />}
 | 
			
		||||
          alwaysPrepend
 | 
			
		||||
          append={remoteMessage}
 | 
			
		||||
          append={<RemoteHint accountId={accountId} />}
 | 
			
		||||
          scrollKey='account_timeline'
 | 
			
		||||
          statusIds={forceEmptyState ? emptyList : statusIds}
 | 
			
		||||
          featuredStatusIds={featuredStatusIds}
 | 
			
		||||
          isLoading={isLoading}
 | 
			
		||||
          hasMore={!forceEmptyState && hasMore}
 | 
			
		||||
          onLoadMore={this.handleLoadMore}
 | 
			
		||||
 
 | 
			
		||||
@@ -73,6 +73,7 @@ import {
 | 
			
		||||
  About,
 | 
			
		||||
  PrivacyPolicy,
 | 
			
		||||
  TermsOfService,
 | 
			
		||||
  AccountFeatured,
 | 
			
		||||
} from './util/async-components';
 | 
			
		||||
import { ColumnsContextProvider } from './util/columns_context';
 | 
			
		||||
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
 | 
			
		||||
@@ -236,6 +237,7 @@ class SwitchingColumnsArea extends PureComponent {
 | 
			
		||||
            <WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
 | 
			
		||||
 | 
			
		||||
            <WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
 | 
			
		||||
            <WrappedRoute path={['/@:acct/featured', '/accounts/:id/featured']} component={AccountFeatured} content={children} />
 | 
			
		||||
            <WrappedRoute path='/@:acct/tagged/:tagged?' exact component={AccountTimeline} content={children} />
 | 
			
		||||
            <WrappedRoute path={['/@:acct/with_replies', '/accounts/:id/with_replies']} component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
 | 
			
		||||
            <WrappedRoute path={['/accounts/:id/followers', '/users/:acct/followers', '/@:acct/followers']} component={Followers} content={children} />
 | 
			
		||||
 
 | 
			
		||||
@@ -66,6 +66,10 @@ export function AccountGallery () {
 | 
			
		||||
  return import(/* webpackChunkName: "features/account_gallery" */'../../account_gallery');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function AccountFeatured() {
 | 
			
		||||
  return import(/* webpackChunkName: "features/account_featured" */'../../account_featured');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function Followers () {
 | 
			
		||||
  return import(/* webpackChunkName: "features/followers" */'../../followers');
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										37
									
								
								app/javascript/mastodon/hooks/useAccountId.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								app/javascript/mastodon/hooks/useAccountId.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,37 @@
 | 
			
		||||
import { useEffect } from 'react';
 | 
			
		||||
 | 
			
		||||
import { useParams } from 'react-router';
 | 
			
		||||
 | 
			
		||||
import { fetchAccount, lookupAccount } from 'mastodon/actions/accounts';
 | 
			
		||||
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
 | 
			
		||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
 | 
			
		||||
 | 
			
		||||
interface Params {
 | 
			
		||||
  acct?: string;
 | 
			
		||||
  id?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function useAccountId() {
 | 
			
		||||
  const { acct, id } = useParams<Params>();
 | 
			
		||||
  const accountId = useAppSelector(
 | 
			
		||||
    (state) =>
 | 
			
		||||
      id ??
 | 
			
		||||
      (state.accounts_map.get(normalizeForLookup(acct)) as string | undefined),
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const account = useAppSelector((state) =>
 | 
			
		||||
    accountId ? state.accounts.get(accountId) : undefined,
 | 
			
		||||
  );
 | 
			
		||||
  const isAccount = !!account;
 | 
			
		||||
 | 
			
		||||
  const dispatch = useAppDispatch();
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (!accountId) {
 | 
			
		||||
      dispatch(lookupAccount(acct));
 | 
			
		||||
    } else if (!isAccount) {
 | 
			
		||||
      dispatch(fetchAccount(accountId));
 | 
			
		||||
    }
 | 
			
		||||
  }, [dispatch, accountId, acct, isAccount]);
 | 
			
		||||
 | 
			
		||||
  return accountId;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										20
									
								
								app/javascript/mastodon/hooks/useAccountVisibility.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								app/javascript/mastodon/hooks/useAccountVisibility.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
import { getAccountHidden } from 'mastodon/selectors/accounts';
 | 
			
		||||
import { useAppSelector } from 'mastodon/store';
 | 
			
		||||
 | 
			
		||||
export function useAccountVisibility(accountId?: string) {
 | 
			
		||||
  const blockedBy = useAppSelector(
 | 
			
		||||
    (state) => !!state.relationships.getIn([accountId, 'blocked_by'], false),
 | 
			
		||||
  );
 | 
			
		||||
  const suspended = useAppSelector(
 | 
			
		||||
    (state) => !!state.accounts.getIn([accountId, 'suspended'], false),
 | 
			
		||||
  );
 | 
			
		||||
  const hidden = useAppSelector((state) =>
 | 
			
		||||
    accountId ? Boolean(getAccountHidden(state, accountId)) : false,
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    blockedBy,
 | 
			
		||||
    suspended,
 | 
			
		||||
    hidden,
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
@@ -27,9 +27,11 @@
 | 
			
		||||
  "account.edit_profile": "Edit profile",
 | 
			
		||||
  "account.enable_notifications": "Notify me when @{name} posts",
 | 
			
		||||
  "account.endorse": "Feature on profile",
 | 
			
		||||
  "account.featured": "Featured",
 | 
			
		||||
  "account.featured.hashtags": "Hashtags",
 | 
			
		||||
  "account.featured.posts": "Posts",
 | 
			
		||||
  "account.featured_tags.last_status_at": "Last post on {date}",
 | 
			
		||||
  "account.featured_tags.last_status_never": "No posts",
 | 
			
		||||
  "account.featured_tags.title": "{name}'s featured hashtags",
 | 
			
		||||
  "account.follow": "Follow",
 | 
			
		||||
  "account.follow_back": "Follow back",
 | 
			
		||||
  "account.followers": "Followers",
 | 
			
		||||
@@ -294,6 +296,7 @@
 | 
			
		||||
  "emoji_button.search_results": "Search results",
 | 
			
		||||
  "emoji_button.symbols": "Symbols",
 | 
			
		||||
  "emoji_button.travel": "Travel & Places",
 | 
			
		||||
  "empty_column.account_featured": "This list is empty",
 | 
			
		||||
  "empty_column.account_hides_collections": "This user has chosen to not make this information available",
 | 
			
		||||
  "empty_column.account_suspended": "Account suspended",
 | 
			
		||||
  "empty_column.account_timeline": "No posts here!",
 | 
			
		||||
 
 | 
			
		||||
@@ -129,6 +129,7 @@ Rails.application.routes.draw do
 | 
			
		||||
  constraints(username: %r{[^@/.]+}) do
 | 
			
		||||
    with_options to: 'accounts#show' do
 | 
			
		||||
      get '/@:username', as: :short_account
 | 
			
		||||
      get '/@:username/featured'
 | 
			
		||||
      get '/@:username/with_replies', as: :short_account_with_replies
 | 
			
		||||
      get '/@:username/media', as: :short_account_media
 | 
			
		||||
      get '/@:username/tagged/:tag', as: :short_account_tag
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user