kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
Support Mastodon trending links
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>merge-requests/2983/head
rodzic
9ef7521e39
commit
36ecc93067
|
@ -60,3 +60,6 @@ export { useHashtagStream } from './streaming/useHashtagStream';
|
|||
export { useListStream } from './streaming/useListStream';
|
||||
export { useGroupStream } from './streaming/useGroupStream';
|
||||
export { useRemoteStream } from './streaming/useRemoteStream';
|
||||
|
||||
// Trends
|
||||
export { useTrendingLinks } from './trends/useTrendingLinks';
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import { Entities } from 'soapbox/entity-store/entities';
|
||||
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||
import { useApi, useFeatures } from 'soapbox/hooks';
|
||||
import { trendsLinkSchema } from 'soapbox/schemas/trends-link';
|
||||
|
||||
const useTrendingLinks = () => {
|
||||
const api = useApi();
|
||||
const features = useFeatures();
|
||||
|
||||
const { entities, ...rest } = useEntities(
|
||||
[Entities.TRENDS_LINKS],
|
||||
() => api.get('/api/v1/trends/links'),
|
||||
{ schema: trendsLinkSchema, enabled: features.trendingLinks },
|
||||
);
|
||||
|
||||
return { trendingLinks: entities, ...rest };
|
||||
};
|
||||
|
||||
export { useTrendingLinks };
|
|
@ -9,6 +9,19 @@ import { HStack, Stack, Text } from './ui';
|
|||
|
||||
import type { Tag } from 'soapbox/types/entities';
|
||||
|
||||
const accountsCountRenderer = (count: number) => !!count && (
|
||||
<Text theme='muted' size='sm'>
|
||||
<FormattedMessage
|
||||
id='trends.count_by_accounts'
|
||||
defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking'
|
||||
values={{
|
||||
rawCount: count,
|
||||
count: <strong>{shortNumberFormat(count)}</strong>,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
);
|
||||
|
||||
interface IHashtag {
|
||||
hashtag: Tag;
|
||||
}
|
||||
|
@ -23,18 +36,7 @@ const Hashtag: React.FC<IHashtag> = ({ hashtag }) => {
|
|||
<Text tag='span' size='sm' weight='semibold'>#{hashtag.name}</Text>
|
||||
</Link>
|
||||
|
||||
{Boolean(count) && (
|
||||
<Text theme='muted' size='sm'>
|
||||
<FormattedMessage
|
||||
id='trends.count_by_accounts'
|
||||
defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking'
|
||||
values={{
|
||||
rawCount: count,
|
||||
count: <strong>{shortNumberFormat(count)}</strong>,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
)}
|
||||
{accountsCountRenderer(count)}
|
||||
</Stack>
|
||||
|
||||
{hashtag.history && (
|
||||
|
@ -52,4 +54,4 @@ const Hashtag: React.FC<IHashtag> = ({ hashtag }) => {
|
|||
);
|
||||
};
|
||||
|
||||
export default Hashtag;
|
||||
export { Hashtag as default, accountsCountRenderer };
|
||||
|
|
|
@ -161,6 +161,7 @@ const PreviewCard: React.FC<IPreviewCard> = ({
|
|||
height: horizontal ? height : undefined,
|
||||
}}
|
||||
className='status-card__image-image'
|
||||
title={card.image_description || undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
import React from 'react';
|
||||
|
||||
import { TrendsLink } from 'soapbox/schemas';
|
||||
import { getTextDirection } from 'soapbox/utils/rtl';
|
||||
|
||||
import Blurhash from './blurhash';
|
||||
import { accountsCountRenderer } from './hashtag';
|
||||
import { HStack, Icon, Stack, Text } from './ui';
|
||||
|
||||
interface ITrendingLink {
|
||||
trendingLink: TrendsLink;
|
||||
}
|
||||
|
||||
const TrendingLink: React.FC<ITrendingLink> = ({ trendingLink }) => {
|
||||
const count = Number(trendingLink.history?.[0]?.accounts);
|
||||
|
||||
const direction = getTextDirection(trendingLink.title + trendingLink.description);
|
||||
|
||||
let media;
|
||||
|
||||
if (trendingLink.image) {
|
||||
media = (
|
||||
<div className='relative h-32 w-32 overflow-hidden rounded-md'>
|
||||
{trendingLink.blurhash && (
|
||||
<Blurhash
|
||||
className='absolute inset-0 z-0 h-full w-full'
|
||||
hash={trendingLink.blurhash}
|
||||
/>
|
||||
)}
|
||||
<img className='relative h-full w-full object-cover' src={trendingLink.image} alt={trendingLink.image_description || undefined} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
className='flex cursor-pointer gap-4 overflow-hidden rounded-lg border border-solid border-gray-200 p-4 text-sm text-gray-800 no-underline hover:bg-gray-100 hover:no-underline dark:border-gray-800 dark:text-gray-200 dark:hover:bg-primary-800/30'
|
||||
href={trendingLink.url}
|
||||
target='_blank'
|
||||
rel='noopener'
|
||||
>
|
||||
{media}
|
||||
<Stack space={2} className='flex-1 overflow-hidden'>
|
||||
<Text className='line-clamp-2' weight='bold' direction={direction}>{trendingLink.title}</Text>
|
||||
{trendingLink.description && <Text truncate direction={direction}>{trendingLink.description}</Text>}
|
||||
<HStack alignItems='center' wrap className='divide-x-dot text-gray-700 dark:text-gray-600'>
|
||||
<HStack space={1} alignItems='center'>
|
||||
<Text tag='span' theme='muted'>
|
||||
<Icon src={require('@tabler/icons/outline/link.svg')} />
|
||||
</Text>
|
||||
<Text tag='span' theme='muted' size='sm' direction={direction}>
|
||||
{trendingLink.provider_name}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{!!count && accountsCountRenderer(count)}
|
||||
</HStack>
|
||||
</Stack>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrendingLink;
|
|
@ -11,7 +11,8 @@ enum Entities {
|
|||
GROUP_TAGS = 'GroupTags',
|
||||
PATRON_USERS = 'PatronUsers',
|
||||
RELATIONSHIPS = 'Relationships',
|
||||
STATUSES = 'Statuses'
|
||||
STATUSES = 'Statuses',
|
||||
TRENDS_LINKS = 'TrendsLinks',
|
||||
}
|
||||
|
||||
interface EntityTypes {
|
||||
|
@ -25,6 +26,7 @@ interface EntityTypes {
|
|||
[Entities.PATRON_USERS]: Schemas.PatronUser;
|
||||
[Entities.RELATIONSHIPS]: Schemas.Relationship;
|
||||
[Entities.STATUSES]: Schemas.Status;
|
||||
[Entities.TRENDS_LINKS]: Schemas.TrendsLink;
|
||||
}
|
||||
|
||||
export { Entities, type EntityTypes };
|
|
@ -4,17 +4,18 @@ import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
|||
|
||||
import { expandSearch, setFilter, setSearchAccount } from 'soapbox/actions/search';
|
||||
import { fetchTrendingStatuses } from 'soapbox/actions/trending-statuses';
|
||||
import { useAccount } from 'soapbox/api/hooks';
|
||||
import { useAccount, useTrendingLinks } from 'soapbox/api/hooks';
|
||||
import Hashtag from 'soapbox/components/hashtag';
|
||||
import IconButton from 'soapbox/components/icon-button';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import TrendingLink from 'soapbox/components/trending-link';
|
||||
import { HStack, Tabs, Text } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account-container';
|
||||
import StatusContainer from 'soapbox/containers/status-container';
|
||||
import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder-account';
|
||||
import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder-hashtag';
|
||||
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { useAppDispatch, useAppSelector, useFeatures } from 'soapbox/hooks';
|
||||
|
||||
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
import type { VirtuosoHandle } from 'react-virtuoso';
|
||||
|
@ -24,6 +25,7 @@ const messages = defineMessages({
|
|||
accounts: { id: 'search_results.accounts', defaultMessage: 'People' },
|
||||
statuses: { id: 'search_results.statuses', defaultMessage: 'Posts' },
|
||||
hashtags: { id: 'search_results.hashtags', defaultMessage: 'Hashtags' },
|
||||
links: { id: 'search_results.links', defaultMessage: 'News' },
|
||||
});
|
||||
|
||||
const SearchResults = () => {
|
||||
|
@ -31,6 +33,7 @@ const SearchResults = () => {
|
|||
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const features = useFeatures();
|
||||
|
||||
const value = useAppSelector((state) => state.search.submittedValue);
|
||||
const results = useAppSelector((state) => state.search.results);
|
||||
|
@ -40,6 +43,7 @@ const SearchResults = () => {
|
|||
const submitted = useAppSelector((state) => state.search.submitted);
|
||||
const selectedFilter = useAppSelector((state) => state.search.filter);
|
||||
const filterByAccount = useAppSelector((state) => state.search.accountId || undefined);
|
||||
const { trendingLinks } = useTrendingLinks();
|
||||
const { account } = useAccount(filterByAccount);
|
||||
|
||||
const handleLoadMore = () => dispatch(expandSearch(selectedFilter));
|
||||
|
@ -61,9 +65,6 @@ const SearchResults = () => {
|
|||
action: () => selectFilter('statuses'),
|
||||
name: 'statuses',
|
||||
},
|
||||
);
|
||||
|
||||
items.push(
|
||||
{
|
||||
text: intl.formatMessage(messages.hashtags),
|
||||
action: () => selectFilter('hashtags'),
|
||||
|
@ -71,6 +72,12 @@ const SearchResults = () => {
|
|||
},
|
||||
);
|
||||
|
||||
if (!submitted && features.trendingLinks) items.push({
|
||||
text: intl.formatMessage(messages.links),
|
||||
action: () => selectFilter('links'),
|
||||
name: 'links',
|
||||
});
|
||||
|
||||
return <Tabs items={items} activeItem={selectedFilter} />;
|
||||
};
|
||||
|
||||
|
@ -197,6 +204,13 @@ const SearchResults = () => {
|
|||
}
|
||||
}
|
||||
|
||||
if (selectedFilter === 'links') {
|
||||
if (submitted) selectFilter('accounts');
|
||||
else if (!submitted && trendingLinks) {
|
||||
searchResults = trendingLinks.map(trendingLink => <TrendingLink trendingLink={trendingLink} />);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{filterByAccount ? (
|
||||
|
@ -228,7 +242,7 @@ const SearchResults = () => {
|
|||
'divide-gray-200 dark:divide-gray-800 divide-solid divide-y': selectedFilter === 'statuses',
|
||||
})}
|
||||
itemClassName={clsx({
|
||||
'pb-4': selectedFilter === 'accounts',
|
||||
'pb-4': ['accounts', 'links'].includes(selectedFilter),
|
||||
'pb-3': selectedFilter === 'hashtags',
|
||||
})}
|
||||
>
|
||||
|
|
|
@ -29,6 +29,7 @@ const ResultsRecord = ImmutableRecord({
|
|||
statuses: ImmutableOrderedSet<string>(),
|
||||
groups: ImmutableOrderedSet<string>(),
|
||||
hashtags: ImmutableOrderedSet<Tag>(), // it's a list of maps
|
||||
links: ImmutableOrderedSet(),
|
||||
accountsHasMore: false,
|
||||
statusesHasMore: false,
|
||||
groupsHasMore: false,
|
||||
|
@ -52,7 +53,7 @@ const ReducerRecord = ImmutableRecord({
|
|||
|
||||
type State = ReturnType<typeof ReducerRecord>;
|
||||
type APIEntities = Array<APIEntity>;
|
||||
export type SearchFilter = 'accounts' | 'statuses' | 'groups' | 'hashtags';
|
||||
export type SearchFilter = 'accounts' | 'statuses' | 'groups' | 'hashtags' | 'links';
|
||||
|
||||
const toIds = (items: APIEntities = []) => {
|
||||
return ImmutableOrderedSet(items.map(item => item.id));
|
||||
|
|
|
@ -93,4 +93,4 @@ const attachmentSchema = z.discriminatedUnion('type', [
|
|||
|
||||
type Attachment = z.infer<typeof attachmentSchema>;
|
||||
|
||||
export { attachmentSchema, type Attachment };
|
||||
export { blurhashSchema, attachmentSchema, type Attachment };
|
|
@ -21,6 +21,7 @@ const cardSchema = z.object({
|
|||
height: z.number().catch(0),
|
||||
html: z.string().catch(''),
|
||||
image: z.string().nullable().catch(null),
|
||||
image_description: z.string().nullable().catch(null),
|
||||
pleroma: z.object({
|
||||
opengraph: z.object({
|
||||
width: z.number(),
|
||||
|
|
|
@ -19,3 +19,4 @@ export { relationshipSchema, type Relationship } from './relationship';
|
|||
export { statusSchema, type Status } from './status';
|
||||
export { tagSchema, type Tag } from './tag';
|
||||
export { tombstoneSchema, type Tombstone } from './tombstone';
|
||||
export { trendsLinkSchema, type TrendsLink } from './trends-link';
|
||||
|
|
|
@ -15,4 +15,4 @@ const tagSchema = z.object({
|
|||
|
||||
type Tag = z.infer<typeof tagSchema>;
|
||||
|
||||
export { tagSchema, type Tag };
|
||||
export { historySchema, tagSchema, type Tag };
|
|
@ -0,0 +1,31 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { blurhashSchema } from './attachment';
|
||||
import { historySchema } from './tag';
|
||||
|
||||
/** https://docs.joinmastodon.org/entities/PreviewCard/#trends-link */
|
||||
const trendsLinkSchema = z.preprocess((link: any) => {
|
||||
return { ...link, id: link.url };
|
||||
}, z.object({
|
||||
id: z.string().catch(''),
|
||||
url: z.string().url().catch(''),
|
||||
title: z.string().catch(''),
|
||||
description: z.string().catch(''),
|
||||
type: z.enum(['link', 'photo', 'video', 'rich']).catch('link'),
|
||||
author_name: z.string().catch(''),
|
||||
author_url: z.string().catch(''),
|
||||
provider_name: z.string().catch(''),
|
||||
provider_url: z.string().catch(''),
|
||||
html: z.string().catch(''),
|
||||
width: z.number().nullable().catch(null),
|
||||
height: z.number().nullable().catch(null),
|
||||
image: z.string().nullable().catch(null),
|
||||
image_description: z.string().nullable().catch(null),
|
||||
embed_url: z.string().catch(''),
|
||||
blurhash: blurhashSchema.nullable().catch(null),
|
||||
history: z.array(historySchema).nullable().catch(null),
|
||||
}));
|
||||
|
||||
type TrendsLink = z.infer<typeof trendsLinkSchema>;
|
||||
|
||||
export { trendsLinkSchema, type TrendsLink };
|
|
@ -1005,6 +1005,12 @@ const getInstanceFeatures = (instance: Instance) => {
|
|||
*/
|
||||
translations: features.includes('translation') || instance.configuration.translation.enabled,
|
||||
|
||||
/**
|
||||
* Trending links.
|
||||
* @see GET /api/v1/trends/links
|
||||
*/
|
||||
trendingLinks: v.software === MASTODON && gte(v.compatVersion, '3.5.0'),
|
||||
|
||||
/**
|
||||
* Trending statuses.
|
||||
* @see GET /api/v1/trends/statuses
|
||||
|
|
Ładowanie…
Reference in New Issue