Add author links on the explore page in web UI (#30521)

main-rebase-security-fix
Eugen Rochko 2024-06-13 15:04:16 +02:00 committed by GitHub
parent 37f53542fe
commit ed6d24330b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 185 additions and 80 deletions

View File

@ -68,13 +68,17 @@ export function importFetchedStatuses(statuses) {
status.filtered.forEach(result => pushUnique(filters, result.filter)); status.filtered.forEach(result => pushUnique(filters, result.filter));
} }
if (status.reblog && status.reblog.id) { if (status.reblog?.id) {
processStatus(status.reblog); processStatus(status.reblog);
} }
if (status.poll && status.poll.id) { if (status.poll?.id) {
pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id]))); pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id])));
} }
if (status.card?.author_account) {
pushUnique(accounts, status.card.author_account);
}
} }
statuses.forEach(processStatus); statuses.forEach(processStatus);

View File

@ -36,6 +36,10 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.poll = status.poll.id; normalStatus.poll = status.poll.id;
} }
if (status.card?.author_account) {
normalStatus.card = { ...status.card, author_account: status.card.author_account.id };
}
if (status.filtered) { if (status.filtered) {
normalStatus.filtered = status.filtered.map(normalizeFilterResult); normalStatus.filtered = status.filtered.map(normalizeFilterResult);
} }

View File

@ -1,6 +1,6 @@
import api, { getLinks } from '../api'; import api, { getLinks } from '../api';
import { importFetchedStatuses } from './importer'; import { importFetchedStatuses, importFetchedAccounts } from './importer';
export const TRENDS_TAGS_FETCH_REQUEST = 'TRENDS_TAGS_FETCH_REQUEST'; export const TRENDS_TAGS_FETCH_REQUEST = 'TRENDS_TAGS_FETCH_REQUEST';
export const TRENDS_TAGS_FETCH_SUCCESS = 'TRENDS_TAGS_FETCH_SUCCESS'; export const TRENDS_TAGS_FETCH_SUCCESS = 'TRENDS_TAGS_FETCH_SUCCESS';
@ -49,8 +49,11 @@ export const fetchTrendingLinks = () => (dispatch) => {
dispatch(fetchTrendingLinksRequest()); dispatch(fetchTrendingLinksRequest());
api() api()
.get('/api/v1/trends/links') .get('/api/v1/trends/links', { params: { limit: 20 } })
.then(({ data }) => dispatch(fetchTrendingLinksSuccess(data))) .then(({ data }) => {
dispatch(importFetchedAccounts(data.map(link => link.author_account).filter(account => !!account)));
dispatch(fetchTrendingLinksSuccess(data));
})
.catch(err => dispatch(fetchTrendingLinksFail(err))); .catch(err => dispatch(fetchTrendingLinksFail(err)));
}; };

View File

@ -0,0 +1,19 @@
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { AuthorLink } from 'mastodon/features/explore/components/author_link';
export const MoreFromAuthor = ({ accountId }) => (
<div className='more-from-author'>
<svg viewBox='0 0 79 79' className='logo logo--icon' role='img'>
<use xlinkHref='#logo-symbol-icon' />
</svg>
<FormattedMessage id='link_preview.more_from_author' defaultMessage='More from {name}' values={{ name: <AuthorLink accountId={accountId} /> }} />
</div>
);
MoreFromAuthor.propTypes = {
accountId: PropTypes.string.isRequired,
};

View File

@ -0,0 +1,21 @@
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { Avatar } from 'mastodon/components/avatar';
import { useAppSelector } from 'mastodon/store';
export const AuthorLink = ({ accountId }) => {
const account = useAppSelector(state => state.getIn(['accounts', accountId]));
return (
<Link to={`/@${account.get('acct')}`} className='story__details__shared__author-link'>
<Avatar account={account} size={16} />
<bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
</Link>
);
};
AuthorLink.propTypes = {
accountId: PropTypes.string.isRequired,
};

View File

