mirror of
https://github.com/mastodon/mastodon.git
synced 2025-05-07 12:16:14 +00:00
Adds featured tab to web (#34405)
This commit is contained in:
parent
678c8dfeec
commit
d43bfa95aa
|
@ -102,7 +102,7 @@ export interface HashtagProps {
|
||||||
description?: React.ReactNode;
|
description?: React.ReactNode;
|
||||||
history?: number[];
|
history?: number[];
|
||||||
name: string;
|
name: string;
|
||||||
people: number;
|
people?: number;
|
||||||
to: string;
|
to: string;
|
||||||
uses?: number;
|
uses?: number;
|
||||||
withGraph?: boolean;
|
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 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>
|
|
||||||
);
|
);
|
||||||
|
|
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 { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { createSelector } from '@reduxjs/toolkit';
|
import { createSelector } from '@reduxjs/toolkit';
|
||||||
import type { Map as ImmutableMap } from 'immutable';
|
import type { Map as ImmutableMap } from 'immutable';
|
||||||
import { List as ImmutableList } from 'immutable';
|
import { List as ImmutableList } from 'immutable';
|
||||||
|
|
||||||
import { lookupAccount, fetchAccount } from 'mastodon/actions/accounts';
|
|
||||||
import { openModal } from 'mastodon/actions/modal';
|
import { openModal } from 'mastodon/actions/modal';
|
||||||
import { expandAccountMediaTimeline } from 'mastodon/actions/timelines';
|
import { expandAccountMediaTimeline } from 'mastodon/actions/timelines';
|
||||||
import { ColumnBackButton } from 'mastodon/components/column_back_button';
|
import { ColumnBackButton } from 'mastodon/components/column_back_button';
|
||||||
|
import { RemoteHint } from 'mastodon/components/remote_hint';
|
||||||
import ScrollableList from 'mastodon/components/scrollable_list';
|
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 { AccountHeader } from 'mastodon/features/account_timeline/components/account_header';
|
||||||
import { LimitedAccountHint } from 'mastodon/features/account_timeline/components/limited_account_hint';
|
import { LimitedAccountHint } from 'mastodon/features/account_timeline/components/limited_account_hint';
|
||||||
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
||||||
import Column from 'mastodon/features/ui/components/column';
|
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 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 type { RootState } from 'mastodon/store';
|
||||||
import { useAppSelector, useAppDispatch } 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<{
|
export const AccountGallery: React.FC<{
|
||||||
multiColumn: boolean;
|
multiColumn: boolean;
|
||||||
}> = ({ multiColumn }) => {
|
}> = ({ multiColumn }) => {
|
||||||
const { acct, id } = useParams<Params>();
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const accountId = useAppSelector(
|
const accountId = useAccountId();
|
||||||
(state) =>
|
|
||||||
id ??
|
|
||||||
(state.accounts_map.get(normalizeForLookup(acct)) as string | undefined),
|
|
||||||
);
|
|
||||||
const attachments = useAppSelector((state) =>
|
const attachments = useAppSelector((state) =>
|
||||||
accountId
|
accountId
|
||||||
? getAccountGallery(state, accountId)
|
? getAccountGallery(state, accountId)
|
||||||
|
@ -123,33 +78,15 @@ export const AccountGallery: React.FC<{
|
||||||
const account = useAppSelector((state) =>
|
const account = useAppSelector((state) =>
|
||||||
accountId ? state.accounts.get(accountId) : undefined,
|
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 isAccount = !!account;
|
||||||
const remote = account?.acct !== account?.username;
|
|
||||||
const hidden = useAppSelector((state) =>
|
const { suspended, blockedBy, hidden } = useAccountVisibility(accountId);
|
||||||
accountId ? getAccountHidden(state, accountId) : false,
|
|
||||||
);
|
|
||||||
const maxId = attachments.last()?.getIn(['status', 'id']) as
|
const maxId = attachments.last()?.getIn(['status', 'id']) as
|
||||||
| string
|
| string
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!accountId) {
|
|
||||||
dispatch(lookupAccount(acct));
|
|
||||||
}
|
|
||||||
}, [dispatch, accountId, acct]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (accountId && !isAccount) {
|
|
||||||
dispatch(fetchAccount(accountId));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (accountId && isAccount) {
|
if (accountId && isAccount) {
|
||||||
void dispatch(expandAccountMediaTimeline(accountId));
|
void dispatch(expandAccountMediaTimeline(accountId));
|
||||||
}
|
}
|
||||||
|
@ -233,7 +170,7 @@ export const AccountGallery: React.FC<{
|
||||||
defaultMessage='Profile unavailable'
|
defaultMessage='Profile unavailable'
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (remote && attachments.isEmpty()) {
|
} else if (attachments.isEmpty()) {
|
||||||
emptyMessage = <RemoteHint accountId={accountId} />;
|
emptyMessage = <RemoteHint accountId={accountId} />;
|
||||||
} else {
|
} else {
|
||||||
emptyMessage = (
|
emptyMessage = (
|
||||||
|
@ -259,7 +196,7 @@ export const AccountGallery: React.FC<{
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
alwaysPrepend
|
alwaysPrepend
|
||||||
append={remote && accountId && <RemoteHint accountId={accountId} />}
|
append={accountId && <RemoteHint accountId={accountId} />}
|
||||||
scrollKey='account_gallery'
|
scrollKey='account_gallery'
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
hasMore={!forceEmptyState && hasMore}
|
hasMore={!forceEmptyState && hasMore}
|
||||||
|
|
|
@ -956,6 +956,9 @@ export const AccountHeader: React.FC<{
|
||||||
|
|
||||||
{!(hideTabs || hidden) && (
|
{!(hideTabs || hidden) && (
|
||||||
<div className='account__section-headline'>
|
<div className='account__section-headline'>
|
||||||
|
<NavLink exact to={`/@${account.acct}/featured`}>
|
||||||
|
<FormattedMessage id='account.featured' defaultMessage='Featured' />
|
||||||
|
</NavLink>
|
||||||
<NavLink exact to={`/@${account.acct}`}>
|
<NavLink exact to={`/@${account.acct}`}>
|
||||||
<FormattedMessage id='account.posts' defaultMessage='Posts' />
|
<FormattedMessage id='account.posts' defaultMessage='Posts' />
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
|
@ -7,12 +7,10 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
import { TimelineHint } from 'mastodon/components/timeline_hint';
|
|
||||||
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
||||||
import { me } from 'mastodon/initial_state';
|
import { me } from 'mastodon/initial_state';
|
||||||
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
|
import { normalizeForLookup } from 'mastodon/reducers/accounts_map';
|
||||||
import { getAccountHidden } from 'mastodon/selectors/accounts';
|
import { getAccountHidden } from 'mastodon/selectors/accounts';
|
||||||
import { useAppSelector } from 'mastodon/store';
|
|
||||||
|
|
||||||
import { lookupAccount, fetchAccount } from '../../actions/accounts';
|
import { lookupAccount, fetchAccount } from '../../actions/accounts';
|
||||||
import { fetchFeaturedTags } from '../../actions/featured_tags';
|
import { fetchFeaturedTags } from '../../actions/featured_tags';
|
||||||
|
@ -21,6 +19,7 @@ import { ColumnBackButton } from '../../components/column_back_button';
|
||||||
import { LoadingIndicator } from '../../components/loading_indicator';
|
import { LoadingIndicator } from '../../components/loading_indicator';
|
||||||
import StatusList from '../../components/status_list';
|
import StatusList from '../../components/status_list';
|
||||||
import Column from '../ui/components/column';
|
import Column from '../ui/components/column';
|
||||||
|
import { RemoteHint } from 'mastodon/components/remote_hint';
|
||||||
|
|
||||||
import { AccountHeader } from './components/account_header';
|
import { AccountHeader } from './components/account_header';
|
||||||
import { LimitedAccountHint } from './components/limited_account_hint';
|
import { LimitedAccountHint } from './components/limited_account_hint';
|
||||||
|
@ -47,11 +46,8 @@ const mapStateToProps = (state, { params: { acct, id, tagged }, withReplies = fa
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
|
|
||||||
remoteUrl: state.getIn(['accounts', accountId, 'url']),
|
|
||||||
isAccount: !!state.getIn(['accounts', accountId]),
|
isAccount: !!state.getIn(['accounts', accountId]),
|
||||||
statusIds: state.getIn(['timelines', `account:${path}`, 'items'], emptyList),
|
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']),
|
isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
|
||||||
hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']),
|
hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']),
|
||||||
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
|
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 {
|
class AccountTimeline extends ImmutablePureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
|
@ -89,7 +67,6 @@ class AccountTimeline extends ImmutablePureComponent {
|
||||||
accountId: PropTypes.string,
|
accountId: PropTypes.string,
|
||||||
dispatch: PropTypes.func.isRequired,
|
dispatch: PropTypes.func.isRequired,
|
||||||
statusIds: ImmutablePropTypes.list,
|
statusIds: ImmutablePropTypes.list,
|
||||||
featuredStatusIds: ImmutablePropTypes.list,
|
|
||||||
isLoading: PropTypes.bool,
|
isLoading: PropTypes.bool,
|
||||||
hasMore: PropTypes.bool,
|
hasMore: PropTypes.bool,
|
||||||
withReplies: PropTypes.bool,
|
withReplies: PropTypes.bool,
|
||||||
|
@ -97,8 +74,6 @@ class AccountTimeline extends ImmutablePureComponent {
|
||||||
isAccount: PropTypes.bool,
|
isAccount: PropTypes.bool,
|
||||||
suspended: PropTypes.bool,
|
suspended: PropTypes.bool,
|
||||||
hidden: PropTypes.bool,
|
hidden: PropTypes.bool,
|
||||||
remote: PropTypes.bool,
|
|
||||||
remoteUrl: PropTypes.string,
|
|
||||||
multiColumn: PropTypes.bool,
|
multiColumn: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -161,7 +136,7 @@ class AccountTimeline extends ImmutablePureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
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()) {
|
if (isLoading && statusIds.isEmpty()) {
|
||||||
return (
|
return (
|
||||||
|
@ -191,8 +166,6 @@ class AccountTimeline extends ImmutablePureComponent {
|
||||||
emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No posts found' />;
|
emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No posts found' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const remoteMessage = remote ? <RemoteHint accountId={accountId} url={remoteUrl} /> : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column>
|
<Column>
|
||||||
<ColumnBackButton />
|
<ColumnBackButton />
|
||||||
|
@ -200,10 +173,9 @@ class AccountTimeline extends ImmutablePureComponent {
|
||||||
<StatusList
|
<StatusList
|
||||||
prepend={<AccountHeader accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={this.props.params.tagged} />}
|
prepend={<AccountHeader accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={this.props.params.tagged} />}
|
||||||
alwaysPrepend
|
alwaysPrepend
|
||||||
append={remoteMessage}
|
append={<RemoteHint accountId={accountId} />}
|
||||||
scrollKey='account_timeline'
|
scrollKey='account_timeline'
|
||||||
statusIds={forceEmptyState ? emptyList : statusIds}
|
statusIds={forceEmptyState ? emptyList : statusIds}
|
||||||
featuredStatusIds={featuredStatusIds}
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
hasMore={!forceEmptyState && hasMore}
|
hasMore={!forceEmptyState && hasMore}
|
||||||
onLoadMore={this.handleLoadMore}
|
onLoadMore={this.handleLoadMore}
|
||||||
|
|
|
@ -73,6 +73,7 @@ import {
|
||||||
About,
|
About,
|
||||||
PrivacyPolicy,
|
PrivacyPolicy,
|
||||||
TermsOfService,
|
TermsOfService,
|
||||||
|
AccountFeatured,
|
||||||
} from './util/async-components';
|
} from './util/async-components';
|
||||||
import { ColumnsContextProvider } from './util/columns_context';
|
import { ColumnsContextProvider } from './util/columns_context';
|
||||||
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
|
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={['/publish', '/statuses/new']} component={Compose} content={children} />
|
||||||
|
|
||||||
<WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} 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/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={['/@: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} />
|
<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');
|
return import(/* webpackChunkName: "features/account_gallery" */'../../account_gallery');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AccountFeatured() {
|
||||||
|
return import(/* webpackChunkName: "features/account_featured" */'../../account_featured');
|
||||||
|
}
|
||||||
|
|
||||||
export function Followers () {
|
export function Followers () {
|
||||||
return import(/* webpackChunkName: "features/followers" */'../../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.edit_profile": "Edit profile",
|
||||||
"account.enable_notifications": "Notify me when @{name} posts",
|
"account.enable_notifications": "Notify me when @{name} posts",
|
||||||
"account.endorse": "Feature on profile",
|
"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_at": "Last post on {date}",
|
||||||
"account.featured_tags.last_status_never": "No posts",
|
"account.featured_tags.last_status_never": "No posts",
|
||||||
"account.featured_tags.title": "{name}'s featured hashtags",
|
|
||||||
"account.follow": "Follow",
|
"account.follow": "Follow",
|
||||||
"account.follow_back": "Follow back",
|
"account.follow_back": "Follow back",
|
||||||
"account.followers": "Followers",
|
"account.followers": "Followers",
|
||||||
|
@ -294,6 +296,7 @@
|
||||||
"emoji_button.search_results": "Search results",
|
"emoji_button.search_results": "Search results",
|
||||||
"emoji_button.symbols": "Symbols",
|
"emoji_button.symbols": "Symbols",
|
||||||
"emoji_button.travel": "Travel & Places",
|
"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_hides_collections": "This user has chosen to not make this information available",
|
||||||
"empty_column.account_suspended": "Account suspended",
|
"empty_column.account_suspended": "Account suspended",
|
||||||
"empty_column.account_timeline": "No posts here!",
|
"empty_column.account_timeline": "No posts here!",
|
||||||
|
|
|
@ -129,6 +129,7 @@ Rails.application.routes.draw do
|
||||||
constraints(username: %r{[^@/.]+}) do
|
constraints(username: %r{[^@/.]+}) do
|
||||||
with_options to: 'accounts#show' do
|
with_options to: 'accounts#show' do
|
||||||
get '/@:username', as: :short_account
|
get '/@:username', as: :short_account
|
||||||
|
get '/@:username/featured'
|
||||||
get '/@:username/with_replies', as: :short_account_with_replies
|
get '/@:username/with_replies', as: :short_account_with_replies
|
||||||
get '/@:username/media', as: :short_account_media
|
get '/@:username/media', as: :short_account_media
|
||||||
get '/@:username/tagged/:tag', as: :short_account_tag
|
get '/@:username/tagged/:tag', as: :short_account_tag
|
||||||
|
|
Loading…
Reference in New Issue
Block a user