From 36ecc930676678de65bbed2e55a850cc07186c62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Thu, 4 Apr 2024 14:50:26 +0200 Subject: [PATCH] Support Mastodon trending links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- src/api/hooks/index.ts | 3 + src/api/hooks/trends/useTrendingLinks.ts | 19 ++++++ src/components/hashtag.tsx | 28 +++++---- src/components/preview-card.tsx | 1 + src/components/trending-link.tsx | 63 +++++++++++++++++++ src/entity-store/entities.ts | 4 +- .../compose/components/search-results.tsx | 26 ++++++-- src/reducers/search.ts | 3 +- src/schemas/attachment.ts | 2 +- src/schemas/card.ts | 1 + src/schemas/index.ts | 1 + src/schemas/tag.ts | 2 +- src/schemas/trends-link.ts | 31 +++++++++ src/utils/features.ts | 6 ++ 14 files changed, 167 insertions(+), 23 deletions(-) create mode 100644 src/api/hooks/trends/useTrendingLinks.ts create mode 100644 src/components/trending-link.tsx create mode 100644 src/schemas/trends-link.ts diff --git a/src/api/hooks/index.ts b/src/api/hooks/index.ts index f085f3c4b..08cfc24b9 100644 --- a/src/api/hooks/index.ts +++ b/src/api/hooks/index.ts @@ -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'; diff --git a/src/api/hooks/trends/useTrendingLinks.ts b/src/api/hooks/trends/useTrendingLinks.ts new file mode 100644 index 000000000..c2f8ab080 --- /dev/null +++ b/src/api/hooks/trends/useTrendingLinks.ts @@ -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 }; diff --git a/src/components/hashtag.tsx b/src/components/hashtag.tsx index 1963c4f04..581db6f3d 100644 --- a/src/components/hashtag.tsx +++ b/src/components/hashtag.tsx @@ -9,6 +9,19 @@ import { HStack, Stack, Text } from './ui'; import type { Tag } from 'soapbox/types/entities'; +const accountsCountRenderer = (count: number) => !!count && ( + + {shortNumberFormat(count)}, + }} + /> + +); + interface IHashtag { hashtag: Tag; } @@ -23,18 +36,7 @@ const Hashtag: React.FC = ({ hashtag }) => { #{hashtag.name} - {Boolean(count) && ( - - {shortNumberFormat(count)}, - }} - /> - - )} + {accountsCountRenderer(count)} {hashtag.history && ( @@ -52,4 +54,4 @@ const Hashtag: React.FC = ({ hashtag }) => { ); }; -export default Hashtag; +export { Hashtag as default, accountsCountRenderer }; diff --git a/src/components/preview-card.tsx b/src/components/preview-card.tsx index 9fbbb70b8..2f7918ce2 100644 --- a/src/components/preview-card.tsx +++ b/src/components/preview-card.tsx @@ -161,6 +161,7 @@ const PreviewCard: React.FC = ({ height: horizontal ? height : undefined, }} className='status-card__image-image' + title={card.image_description || undefined} /> ); diff --git a/src/components/trending-link.tsx b/src/components/trending-link.tsx new file mode 100644 index 000000000..776e28512 --- /dev/null +++ b/src/components/trending-link.tsx @@ -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 = ({ trendingLink }) => { + const count = Number(trendingLink.history?.[0]?.accounts); + + const direction = getTextDirection(trendingLink.title + trendingLink.description); + + let media; + + if (trendingLink.image) { + media = ( +
+ {trendingLink.blurhash && ( + + )} + {trendingLink.image_description +
+ ); + } + + return ( + + {media} + + {trendingLink.title} + {trendingLink.description && {trendingLink.description}} + + + + + + + {trendingLink.provider_name} + + + + {!!count && accountsCountRenderer(count)} + + + + ); +}; + +export default TrendingLink; diff --git a/src/entity-store/entities.ts b/src/entity-store/entities.ts index 97ef4d9ac..1e1f38519 100644 --- a/src/entity-store/entities.ts +++ b/src/entity-store/entities.ts @@ -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 }; \ No newline at end of file diff --git a/src/features/compose/components/search-results.tsx b/src/features/compose/components/search-results.tsx index 362e4abe7..8ec992b80 100644 --- a/src/features/compose/components/search-results.tsx +++ b/src/features/compose/components/search-results.tsx @@ -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 ; }; @@ -197,6 +204,13 @@ const SearchResults = () => { } } + if (selectedFilter === 'links') { + if (submitted) selectFilter('accounts'); + else if (!submitted && trendingLinks) { + searchResults = trendingLinks.map(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', })} > diff --git a/src/reducers/search.ts b/src/reducers/search.ts index f4e299290..bf3c9c01f 100644 --- a/src/reducers/search.ts +++ b/src/reducers/search.ts @@ -29,6 +29,7 @@ const ResultsRecord = ImmutableRecord({ statuses: ImmutableOrderedSet(), groups: ImmutableOrderedSet(), hashtags: ImmutableOrderedSet(), // it's a list of maps + links: ImmutableOrderedSet(), accountsHasMore: false, statusesHasMore: false, groupsHasMore: false, @@ -52,7 +53,7 @@ const ReducerRecord = ImmutableRecord({ type State = ReturnType; type APIEntities = Array; -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)); diff --git a/src/schemas/attachment.ts b/src/schemas/attachment.ts index dfc880f58..62a305308 100644 --- a/src/schemas/attachment.ts +++ b/src/schemas/attachment.ts @@ -93,4 +93,4 @@ const attachmentSchema = z.discriminatedUnion('type', [ type Attachment = z.infer; -export { attachmentSchema, type Attachment }; \ No newline at end of file +export { blurhashSchema, attachmentSchema, type Attachment }; \ No newline at end of file diff --git a/src/schemas/card.ts b/src/schemas/card.ts index dc4ba2e6b..b37ad2700 100644 --- a/src/schemas/card.ts +++ b/src/schemas/card.ts @@ -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(), diff --git a/src/schemas/index.ts b/src/schemas/index.ts index 336a628ee..739157122 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -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'; diff --git a/src/schemas/tag.ts b/src/schemas/tag.ts index 22e903d60..c7845b0f1 100644 --- a/src/schemas/tag.ts +++ b/src/schemas/tag.ts @@ -15,4 +15,4 @@ const tagSchema = z.object({ type Tag = z.infer; -export { tagSchema, type Tag }; \ No newline at end of file +export { historySchema, tagSchema, type Tag }; \ No newline at end of file diff --git a/src/schemas/trends-link.ts b/src/schemas/trends-link.ts new file mode 100644 index 000000000..5e9563c3a --- /dev/null +++ b/src/schemas/trends-link.ts @@ -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; + +export { trendsLinkSchema, type TrendsLink }; diff --git a/src/utils/features.ts b/src/utils/features.ts index 8c42da8f4..5c6060008 100644 --- a/src/utils/features.ts +++ b/src/utils/features.ts @@ -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