@ -1,61 +1,89 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { PureComponent } from 'react'; import { useState, useCallback } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { Blurhash } from 'mastodon/components/blurhash'; import { Blurhash } from 'mastodon/components/blurhash';
import { accountsCountRenderer } from 'mastodon/components/hashtag';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import { ShortNumber } from 'mastodon/components/short_number'; import { ShortNumber } from 'mastodon/components/short_number';
import { Skeleton } from 'mastodon/components/skeleton'; import { Skeleton } from 'mastodon/components/skeleton';
export default class Story extends PureComponent { import { AuthorLink } from './author_link';
static propTypes = { const sharesCountRenderer = (displayNumber, pluralReady) => (
url: PropTypes.string, <FormattedMessage
title: PropTypes.string, id='link_preview.shares'
lang: PropTypes.string, defaultMessage='{count, plural, one {{counter} post} other {{counter} posts}}'
publisher: PropTypes.string, values={{
publishedAt: PropTypes.string, count: pluralReady,
author: PropTypes.string, counter: <strong>{displayNumber}</strong>,
sharedTimes: PropTypes.number, }}
thumbnail: PropTypes.string, />
thumbnailDescription: PropTypes.string, );
blurhash: PropTypes.string,
expanded: PropTypes.bool,
};
state = { export const Story = ({
thumbnailLoaded: false, url,
}; title,
lang,
publisher,
publishedAt,
author,
authorAccount,
sharedTimes,
thumbnail,
thumbnailDescription,
blurhash,
expanded
}) => {
const [thumbnailLoaded, setThumbnailLoaded] = useState(false);
handleImageLoad = () => this.setState({ thumbnailLoaded: true }); const handleImageLoad = useCallback(() => {
setThumbnailLoaded(true);
}, [setThumbnailLoaded]);
render () { return (
const { expanded, url, title, lang, publisher, author, publishedAt, sharedTimes, thumbnail, thumbnailDescription, blurhash } = this.props; <div className={classNames('story', { expanded })}>
<div className='story__details'>
const { thumbnailLoaded } = this.state; <div className='story__details__publisher'>
{publisher ? <span lang={lang}>{publisher}</span> : <Skeleton width={50} />}{publishedAt && <> · <RelativeTimestamp timestamp={publishedAt} /></>}
return (
<a className={classNames('story', { expanded })} href={url} target='blank' rel='noopener'>
<div className='story__details'>
<div className='story__details__publisher'>{publisher ? <span lang={lang}>{publisher}</span> : <Skeleton width={50} />}{publishedAt && <> · <RelativeTimestamp timestamp={publishedAt} /></>}</div>
<div className='story__details__title' lang={lang}>{title ? title : <Skeleton />}</div>
<div className='story__details__shared'>{author && <><FormattedMessage id='link_preview.author' defaultMessage='By {name}' values={{ name: <strong>{author}</strong> }} /> · </>}{typeof sharedTimes === 'number' ? <ShortNumber value={sharedTimes} renderer={accountsCountRenderer} /> : <Skeleton width={100} />}</div>
</div> </div>
<div className='story__thumbnail'> <a className='story__details__title' lang={lang} href={url} target='blank' rel='noopener'>
{thumbnail ? ( {title ? title : <Skeleton />}
<> </a>
<div className={classNames('story__thumbnail__preview', { 'story__thumbnail__preview--hidden': thumbnailLoaded })}><Blurhash hash={blurhash} /></div>
<img src={thumbnail} onLoad={this.handleImageLoad} alt={thumbnailDescription} title={thumbnailDescription} lang={lang} /> <div className='story__details__shared'>
</> {author ? <FormattedMessage id='link_preview.author' className='story__details__shared__author' defaultMessage='By {name}' values={{ name: authorAccount ? <AuthorLink accountId={authorAccount} /> : <strong>{author}</strong> }} /> : <span />}
) : <Skeleton />} {typeof sharedTimes === 'number' ? <span className='story__details__shared__pill'><ShortNumber value={sharedTimes} renderer={sharesCountRenderer} /></span> : <Skeleton width='10ch' />}
</div> </div>
</div>
<a className='story__thumbnail' href={url} target='blank' rel='noopener'>
{thumbnail ? (
<>
<div className={classNames('story__thumbnail__preview', { 'story__thumbnail__preview--hidden': thumbnailLoaded })}><Blurhash hash={blurhash} /></div>
<img src={thumbnail} onLoad={handleImageLoad} alt={thumbnailDescription} title={thumbnailDescription} lang={lang} />
</>
) : <Skeleton />}
</a> </a>
); </div>
} );
};
} Story.propTypes = {
url: PropTypes.string,
title: PropTypes.string,
lang: PropTypes.string,
publisher: PropTypes.string,
publishedAt: PropTypes.string,
author: PropTypes.string,
authorAccount: PropTypes.string,
sharedTimes: PropTypes.number,
thumbnail: PropTypes.string,
thumbnailDescription: PropTypes.string,
blurhash: PropTypes.string,
expanded: PropTypes.bool,
};

View File

@ -13,7 +13,7 @@ import { DismissableBanner } from 'mastodon/components/dismissable_banner';
import { LoadingIndicator } from 'mastodon/components/loading_indicator'; import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { WithRouterPropTypes } from 'mastodon/utils/react_router'; import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import Story from './components/story'; import { Story } from './components/story';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
links: state.getIn(['trends', 'links', 'items']), links: state.getIn(['trends', 'links', 'items']),
@ -75,6 +75,7 @@ class Links extends PureComponent {
publisher={link.get('provider_name')} publisher={link.get('provider_name')}
publishedAt={link.get('published_at')} publishedAt={link.get('published_at')}
author={link.get('author_name')} author={link.get('author_name')}
authorAccount={link.getIn(['author_account', 'id'])}
sharedTimes={link.getIn(['history', 0, 'accounts']) * 1 + link.getIn(['history', 1, 'accounts']) * 1} sharedTimes={link.getIn(['history', 0, 'accounts']) * 1 + link.getIn(['history', 1, 'accounts']) * 1}
thumbnail={link.get('image')} thumbnail={link.get('image')}
thumbnailDescription={link.get('image_description')} thumbnailDescription={link.get('image_description')}

View File

@ -6,7 +6,6 @@ import { PureComponent } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { Link } from 'react-router-dom';
import Immutable from 'immutable'; import Immutable from 'immutable';
@ -15,9 +14,9 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import DescriptionIcon from '@/material-icons/400-24px/description-fill.svg?react'; import DescriptionIcon from '@/material-icons/400-24px/description-fill.svg?react';
import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react'; import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react';
import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react'; import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react';
import { Avatar } from 'mastodon/components/avatar';
import { Blurhash } from 'mastodon/components/blurhash'; import { Blurhash } from 'mastodon/components/blurhash';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { MoreFromAuthor } from 'mastodon/components/more_from_author';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp'; import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import { useBlurhash } from 'mastodon/initial_state'; import { useBlurhash } from 'mastodon/initial_state';
@ -59,20 +58,6 @@ const addAutoPlay = html => {
return html; return html;
}; };
const MoreFromAuthor = ({ author }) => (
<div className='more-from-author'>
<svg viewBox='0 0 79 79' className='logo logo--icon' role='img'>
<use xlinkHref='#logo-symbol-icon' />
</svg>
<FormattedMessage id='link_preview.more_from_author' defaultMessage='More from {name}' values={{ name: <Link to={`/@${author.get('acct')}`}><Avatar account={author} size={16} /> {author.get('display_name')}</Link> }} />
</div>
);
MoreFromAuthor.propTypes = {
author: ImmutablePropTypes.map,
};
export default class Card extends PureComponent { export default class Card extends PureComponent {
static propTypes = { static propTypes = {
@ -259,7 +244,7 @@ export default class Card extends PureComponent {
{description} {description}
</a> </a>
{showAuthor && <MoreFromAuthor author={card.get('author_account')} />} {showAuthor && <MoreFromAuthor accountId={card.get('author_account')} />}
</> </>
); );
} }

View File

@ -415,6 +415,7 @@
"limited_account_hint.title": "This profile has been hidden by the moderators of {domain}.", "limited_account_hint.title": "This profile has been hidden by the moderators of {domain}.",
"link_preview.author": "By {name}", "link_preview.author": "By {name}",
"link_preview.more_from_author": "More from {name}", "link_preview.more_from_author": "More from {name}",
"link_preview.shares": "{count, plural, one {{counter} post} other {{counter} posts}}",
"lists.account.add": "Add to list", "lists.account.add": "Add to list",
"lists.account.remove": "Remove from list", "lists.account.remove": "Remove from list",
"lists.delete": "Delete list", "lists.delete": "Delete list",

View File

@ -8796,43 +8796,80 @@ noscript {
display: flex; display: flex;
align-items: center; align-items: center;
color: $primary-text-color; color: $primary-text-color;
text-decoration: none; padding: 16px;
padding: 15px;
border-bottom: 1px solid var(--background-border-color); border-bottom: 1px solid var(--background-border-color);
gap: 15px; gap: 16px;
&:last-child { &:last-child {
border-bottom: 0; border-bottom: 0;
} }
&:hover,
&:active,
&:focus {
color: $highlight-text-color;
.story__details__publisher,
.story__details__shared {
color: $highlight-text-color;
}
}
&__details { &__details {
flex: 1 1 auto; flex: 1 1 auto;
&__publisher { &__publisher {
color: $darker-text-color; color: $darker-text-color;
margin-bottom: 8px; margin-bottom: 8px;
font-size: 14px;
line-height: 20px;
} }
&__title { &__title {
display: block;
font-size: 19px; font-size: 19px;
line-height: 24px; line-height: 24px;
font-weight: 500; font-weight: 500;
margin-bottom: 8px; margin-bottom: 8px;
text-decoration: none;
color: $primary-text-color;
&:hover,
&:active,
&:focus {
color: $highlight-text-color;
}
} }
&__shared { &__shared {
display: flex;
align-items: center;
color: $darker-text-color; color: $darker-text-color;
gap: 8px;
justify-content: space-between;
font-size: 14px;
line-height: 20px;
& > span {
display: flex;
align-items: center;
gap: 4px;
}
&__pill {
background: var(--surface-variant-background-color);
border-radius: 4px;
color: inherit;
text-decoration: none;
padding: 4px 12px;
font-size: 12px;
font-weight: 500;
line-height: 16px;
}
&__author-link {
display: inline-flex;
align-items: center;
gap: 4px;
color: $primary-text-color;
font-weight: 500;
text-decoration: none;
&:hover,
&:active,
&:focus {
color: $highlight-text-color;
}
}
} }
strong { strong {
@ -9903,14 +9940,14 @@ noscript {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
padding: 4px 12px; padding: 4px 12px;
background: $ui-base-color; background: var(--surface-variant-background-color);
border-radius: 4px; border-radius: 4px;
font-weight: 500; font-weight: 500;
&:hover, &:hover,
&:focus, &:focus,
&:active { &:active {
background: lighten($ui-base-color, 4%); background: var(--surface-variant-active-background-color);
} }
} }

View File

@ -106,4 +106,6 @@ $font-monospace: 'mastodon-font-monospace' !default;
--background-color: #{darken($ui-base-color, 8%)}; --background-color: #{darken($ui-base-color, 8%)};
--background-color-tint: #{rgba(darken($ui-base-color, 8%), 0.9)}; --background-color-tint: #{rgba(darken($ui-base-color, 8%), 0.9)};
--surface-background-color: #{darken($ui-base-color, 4%)}; --surface-background-color: #{darken($ui-base-color, 4%)};
--surface-variant-background-color: #{$ui-base-color};
--surface-variant-active-background-color: #{lighten($ui-base-color, 4%)};
} }