mirror of https://github.com/mastodon/mastodon
Adds featured tab to web (#34405)
parent
678c8dfeec
commit
d43bfa95aa
@ -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 Trends from 'mastodon/features/getting_started/containers/trends_container';
|
||||||
import { showTrends } from 'mastodon/initial_state';
|
import { showTrends } from 'mastodon/initial_state';
|
||||||
|
|
||||||
const DefaultNavigation: React.FC = () => (showTrends ? <Trends /> : null);
|
|
||||||
|
|
||||||
export const NavigationPortal: React.FC = () => (
|
export const NavigationPortal: React.FC = () => (
|
||||||
<div className='navigation-panel__portal'>
|
<div className='navigation-panel__portal'>{showTrends && <Trends />}</div>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue