Support Mastodon trending links
Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
parent
236e4db1a9
commit
838ace63d9
13 changed files with 141 additions and 29 deletions
|
@ -133,7 +133,7 @@
|
||||||
"multiselect-react-dropdown": "^2.0.25",
|
"multiselect-react-dropdown": "^2.0.25",
|
||||||
"object-to-formdata": "^4.5.1",
|
"object-to-formdata": "^4.5.1",
|
||||||
"path-browserify": "^1.0.1",
|
"path-browserify": "^1.0.1",
|
||||||
"pl-api": "^0.0.11",
|
"pl-api": "^0.0.12",
|
||||||
"postcss": "^8.4.29",
|
"postcss": "^8.4.29",
|
||||||
"process": "^0.11.10",
|
"process": "^0.11.10",
|
||||||
"punycode": "^2.1.1",
|
"punycode": "^2.1.1",
|
||||||
|
|
|
@ -115,6 +115,7 @@ const setFilter = (filterType: SearchFilter) =>
|
||||||
};
|
};
|
||||||
|
|
||||||
const expandSearch = (type: SearchFilter) => (dispatch: AppDispatch, getState: () => RootState) => {
|
const expandSearch = (type: SearchFilter) => (dispatch: AppDispatch, getState: () => RootState) => {
|
||||||
|
if (type === 'links') return;
|
||||||
const value = getState().search.value;
|
const value = getState().search.value;
|
||||||
const offset = getState().search.results[type].size;
|
const offset = getState().search.results[type].size;
|
||||||
const accountId = getState().search.accountId;
|
const accountId = getState().search.accountId;
|
||||||
|
|
|
@ -44,3 +44,6 @@ export { useHashtagStream } from './streaming/useHashtagStream';
|
||||||
export { useListStream } from './streaming/useListStream';
|
export { useListStream } from './streaming/useListStream';
|
||||||
export { useGroupStream } from './streaming/useGroupStream';
|
export { useGroupStream } from './streaming/useGroupStream';
|
||||||
export { useRemoteStream } from './streaming/useRemoteStream';
|
export { useRemoteStream } from './streaming/useRemoteStream';
|
||||||
|
|
||||||
|
// Trends
|
||||||
|
export { useTrendingLinks } from './trends/useTrendingLinks';
|
||||||
|
|
20
src/api/hooks/trends/useTrendingLinks.ts
Normal file
20
src/api/hooks/trends/useTrendingLinks.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import { trendsLinkSchema } from 'pl-api';
|
||||||
|
|
||||||
|
import { Entities } from 'soapbox/entity-store/entities';
|
||||||
|
import { useEntities } from 'soapbox/entity-store/hooks';
|
||||||
|
import { useClient, useFeatures } from 'soapbox/hooks';
|
||||||
|
|
||||||
|
const useTrendingLinks = () => {
|
||||||
|
const client = useClient();
|
||||||
|
const features = useFeatures();
|
||||||
|
|
||||||
|
const { entities, ...rest } = useEntities(
|
||||||
|
[Entities.TRENDS_LINKS],
|
||||||
|
() => client.trends.getTrendingLinks(),
|
||||||
|
{ 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 'pl-api';
|
import type { Tag } from 'pl-api';
|
||||||
|
|
||||||
|
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 {
|
interface IHashtag {
|
||||||
hashtag: Tag;
|
hashtag: Tag;
|
||||||
}
|
}
|
||||||
|
@ -23,18 +36,7 @@ const Hashtag: React.FC<IHashtag> = ({ hashtag }) => {
|
||||||
<Text tag='span' size='sm' weight='semibold'>#{hashtag.name}</Text>
|
<Text tag='span' size='sm' weight='semibold'>#{hashtag.name}</Text>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{Boolean(count) && (
|
{accountsCountRenderer(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>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
{hashtag.history && (
|
{hashtag.history && (
|
||||||
|
@ -52,4 +54,4 @@ const Hashtag: React.FC<IHashtag> = ({ hashtag }) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { Hashtag as default };
|
export { Hashtag as default, accountsCountRenderer };
|
||||||
|
|
|
@ -159,6 +159,7 @@ const PreviewCard: React.FC<IPreviewCard> = ({
|
||||||
height: horizontal ? height : undefined,
|
height: horizontal ? height : undefined,
|
||||||
}}
|
}}
|
||||||
className='status-card__image-image'
|
className='status-card__image-image'
|
||||||
|
title={card.image_description || undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
63
src/components/trending-link.tsx
Normal file
63
src/components/trending-link.tsx
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
import { TrendsLink } from 'pl-api';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
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;
|
|
@ -1,4 +1,4 @@
|
||||||
import type { BookmarkFolder, GroupMember, GroupRelationship, Relationship } from 'pl-api';
|
import type { BookmarkFolder, GroupMember, GroupRelationship, Relationship, TrendsLink } from 'pl-api';
|
||||||
import type { Account, Group, Status } from 'soapbox/normalizers';
|
import type { Account, Group, Status } from 'soapbox/normalizers';
|
||||||
import type * as Schemas from 'soapbox/schemas';
|
import type * as Schemas from 'soapbox/schemas';
|
||||||
|
|
||||||
|
@ -14,6 +14,7 @@ enum Entities {
|
||||||
RELAYS = 'Relays',
|
RELAYS = 'Relays',
|
||||||
RULES = 'Rules',
|
RULES = 'Rules',
|
||||||
STATUSES = 'Statuses',
|
STATUSES = 'Statuses',
|
||||||
|
TRENDS_LINKS = 'TrendsLinks',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EntityTypes {
|
interface EntityTypes {
|
||||||
|
@ -27,6 +28,7 @@ interface EntityTypes {
|
||||||
[Entities.RELAYS]: Schemas.Relay;
|
[Entities.RELAYS]: Schemas.Relay;
|
||||||
[Entities.RULES]: Schemas.AdminRule;
|
[Entities.RULES]: Schemas.AdminRule;
|
||||||
[Entities.STATUSES]: Status;
|
[Entities.STATUSES]: Status;
|
||||||
|
[Entities.TRENDS_LINKS]: TrendsLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Entities, type EntityTypes };
|
export { Entities, type EntityTypes };
|
|
@ -1,22 +1,23 @@
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import React, { useEffect, useRef } from 'react';
|
import { List as ImmutableList, type OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import { expandSearch, setFilter, setSearchAccount } from 'soapbox/actions/search';
|
import { expandSearch, setFilter, setSearchAccount } from 'soapbox/actions/search';
|
||||||
import { fetchTrendingStatuses } from 'soapbox/actions/trending-statuses';
|
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 Hashtag from 'soapbox/components/hashtag';
|
||||||
import IconButton from 'soapbox/components/icon-button';
|
import IconButton from 'soapbox/components/icon-button';
|
||||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||||
|
import TrendingLink from 'soapbox/components/trending-link';
|
||||||
import { HStack, Spinner, Tabs, Text } from 'soapbox/components/ui';
|
import { HStack, Spinner, Tabs, Text } from 'soapbox/components/ui';
|
||||||
import AccountContainer from 'soapbox/containers/account-container';
|
import AccountContainer from 'soapbox/containers/account-container';
|
||||||
import StatusContainer from 'soapbox/containers/status-container';
|
import StatusContainer from 'soapbox/containers/status-container';
|
||||||
import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder-account';
|
import PlaceholderAccount from 'soapbox/features/placeholder/components/placeholder-account';
|
||||||
import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder-hashtag';
|
import PlaceholderHashtag from 'soapbox/features/placeholder/components/placeholder-hashtag';
|
||||||
import PlaceholderStatus from 'soapbox/features/placeholder/components/placeholder-status';
|
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';
|
import type { VirtuosoHandle } from 'react-virtuoso';
|
||||||
import type { SearchFilter } from 'soapbox/reducers/search';
|
import type { SearchFilter } from 'soapbox/reducers/search';
|
||||||
|
|
||||||
|
@ -24,6 +25,7 @@ const messages = defineMessages({
|
||||||
accounts: { id: 'search_results.accounts', defaultMessage: 'People' },
|
accounts: { id: 'search_results.accounts', defaultMessage: 'People' },
|
||||||
statuses: { id: 'search_results.statuses', defaultMessage: 'Posts' },
|
statuses: { id: 'search_results.statuses', defaultMessage: 'Posts' },
|
||||||
hashtags: { id: 'search_results.hashtags', defaultMessage: 'Hashtags' },
|
hashtags: { id: 'search_results.hashtags', defaultMessage: 'Hashtags' },
|
||||||
|
links: { id: 'search_results.links', defaultMessage: 'News' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const SearchResults = () => {
|
const SearchResults = () => {
|
||||||
|
@ -31,6 +33,9 @@ const SearchResults = () => {
|
||||||
|
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const features = useFeatures();
|
||||||
|
|
||||||
|
const [tabKey, setTabKey] = useState(1);
|
||||||
|
|
||||||
const value = useAppSelector((state) => state.search.submittedValue);
|
const value = useAppSelector((state) => state.search.submittedValue);
|
||||||
const results = useAppSelector((state) => state.search.results);
|
const results = useAppSelector((state) => state.search.results);
|
||||||
|
@ -40,6 +45,7 @@ const SearchResults = () => {
|
||||||
const submitted = useAppSelector((state) => state.search.submitted);
|
const submitted = useAppSelector((state) => state.search.submitted);
|
||||||
const selectedFilter = useAppSelector((state) => state.search.filter);
|
const selectedFilter = useAppSelector((state) => state.search.filter);
|
||||||
const filterByAccount = useAppSelector((state) => state.search.accountId || undefined);
|
const filterByAccount = useAppSelector((state) => state.search.accountId || undefined);
|
||||||
|
const { trendingLinks } = useTrendingLinks();
|
||||||
const { account } = useAccount(filterByAccount);
|
const { account } = useAccount(filterByAccount);
|
||||||
|
|
||||||
const handleLoadMore = () => dispatch(expandSearch(selectedFilter));
|
const handleLoadMore = () => dispatch(expandSearch(selectedFilter));
|
||||||
|
@ -61,9 +67,6 @@ const SearchResults = () => {
|
||||||
action: () => selectFilter('statuses'),
|
action: () => selectFilter('statuses'),
|
||||||
name: 'statuses',
|
name: 'statuses',
|
||||||
},
|
},
|
||||||
);
|
|
||||||
|
|
||||||
items.push(
|
|
||||||
{
|
{
|
||||||
text: intl.formatMessage(messages.hashtags),
|
text: intl.formatMessage(messages.hashtags),
|
||||||
action: () => selectFilter('hashtags'),
|
action: () => selectFilter('hashtags'),
|
||||||
|
@ -71,7 +74,13 @@ const SearchResults = () => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return <Tabs items={items} activeItem={selectedFilter} />;
|
if (!submitted && features.trendingLinks) items.push({
|
||||||
|
text: intl.formatMessage(messages.links),
|
||||||
|
action: () => selectFilter('links'),
|
||||||
|
name: 'links',
|
||||||
|
});
|
||||||
|
|
||||||
|
return <Tabs key={tabKey} items={items} activeItem={selectedFilter} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCurrentIndex = (id: string): number => resultsIds?.keySeq().findIndex(key => key === id);
|
const getCurrentIndex = (id: string): number => resultsIds?.keySeq().findIndex(key => key === id);
|
||||||
|
@ -197,6 +206,15 @@ const SearchResults = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedFilter === 'links') {
|
||||||
|
if (submitted) {
|
||||||
|
selectFilter('accounts');
|
||||||
|
setTabKey(key => ++key);
|
||||||
|
} else if (!submitted && trendingLinks) {
|
||||||
|
searchResults = ImmutableList(trendingLinks.map(trendingLink => <TrendingLink trendingLink={trendingLink} />));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{filterByAccount ? (
|
{filterByAccount ? (
|
||||||
|
@ -228,7 +246,7 @@ const SearchResults = () => {
|
||||||
'divide-gray-200 dark:divide-gray-800 divide-solid divide-y': selectedFilter === 'statuses',
|
'divide-gray-200 dark:divide-gray-800 divide-solid divide-y': selectedFilter === 'statuses',
|
||||||
})}
|
})}
|
||||||
itemClassName={clsx({
|
itemClassName={clsx({
|
||||||
'pb-4': selectedFilter === 'accounts',
|
'pb-4': selectedFilter === 'accounts' || selectedFilter === 'links',
|
||||||
'pb-3': selectedFilter === 'hashtags',
|
'pb-3': selectedFilter === 'hashtags',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
|
|
@ -1305,6 +1305,7 @@
|
||||||
"search_results.accounts": "People",
|
"search_results.accounts": "People",
|
||||||
"search_results.filter_message": "You are searching for posts from @{acct}.",
|
"search_results.filter_message": "You are searching for posts from @{acct}.",
|
||||||
"search_results.hashtags": "Hashtags",
|
"search_results.hashtags": "Hashtags",
|
||||||
|
"search_results.links": "News",
|
||||||
"search_results.statuses": "Posts",
|
"search_results.statuses": "Posts",
|
||||||
"security.codes.fail": "Failed to fetch backup codes",
|
"security.codes.fail": "Failed to fetch backup codes",
|
||||||
"security.confirm.fail": "Incorrect code or password. Try again.",
|
"security.confirm.fail": "Incorrect code or password. Try again.",
|
||||||
|
|
|
@ -1305,6 +1305,7 @@
|
||||||
"search_results.accounts": "Ludzie",
|
"search_results.accounts": "Ludzie",
|
||||||
"search_results.filter_message": "Szukasz wpisów autorstwa @{acct}.",
|
"search_results.filter_message": "Szukasz wpisów autorstwa @{acct}.",
|
||||||
"search_results.hashtags": "Hashtagi",
|
"search_results.hashtags": "Hashtagi",
|
||||||
|
"search_results.links": "Aktualności",
|
||||||
"search_results.statuses": "Wpisy",
|
"search_results.statuses": "Wpisy",
|
||||||
"security.codes.fail": "Nie udało się uzyskać zapasowych kodów",
|
"security.codes.fail": "Nie udało się uzyskać zapasowych kodów",
|
||||||
"security.confirm.fail": "Nieprawidłowy kod lub hasło. Spróbuj ponownie.",
|
"security.confirm.fail": "Nieprawidłowy kod lub hasło. Spróbuj ponownie.",
|
||||||
|
|
|
@ -51,7 +51,7 @@ const ReducerRecord = ImmutableRecord({
|
||||||
|
|
||||||
type State = ReturnType<typeof ReducerRecord>;
|
type State = ReturnType<typeof ReducerRecord>;
|
||||||
type APIEntities = Array<APIEntity>;
|
type APIEntities = Array<APIEntity>;
|
||||||
type SearchFilter = 'accounts' | 'statuses' | 'groups' | 'hashtags';
|
type SearchFilter = 'accounts' | 'statuses' | 'groups' | 'hashtags' | 'links';
|
||||||
|
|
||||||
const toIds = (items: APIEntities = []) => ImmutableOrderedSet(items.map(item => item.id));
|
const toIds = (items: APIEntities = []) => ImmutableOrderedSet(items.map(item => item.id));
|
||||||
|
|
||||||
|
|
|
@ -8390,10 +8390,10 @@ pkg-types@^1.0.3:
|
||||||
mlly "^1.2.0"
|
mlly "^1.2.0"
|
||||||
pathe "^1.1.0"
|
pathe "^1.1.0"
|
||||||
|
|
||||||
pl-api@^0.0.11:
|
pl-api@^0.0.12:
|
||||||
version "0.0.11"
|
version "0.0.12"
|
||||||
resolved "https://registry.yarnpkg.com/pl-api/-/pl-api-0.0.11.tgz#3adbfac0c1ba16e2696e00f0ef1f792880e6d0a5"
|
resolved "https://registry.yarnpkg.com/pl-api/-/pl-api-0.0.12.tgz#3bd12c2e1799bc4252420b61fb3893039cb25e78"
|
||||||
integrity sha512-tFFETaG4lFkLNXH1hj4JZ4qTiF3Zxy0K/3gV/KHhGwn9V/IuNTVw1y5cZWajGiGKU+hOnj31oX4ZZwwEggHh9Q==
|
integrity sha512-S4KouCH5ZL2GOIeQPczgY3NXX8yhtRhbhdcaywUxrjybHY2idVv9SnW3sgldUe83INY0AwnBHBuxRlMY2VuNiA==
|
||||||
dependencies:
|
dependencies:
|
||||||
blurhash "^2.0.5"
|
blurhash "^2.0.5"
|
||||||
http-link-header "^1.1.3"
|
http-link-header "^1.1.3"
|
||||||
|
|
Loading…
Reference in a new issue