Add timeline of public posts about a trending link in web UI (#30840)
This commit is contained in:
parent
aeefe5b2be
commit
20fa9ce484
|
@ -158,6 +158,7 @@ export const expandAccountTimeline = (accountId, { maxId, withReplies, t
|
||||||
export const expandAccountFeaturedTimeline = (accountId, { tagged } = {}) => expandTimeline(`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, tagged });
|
export const expandAccountFeaturedTimeline = (accountId, { tagged } = {}) => expandTimeline(`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, tagged });
|
||||||
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
|
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
|
||||||
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
|
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
|
||||||
|
export const expandLinkTimeline = (url, { maxId } = {}, done = noOp) => expandTimeline(`link:${url}`, `/api/v1/timelines/link`, { url, max_id: maxId }, done);
|
||||||
export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}, done = noOp) => {
|
export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}, done = noOp) => {
|
||||||
return expandTimeline(`hashtag:${hashtag}${local ? ':local' : ''}`, `/api/v1/timelines/tag/${hashtag}`, {
|
return expandTimeline(`hashtag:${hashtag}${local ? ':local' : ''}`, `/api/v1/timelines/tag/${hashtag}`, {
|
||||||
max_id: maxId,
|
max_id: maxId,
|
||||||
|
|
|
@ -44,6 +44,7 @@ export interface ApiPreviewCardJSON {
|
||||||
type: string;
|
type: string;
|
||||||
author_name: string;
|
author_name: string;
|
||||||
author_url: string;
|
author_url: string;
|
||||||
|
author_account?: ApiAccountJSON;
|
||||||
provider_name: string;
|
provider_name: string;
|
||||||
provider_url: string;
|
provider_url: string;
|
||||||
html: string;
|
html: string;
|
||||||
|
|
|
@ -33,6 +33,7 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
withCounters: PropTypes.bool,
|
withCounters: PropTypes.bool,
|
||||||
timelineId: PropTypes.string,
|
timelineId: PropTypes.string,
|
||||||
lastId: PropTypes.string,
|
lastId: PropTypes.string,
|
||||||
|
bindToDocument: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
|
|
|
@ -4,6 +4,8 @@ import { useState, useCallback } 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 { Blurhash } from 'mastodon/components/blurhash';
|
import { Blurhash } from 'mastodon/components/blurhash';
|
||||||
|
@ -57,7 +59,7 @@ export const Story = ({
|
||||||
|
|
||||||
<div className='story__details__shared'>
|
<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 />}
|
{author ? <FormattedMessage id='link_preview.author' className='story__details__shared__author' defaultMessage='By {name}' values={{ name: authorAccount ? <AuthorLink accountId={authorAccount} /> : <strong>{author}</strong> }} /> : <span />}
|
||||||
{typeof sharedTimes === 'number' ? <span className='story__details__shared__pill'><ShortNumber value={sharedTimes} renderer={sharesCountRenderer} /></span> : <Skeleton width='10ch' />}
|
{typeof sharedTimes === 'number' ? <Link className='story__details__shared__pill' to={`/links/${encodeURIComponent(url)}`}><ShortNumber value={sharedTimes} renderer={sharesCountRenderer} /></Link> : <Skeleton width='10ch' />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { useRef, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
import { Helmet } from 'react-helmet';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
|
||||||
|
import { expandLinkTimeline } from 'mastodon/actions/timelines';
|
||||||
|
import Column from 'mastodon/components/column';
|
||||||
|
import { ColumnHeader } from 'mastodon/components/column_header';
|
||||||
|
import StatusListContainer from 'mastodon/features/ui/containers/status_list_container';
|
||||||
|
import type { Card } from 'mastodon/models/status';
|
||||||
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
|
export const LinkTimeline: React.FC<{
|
||||||
|
multiColumn: boolean;
|
||||||
|
}> = ({ multiColumn }) => {
|
||||||
|
const { url } = useParams<{ url: string }>();
|
||||||
|
const decodedUrl = url ? decodeURIComponent(url) : undefined;
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const columnRef = useRef<Column>(null);
|
||||||
|
const firstStatusId = useAppSelector((state) =>
|
||||||
|
decodedUrl
|
||||||
|
? // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
|
||||||
|
(state.timelines.getIn([`link:${decodedUrl}`, 'items', 0]) as string)
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
const story = useAppSelector((state) =>
|
||||||
|
firstStatusId
|
||||||
|
? (state.statuses.getIn([firstStatusId, 'card']) as Card)
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleHeaderClick = useCallback(() => {
|
||||||
|
columnRef.current?.scrollTop();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLoadMore = useCallback(
|
||||||
|
(maxId: string) => {
|
||||||
|
dispatch(expandLinkTimeline(decodedUrl, { maxId }));
|
||||||
|
},
|
||||||
|
[dispatch, decodedUrl],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
dispatch(expandLinkTimeline(decodedUrl));
|
||||||
|
}, [dispatch, decodedUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column bindToDocument={!multiColumn} ref={columnRef} label={story?.title}>
|
||||||
|
<ColumnHeader
|
||||||
|
icon='explore'
|
||||||
|
iconComponent={ExploreIcon}
|
||||||
|
title={story?.title}
|
||||||
|
onClick={handleHeaderClick}
|
||||||
|
multiColumn={multiColumn}
|
||||||
|
showBackButton
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatusListContainer
|
||||||
|
timelineId={`link:${decodedUrl}`}
|
||||||
|
onLoadMore={handleLoadMore}
|
||||||
|
trackScroll
|
||||||
|
scrollKey={`link_timeline-${decodedUrl}`}
|
||||||
|
bindToDocument={!multiColumn}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Helmet>
|
||||||
|
<title>{story?.title}</title>
|
||||||
|
<meta name='robots' content='noindex' />
|
||||||
|
</Helmet>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-default-export
|
||||||
|
export default LinkTimeline;
|
|
@ -56,6 +56,7 @@ import {
|
||||||
FavouritedStatuses,
|
FavouritedStatuses,
|
||||||
BookmarkedStatuses,
|
BookmarkedStatuses,
|
||||||
FollowedTags,
|
FollowedTags,
|
||||||
|
LinkTimeline,
|
||||||
ListTimeline,
|
ListTimeline,
|
||||||
Blocks,
|
Blocks,
|
||||||
DomainBlocks,
|
DomainBlocks,
|
||||||
|
@ -202,6 +203,7 @@ class SwitchingColumnsArea extends PureComponent {
|
||||||
<WrappedRoute path='/public/remote' exact component={Firehose} componentParams={{ feedType: 'public:remote' }} content={children} />
|
<WrappedRoute path='/public/remote' exact component={Firehose} componentParams={{ feedType: 'public:remote' }} content={children} />
|
||||||
<WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} />
|
<WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} />
|
||||||
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
|
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
|
||||||
|
<WrappedRoute path='/links/:url' component={LinkTimeline} content={children} />
|
||||||
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
|
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
|
||||||
<WrappedRoute path='/notifications' component={Notifications} content={children} exact />
|
<WrappedRoute path='/notifications' component={Notifications} content={children} exact />
|
||||||
<WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact />
|
<WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact />
|
||||||
|
|
|
@ -201,3 +201,7 @@ export function NotificationRequests () {
|
||||||
export function NotificationRequest () {
|
export function NotificationRequest () {
|
||||||
return import(/*webpackChunkName: "features/notifications/request" */'../../notifications/request');
|
return import(/*webpackChunkName: "features/notifications/request" */'../../notifications/request');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function LinkTimeline () {
|
||||||
|
return import(/*webpackChunkName: "features/link_timeline" */'../../link_timeline');
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,12 @@
|
||||||
|
import type { RecordOf } from 'immutable';
|
||||||
|
|
||||||
|
import type { ApiPreviewCardJSON } from 'mastodon/api_types/statuses';
|
||||||
|
|
||||||
export type { StatusVisibility } from 'mastodon/api_types/statuses';
|
export type { StatusVisibility } from 'mastodon/api_types/statuses';
|
||||||
|
|
||||||
// Temporary until we type it correctly
|
// Temporary until we type it correctly
|
||||||
export type Status = Immutable.Map<string, unknown>;
|
export type Status = Immutable.Map<string, unknown>;
|
||||||
|
|
||||||
|
type CardShape = Required<ApiPreviewCardJSON>;
|
||||||
|
|
||||||
|
export type Card = RecordOf<CardShape>;
|
||||||
|
|
|
@ -27,6 +27,7 @@ Rails.application.routes.draw do
|
||||||
/public/remote
|
/public/remote
|
||||||
/conversations
|
/conversations
|
||||||
/lists/(*any)
|
/lists/(*any)
|
||||||
|
/links/(*any)
|
||||||
/notifications/(*any)
|
/notifications/(*any)
|
||||||
/favourites
|
/favourites
|
||||||
/bookmarks
|
/bookmarks
|
||||||
|
|
Loading…
Reference in New Issue