Merge branch 'implement-explorer' into 'main'

Implement explorer

See merge request soapbox-pub/soapbox!3337
merge-requests/3350/head
Alex Gleason 2025-03-13 08:13:08 +00:00
commit 8edad06159
32 zmienionych plików z 1240 dodań i 187 usunięć

Wyświetl plik

@ -141,6 +141,7 @@
"reselect": "^5.0.0",
"sass": "^1.79.5",
"stringz": "^2.0.0",
"swiper": "^11.2.5",
"type-fest": "^4.0.0",
"typescript": "^5.6.2",
"vite": "^6.0.2",

Wyświetl plik

@ -49,9 +49,9 @@ const clearSearchResults = () => ({
type: SEARCH_RESULTS_CLEAR,
});
const submitSearch = (filter?: SearchFilter) =>
const submitSearch = (filter?: SearchFilter, newValue?: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const value = getState().search.value;
const value = newValue ?? getState().search.value;
const type = filter || getState().search.filter || 'statuses';
const accountId = getState().search.accountId;

Wyświetl plik

@ -1,9 +1,10 @@
import pencilIcon from '@tabler/icons/outline/pencil.svg';
import { useRef, useState } from 'react';
import { useRef } from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { Link, useHistory } from 'react-router-dom';
import { Link } from 'react-router-dom';
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper.tsx';
import { InstanceFavicon } from 'soapbox/components/instance-favicon.tsx';
import Markup from 'soapbox/components/markup.tsx';
import Avatar from 'soapbox/components/ui/avatar.tsx';
import Emoji from 'soapbox/components/ui/emoji.tsx';
@ -25,55 +26,10 @@ import RelativeTimestamp from './relative-timestamp.tsx';
import type { StatusApprovalStatus } from 'soapbox/normalizers/status.ts';
import type { Account as AccountSchema } from 'soapbox/schemas/index.ts';
interface IInstanceFavicon {
account: AccountSchema;
disabled?: boolean;
}
const messages = defineMessages({
bot: { id: 'account.badges.bot', defaultMessage: 'Bot' },
});
const InstanceFavicon: React.FC<IInstanceFavicon> = ({ account, disabled }) => {
const history = useHistory();
const [missing, setMissing] = useState<boolean>(false);
const handleError = () => setMissing(true);
const handleClick: React.MouseEventHandler = (e) => {
e.stopPropagation();
if (disabled) return;
const timelineUrl = `/timeline/${account.domain}`;
if (!(e.ctrlKey || e.metaKey)) {
history.push(timelineUrl);
} else {
window.open(timelineUrl, '_blank');
}
};
if (missing || !account.pleroma?.favicon) {
return null;
}
return (
<button
className='size-4 flex-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2'
onClick={handleClick}
disabled={disabled}
>
<img
src={account.pleroma.favicon}
alt=''
title={account.domain}
className='max-h-full w-full'
onError={handleError}
/>
</button>
);
};
interface IProfilePopper {
condition: boolean;
wrapper: (children: React.ReactNode) => React.ReactNode;

Wyświetl plik

@ -0,0 +1,49 @@
import { useState } from 'react';
import { useHistory } from 'react-router-dom';
import type { Account as AccountSchema } from 'soapbox/schemas/index.ts';
interface IInstanceFavicon {
account: AccountSchema;
disabled?: boolean;
}
export const InstanceFavicon: React.FC<IInstanceFavicon> = ({ account, disabled }) => {
const history = useHistory();
const [missing, setMissing] = useState<boolean>(false);
const handleError = () => setMissing(true);
const handleClick: React.MouseEventHandler = (e) => {
e.stopPropagation();
if (disabled) return;
const timelineUrl = `/timeline/${account.domain}`;
if (!(e.ctrlKey || e.metaKey)) {
history.push(timelineUrl);
} else {
window.open(timelineUrl, '_blank');
}
};
if (missing || !account.pleroma?.favicon) {
return null;
}
return (
<button
className='size-4 flex-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2'
onClick={handleClick}
disabled={disabled}
>
<img
src={account.pleroma.favicon}
alt=''
title={account.domain}
className='max-h-full w-full'
onError={handleError}
/>
</button>
);
};

Wyświetl plik

@ -13,7 +13,6 @@ import plusIcon from '@tabler/icons/outline/plus.svg';
import settingsIcon from '@tabler/icons/outline/settings.svg';
import userPlusIcon from '@tabler/icons/outline/user-plus.svg';
import userIcon from '@tabler/icons/outline/user.svg';
import worldIcon from '@tabler/icons/outline/world.svg';
import xIcon from '@tabler/icons/outline/x.svg';
import clsx from 'clsx';
import { useCallback, useEffect, useRef, useState } from 'react';
@ -268,15 +267,6 @@ const SidebarMenu: React.FC = (): JSX.Element | null => {
/>
)}
{features.publicTimeline && features.federating && (
<SidebarLink
to='/timeline/global'
icon={worldIcon}
text={<FormattedMessage id='tabs_bar.global' defaultMessage='Global' />}
onClick={onClose}
/>
)}
<Divider />
{features.blocks && (

Wyświetl plik

@ -27,7 +27,16 @@ const SidebarNavigationLink = forwardRef((props: ISidebarNavigationLink, ref: Re
const { icon, activeIcon, text, to = '', count, countMax, onClick } = props;
const { pathname } = useLocation();
const isActive = pathname === to;
const isDefault = to === '' || to === '/';
let isActive;
if (isDefault) {
isActive = pathname === to;
} else {
isActive = pathname.includes(to);
}
const handleClick: React.EventHandler<React.MouseEvent> = (e) => {
if (onClick) {

Wyświetl plik

@ -5,6 +5,7 @@ import settingsFilledIcon from '@tabler/icons/filled/settings.svg';
import userFilledIcon from '@tabler/icons/filled/user.svg';
import bellIcon from '@tabler/icons/outline/bell.svg';
import bookmarkIcon from '@tabler/icons/outline/bookmark.svg';
import compassIcon from '@tabler/icons/outline/brand-safari.svg';
import calendarEventIcon from '@tabler/icons/outline/calendar-event.svg';
import circlesIcon from '@tabler/icons/outline/circles.svg';
import codeIcon from '@tabler/icons/outline/code.svg';
@ -14,11 +15,9 @@ import homeIcon from '@tabler/icons/outline/home.svg';
import listIcon from '@tabler/icons/outline/list.svg';
import mailIcon from '@tabler/icons/outline/mail.svg';
import messagesIcon from '@tabler/icons/outline/messages.svg';
import searchIcon from '@tabler/icons/outline/search.svg';
import settingsIcon from '@tabler/icons/outline/settings.svg';
import userPlusIcon from '@tabler/icons/outline/user-plus.svg';
import userIcon from '@tabler/icons/outline/user.svg';
import worldIcon from '@tabler/icons/outline/world.svg';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
@ -31,7 +30,6 @@ import ComposeButton from 'soapbox/features/ui/components/compose-button.tsx';
import ProfileDropdown from 'soapbox/features/ui/components/profile-dropdown.tsx';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
import { useInstance } from 'soapbox/hooks/useInstance.ts';
import { useOwnAccount } from 'soapbox/hooks/useOwnAccount.ts';
import { useSettings } from 'soapbox/hooks/useSettings.ts';
import { useSettingsNotifications } from 'soapbox/hooks/useSettingsNotifications.ts';
@ -52,7 +50,6 @@ const SidebarNavigation = () => {
const intl = useIntl();
const { unreadChatsCount } = useStatContext();
const { instance } = useInstance();
const features = useFeatures();
const { isDeveloper } = useSettings();
const { account } = useOwnAccount();
@ -62,8 +59,6 @@ const SidebarNavigation = () => {
const dashboardCount = useAppSelector((state) => state.admin.openReports.count() + state.admin.awaitingApproval.count());
const settingsNotifications = useSettingsNotifications();
const restrictUnauth = instance.pleroma.metadata.restrict_unauthenticated;
const makeMenu = (): Menu => {
const menu: Menu = [];
@ -171,9 +166,9 @@ const SidebarNavigation = () => {
)}
<SidebarNavigationLink
to='/search'
icon={searchIcon}
text={<FormattedMessage id='tabs_bar.search' defaultMessage='Discover' />}
to='/explore'
icon={compassIcon}
text={<FormattedMessage id='tabs_bar.search' defaultMessage='Explore' />}
/>
{account && (
@ -220,16 +215,6 @@ const SidebarNavigation = () => {
</>
)}
{(features.publicTimeline) && (
features.federating && (account || !restrictUnauth.timelines.federated)) && (
<SidebarNavigationLink
to='/timeline/global'
icon={worldIcon}
text={<FormattedMessage id='tabs_bar.global' defaultMessage='Global' />}
/>
)}
{menu.length > 0 && (
<DropdownMenu items={menu} placement='top'>
<SidebarNavigationLink

Wyświetl plik

@ -3,12 +3,12 @@ import circlesFilledIcon from '@tabler/icons/filled/circles.svg';
import homeFilledIcon from '@tabler/icons/filled/home.svg';
import mailFilledIcon from '@tabler/icons/filled/mail.svg';
import bellIcon from '@tabler/icons/outline/bell.svg';
import compassIcon from '@tabler/icons/outline/brand-safari.svg';
import circlesIcon from '@tabler/icons/outline/circles.svg';
import dashboardIcon from '@tabler/icons/outline/dashboard.svg';
import homeIcon from '@tabler/icons/outline/home.svg';
import mailIcon from '@tabler/icons/outline/mail.svg';
import messagesIcon from '@tabler/icons/outline/messages.svg';
import searchIcon from '@tabler/icons/outline/search.svg';
import { FormattedMessage } from 'react-intl';
import ThumbNavigationLink from 'soapbox/components/thumb-navigation-link.tsx';
@ -83,13 +83,6 @@ const ThumbNavigation: React.FC = (): JSX.Element => {
/>
)}
<ThumbNavigationLink
src={searchIcon}
text={<FormattedMessage id='navigation.search' defaultMessage='Discover' />}
to='/search'
exact
/>
{account && (
<ThumbNavigationLink
src={bellIcon}
@ -101,6 +94,13 @@ const ThumbNavigation: React.FC = (): JSX.Element => {
/>
)}
<ThumbNavigationLink
src={compassIcon}
text={<FormattedMessage id='navigation.search' defaultMessage='Explore' />}
to='/explore'
exact
/>
{account && renderMessagesLink()}
{(account && account.staff) && (

Wyświetl plik

@ -68,7 +68,6 @@ const Input = forwardRef<HTMLInputElement, IInput>(
clsx('relative', {
'rounded-md': theme !== 'search',
'rounded-full': theme === 'search',
'mt-1': !String(outerClassName).includes('mt-'),
[String(outerClassName)]: typeof outerClassName !== 'undefined',
})
}

Wyświetl plik

@ -259,7 +259,7 @@ const Header: React.FC<IHeader> = ({ account }) => {
const onSearch = () => {
dispatch(setSearchAccount(account.id));
history.push('/search');
history.push('/explore');
};
const onAvatarClick = () => {

Wyświetl plik

@ -10,7 +10,7 @@ import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
const messages = defineMessages({
search: { id: 'aliases.search', defaultMessage: 'Search your old account' },
searchTitle: { id: 'tabs_bar.search', defaultMessage: 'Discover' },
searchTitle: { id: 'tabs_bar.search', defaultMessage: 'Explore' },
});
const Search: React.FC = () => {

Wyświetl plik

@ -1,9 +1,14 @@
import xIcon from '@tabler/icons/outline/x.svg';
import clsx from 'clsx';
import { useEffect, useRef } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import {
FormattedMessage,
} from 'react-intl';
import { expandSearch, setFilter, setSearchAccount } from 'soapbox/actions/search.ts';
import {
expandSearch,
setSearchAccount,
} from 'soapbox/actions/search.ts';
import { expandTrendingStatuses, fetchTrendingStatuses } from 'soapbox/actions/trending-statuses.ts';
import { useAccount } from 'soapbox/api/hooks/index.ts';
import Hashtag from 'soapbox/components/hashtag.tsx';
@ -11,7 +16,6 @@ import IconButton from 'soapbox/components/icon-button.tsx';
import ScrollableList from 'soapbox/components/scrollable-list.tsx';
import HStack from 'soapbox/components/ui/hstack.tsx';
import Spinner from 'soapbox/components/ui/spinner.tsx';
import Tabs from 'soapbox/components/ui/tabs.tsx';
import Text from 'soapbox/components/ui/text.tsx';
import AccountContainer from 'soapbox/containers/account-container.tsx';
import StatusContainer from 'soapbox/containers/status-container.tsx';
@ -24,18 +28,10 @@ import { useSuggestions } from 'soapbox/queries/suggestions.ts';
import type { OrderedSet as ImmutableOrderedSet } from 'immutable';
import type { VirtuosoHandle } from 'react-virtuoso';
import type { SearchFilter } from 'soapbox/reducers/search.ts';
const messages = defineMessages({
accounts: { id: 'search_results.accounts', defaultMessage: 'People' },
statuses: { id: 'search_results.statuses', defaultMessage: 'Posts' },
hashtags: { id: 'search_results.hashtags', defaultMessage: 'Hashtags' },
});
const SearchResults = () => {
const node = useRef<VirtuosoHandle>(null);
const intl = useIntl();
const dispatch = useAppDispatch();
const { data: suggestions } = useSuggestions();
@ -60,34 +56,6 @@ const SearchResults = () => {
const handleUnsetAccount = () => dispatch(setSearchAccount(null));
const selectFilter = (newActiveFilter: SearchFilter) => dispatch(setFilter(newActiveFilter));
const renderFilterBar = () => {
const items = [];
items.push(
{
text: intl.formatMessage(messages.statuses),
action: () => selectFilter('statuses'),
name: 'statuses',
},
{
text: intl.formatMessage(messages.accounts),
action: () => selectFilter('accounts'),
name: 'accounts',
},
);
items.push(
{
text: intl.formatMessage(messages.hashtags),
action: () => selectFilter('hashtags'),
name: 'hashtags',
},
);
return <Tabs items={items} activeItem={selectedFilter} />;
};
const getCurrentIndex = (id: string): number => {
return resultsIds?.keySeq().findIndex(key => key === id);
};
@ -215,7 +183,7 @@ const SearchResults = () => {
return (
<>
{filterByAccount ? (
{filterByAccount && (
<HStack className='mb-4 border-b border-solid border-gray-200 px-2 pb-4 dark:border-gray-800' space={2}>
<IconButton iconClassName='h-5 w-5' src={xIcon} onClick={handleUnsetAccount} />
<Text truncate>
@ -226,8 +194,6 @@ const SearchResults = () => {
/>
</Text>
</HStack>
) : (
<div className='px-4'>{renderFilterBar()}</div>
)}
{noResultsMessage || (

Wyświetl plik

@ -76,7 +76,7 @@ const SearchZapSplit = (props: ISearchZapSplit) => {
dispatch(setSearchAccount(null));
dispatch(submitSearch());
history.push('/search');
history.push('/explore');
} else {
dispatch(submitSearch());
}

Wyświetl plik

@ -2,7 +2,7 @@ import searchIcon from '@tabler/icons/outline/search.svg';
import xIcon from '@tabler/icons/outline/x.svg';
import clsx from 'clsx';
import { debounce } from 'es-toolkit';
import { useCallback, useEffect } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useHistory } from 'react-router-dom';
@ -17,6 +17,7 @@ import {
import AutosuggestAccountInput from 'soapbox/components/autosuggest-account-input.tsx';
import Input from 'soapbox/components/ui/input.tsx';
import SvgIcon from 'soapbox/components/ui/svg-icon.tsx';
import { useSearchTokens } from 'soapbox/features/explore/useSearchTokens.ts';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { selectAccount } from 'soapbox/selectors/index.ts';
@ -55,6 +56,8 @@ const Search = (props: ISearch) => {
const dispatch = useAppDispatch();
const history = useHistory();
const intl = useIntl();
const [inputValue, setInputValue] = useState('');
const { addToken } = useSearchTokens();
const value = useAppSelector((state) => state.search.value);
const submitted = useAppSelector((state) => state.search.submitted);
@ -67,6 +70,7 @@ const Search = (props: ISearch) => {
const { value } = event.target;
dispatch(changeSearch(value));
setInputValue(value);
if (autoSubmit) {
debouncedSubmit();
@ -83,11 +87,12 @@ const Search = (props: ISearch) => {
const handleSubmit = () => {
if (openInRoute) {
addToken(value);
dispatch(setSearchAccount(null));
dispatch(submitSearch());
history.push('/search');
history.push('/explore');
} else {
addToken(value);
dispatch(submitSearch());
}
};
@ -124,7 +129,7 @@ const Search = (props: ISearch) => {
type: 'text',
id: 'search',
placeholder: intl.formatMessage(messages.placeholder),
value,
value: inputValue,
onChange: handleChange,
onKeyDown: handleKeyDown,
onFocus: handleFocus,

Wyświetl plik

@ -0,0 +1,74 @@
import arrowIcon from '@tabler/icons/outline/chevron-down.svg';
import rocketIcon from '@tabler/icons/outline/rocket.svg';
import { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import HStack from 'soapbox/components/ui/hstack.tsx';
import IconButton from 'soapbox/components/ui/icon-button.tsx';
import Stack from 'soapbox/components/ui/stack.tsx';
import SvgIcon from 'soapbox/components/ui/svg-icon.tsx';
import Text from 'soapbox/components/ui/text.tsx';
const messages = defineMessages({
welcomeTitle: { id: 'column.explore.welcome_card.title', defaultMessage: 'Welcome to Explore' },
welcomeText: { id: 'column.explore.welcome_card.text', defaultMessage: 'Explore the world of decentralized social media, dive into {nostrLink} or cross {bridgeLink} to other networks, and connect with a global community. All in one place.' },
nostrTitle: { id: 'column.explore.nostr', defaultMessage: 'Nostr' },
bridgeTitle: { id: 'column.explore.bridge', defaultMessage: 'Bridges' },
});
const ExploreCards = () => {
const [isOpen, setIsOpen] = useState(true);
const intl = useIntl();
const handleClick = () => {
setIsOpen((prev) => {
const newValue = !prev;
localStorage.setItem('soapbox:explore:card:status', JSON.stringify(!isOpen));
return newValue;
});
};
useEffect(
() => {
const value = localStorage.getItem('soapbox:explore:card:status');
if (value !== null) {
setIsOpen(JSON.parse(value));
}
}, []);
return (
<Stack className='mx-4 mt-4' space={2}>
<Stack
space={4}
className={`rounded-xl bg-gradient-to-r from-primary-500 to-primary-700 ${isOpen ? 'mt-0 px-5 pb-8 pt-4' : 'p-4'}`}
>
<HStack justifyContent='between' className='text-white'>
<HStack space={2}>
<SvgIcon src={rocketIcon} />
<p className='text-xl font-bold'>
{intl.formatMessage(messages.welcomeTitle)}
</p>
</HStack>
<IconButton
src={arrowIcon}
theme='transparent'
onClick={handleClick}
className={`transition-transform duration-300 ${
isOpen ? 'rotate-180' : 'rotate-0'
}`}
/>
</HStack>
<Text className={`text-white ${isOpen ? 'max-h-96 opacity-100' : 'hidden max-h-0 opacity-0'}`}>
{intl.formatMessage(messages.welcomeText, {
nostrLink: <a className='font-medium text-secondary-400 underline' href='https://soapbox.pub/blog/nostr101/' target='_blank' rel='noopener noreferrer'>{intl.formatMessage(messages.nostrTitle)}</a>,
bridgeLink: <a className='font-medium text-secondary-400 underline' href='https://soapbox.pub/blog/mostr-fediverse-nostr-bridge/' target='_blank' rel='noopener noreferrer'>{intl.formatMessage(messages.bridgeTitle)}</a>,
})}
</Text>
</Stack>
</Stack>
);
};
export default ExploreCards;

Wyświetl plik

@ -0,0 +1,226 @@
import arrowIcon from '@tabler/icons/outline/chevron-down.svg';
import xIcon from '@tabler/icons/outline/x.svg';
import { debounce } from 'es-toolkit';
import { useEffect, useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { changeSearch, submitSearch } from 'soapbox/actions/search.ts';
import Divider from 'soapbox/components/ui/divider.tsx';
import HStack from 'soapbox/components/ui/hstack.tsx';
import IconButton from 'soapbox/components/ui/icon-button.tsx';
import Stack from 'soapbox/components/ui/stack.tsx';
import {
WordFilter,
LanguageFilter,
MediaFilter,
PlatformFilters,
ToggleRepliesFilter,
} from 'soapbox/features/explore/components/filters.tsx';
import { useSearchTokens } from 'soapbox/features/explore/useSearchTokens.ts';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
const messages = defineMessages({
allMedia: { id: 'column.explore.media_filters.all_media', defaultMessage: 'All media' },
imageOnly: { id: 'column.explore.media_filters.image', defaultMessage: 'Image only' },
videoOnly: { id: 'column.explore.media_filters.video', defaultMessage: 'Video only' },
noMedia: { id: 'column.explore.media_filters.no_media', defaultMessage: 'No media' },
showReplies: { id: 'home.column_settings.show_replies', defaultMessage: 'Show replies' },
nostr: { id: 'column.explore.filters.nostr', defaultMessage: 'Nostr' },
atproto: { id: 'column.explore.filters.bluesky', defaultMessage: 'Bluesky' },
activitypub: { id: 'column.explore.filters.fediverse', defaultMessage: 'Fediverse' },
removeFilter: { id: 'column.explore.filters.remove_filter', defaultMessage: 'Remove filter' },
});
const ExploreFilter = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { tokens, addToken, removeToken, removeTokens } = useSearchTokens();
const [isOpen, setIsOpen] = useState(false);
const handleClick = () => {
setIsOpen((prev) => {
const newValue = !prev;
localStorage.setItem('soapbox:explore:filter:status', JSON.stringify(newValue));
return newValue;
});
};
const debouncedSearch = useMemo(
() => debounce((value: string) => {
dispatch(changeSearch(value));
dispatch(submitSearch(undefined, value));
}, 300),
[dispatch],
);
useEffect(
() => {
debouncedSearch([...tokens].join(' '));
return () => {
debouncedSearch.cancel();
};
}, [tokens, dispatch],
);
useEffect(
() => {
const isOpenStatus = localStorage.getItem('soapbox:explore:filter:status');
if (isOpenStatus !== null) {
setIsOpen(JSON.parse(isOpenStatus));
}
}
, []);
const filters = new Set<string>(['protocol:nostr', 'protocol:atproto', 'protocol:activitypub']);
for (const token of tokens) {
if (token.startsWith('-protocol:')) {
filters.delete(token.replace(/^-/, ''));
}
filters.add(token);
}
function renderMediaFilter(): JSX.Element | null {
const clearMediaFilters = () => removeTokens(['media:true', '-video:true', 'video:true', '-media:true']);
if (tokens.has('media:true') && tokens.has('-video:true')) {
return (
<FilterToken
label={intl.formatMessage(messages.imageOnly)}
textColor='text-blue-500'
borderColor='border-blue-500'
onRemove={clearMediaFilters}
/>
);
}
if (tokens.has('video:true')) {
return (
<FilterToken
label={intl.formatMessage(messages.videoOnly)}
textColor='text-blue-500'
borderColor='border-blue-500'
onRemove={clearMediaFilters}
/>
);
}
if (tokens.has('-media:true')) {
return (
<FilterToken
label={intl.formatMessage(messages.noMedia)}
textColor='text-blue-500'
borderColor='border-blue-500'
onRemove={clearMediaFilters}
/>
);
}
return null;
}
return (
<Stack className='px-4' space={3}>
<HStack alignItems='start' justifyContent='between' space={1}>
<HStack className='flex-wrap whitespace-normal' alignItems='center'>
{!tokens.has('-protocol:nostr') ? (
<FilterToken
label={intl.formatMessage(messages.nostr)}
textColor='text-purple-500'
borderColor='border-purple-500'
onRemove={() => addToken('-protocol:nostr')}
/>
) : null}
{!tokens.has('-protocol:atproto') ? (
<FilterToken
label={intl.formatMessage(messages.atproto)}
textColor='text-blue-500'
borderColor='border-blue-500'
onRemove={() => addToken('-protocol:atproto')}
/>
) : null}
{!tokens.has('-protocol:activitypub') ? (
<FilterToken
label={intl.formatMessage(messages.activitypub)}
textColor='text-indigo-500'
borderColor='border-indigo-500'
onRemove={() => addToken('-protocol:activitypub')}
/>
) : null}
{renderMediaFilter()}
{[...filters].filter((token) => token.startsWith('language:')).map((token) => (
<FilterToken
key={token}
label={token.replace('language:', '')}
textColor='text-gray-500'
borderColor='border-gray-500'
onRemove={() => removeToken(token)}
/>
))}
{[...filters].filter((token) => !token.includes(':')).map((token) => (
<FilterToken
key={token}
label={token}
textColor='text-green-500'
borderColor='border-green-600'
onRemove={() => removeToken(token)}
/>
))}
</HStack>
<IconButton
src={arrowIcon}
theme='transparent'
className={`transition-transform duration-300 ${ isOpen ? 'rotate-180' : 'rotate-0'}`}
onClick={handleClick}
/>
</HStack>
<Stack className={`overflow-hidden transition-all duration-500 ease-in-out ${isOpen ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0'}`} space={3}>
<ToggleRepliesFilter />
<MediaFilter />
<LanguageFilter />
<PlatformFilters />
<Divider />
<WordFilter />
</Stack>
</Stack>
);
};
interface IFilterToken {
label: string;
textColor: string;
borderColor: string;
onRemove: () => void;
}
const FilterToken: React.FC<IFilterToken> = ({ label, textColor, borderColor, onRemove }) => {
const intl = useIntl();
const handleChangeFilters = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
onRemove();
};
return (
<div className={`group m-1 flex items-center whitespace-normal break-words rounded-full border-2 bg-transparent px-3 pr-1 text-base font-medium shadow-sm hover:cursor-pointer ${borderColor} ${textColor}`}>
{label}
<IconButton
iconClassName='!w-4' className={`!py-0 group-hover:block ${textColor}`} src={xIcon}
onClick={handleChangeFilters}
aria-label={intl.formatMessage(messages.removeFilter)}
/>
</div>
);
};
export default ExploreFilter;

Wyświetl plik

@ -0,0 +1,387 @@
import refreshIcon from '@tabler/icons/outline/refresh.svg';
import searchIcon from '@tabler/icons/outline/search.svg';
import xIcon from '@tabler/icons/outline/x.svg';
import clsx from 'clsx';
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Button from 'soapbox/components/ui/button.tsx';
import Checkbox from 'soapbox/components/ui/checkbox.tsx';
import HStack from 'soapbox/components/ui/hstack.tsx';
import IconButton from 'soapbox/components/ui/icon-button.tsx';
import Input from 'soapbox/components/ui/input.tsx';
import Stack from 'soapbox/components/ui/stack.tsx';
import SvgIcon from 'soapbox/components/ui/svg-icon.tsx';
import Text from 'soapbox/components/ui/text.tsx';
import Toggle from 'soapbox/components/ui/toggle.tsx';
import { useSearchTokens } from 'soapbox/features/explore/useSearchTokens.ts';
import { SelectDropdown } from 'soapbox/features/forms/index.tsx';
import toast from 'soapbox/toast.tsx';
const messages = defineMessages({
showReplies: { id: 'home.column_settings.show_replies', defaultMessage: 'Show replies' },
media: { id: 'column.explore.filters.media', defaultMessage: 'Media' },
language: { id: 'column.explore.filters.language', defaultMessage: 'Language' },
platforms: { id: 'column.explore.filters.platforms', defaultMessage: 'Platforms' },
createYourFilter: { id: 'column.explore.filters.create_your_filter', defaultMessage: 'Create your filter' },
resetFilter: { id: 'column.explore.filters.reset', defaultMessage: 'Reset Filters' },
filterByWords: { id: 'column.explore.filters.filter_by_words', defaultMessage: 'Filter word' },
negative: { id: 'column.explore.filters.invert', defaultMessage: 'Invert' },
nostr: { id: 'column.explore.filters.nostr', defaultMessage: 'Nostr' },
atproto: { id: 'column.explore.filters.bluesky', defaultMessage: 'Bluesky' },
activitypub: { id: 'column.explore.filters.fediverse', defaultMessage: 'Fediverse' },
cancel: { id: 'column.explore.filters.cancel', defaultMessage: 'Cancel' },
addFilter: { id: 'column.explore.filters.add_filter', defaultMessage: 'Add Filter' },
allMedia: { id: 'column.explore.media_filters.all_media', defaultMessage: 'All media' },
imageOnly: { id: 'column.explore.media_filters.image', defaultMessage: 'Image only' },
videoOnly: { id: 'column.explore.media_filters.video', defaultMessage: 'Video only' },
noMedia: { id: 'column.explore.media_filters.no_media', defaultMessage: 'No media' },
clearSearch: { id: 'column.explore.filters.clear_input', defaultMessage: 'Clear filter input' },
empty: { id: 'column.explore.filters.empty', defaultMessage: 'Hey there... You forget to write the filter!' },
});
const languages = {
default: 'Global',
en: 'English',
ar: 'العربية',
bg: 'Български',
bn: 'বাংলা',
ca: 'Català',
co: 'Corsu',
cs: 'Čeština',
cy: 'Cymraeg',
da: 'Dansk',
de: 'Deutsch',
el: 'Ελληνικά',
eo: 'Esperanto',
es: 'Español',
eu: 'Euskara',
fa: 'فارسی',
fi: 'Suomi',
fr: 'Français',
ga: 'Gaeilge',
gl: 'Galego',
he: 'עברית',
hi: 'हिन्दी',
hr: 'Hrvatski',
hu: 'Magyar',
hy: 'Հայերեն',
id: 'Bahasa Indonesia',
io: 'Ido',
is: 'íslenska',
it: 'Italiano',
ja: '日本語',
jv: 'ꦧꦱꦗꦮ',
ka: 'ქართული',
kk: 'Қазақша',
ko: '한국어',
lt: 'Lietuvių',
lv: 'Latviešu',
ml: 'മലയാളം',
ms: 'Bahasa Melayu',
nl: 'Nederlands',
no: 'Norsk',
oc: 'Occitan',
pl: 'Polski',
pt: 'Português',
ro: 'Română',
ru: 'Русский',
sk: 'Slovenčina',
sl: 'Slovenščina',
sq: 'Shqip',
sr: 'Српски',
sv: 'Svenska',
ta: 'தமிழ்',
te: 'తెలుగు',
th: 'ไทย',
tr: 'Türkçe',
uk: 'Українська',
zh: '中文',
};
const ProtocolCheckBox: React.FC<{ protocol: 'nostr' | 'atproto' | 'activitypub' }> = ({ protocol }) => {
const intl = useIntl();
const { tokens, addToken, removeToken } = useSearchTokens();
const token = `-protocol:${protocol}`;
const checked = !tokens.has(token);
const message = messages[protocol];
const handleProtocolFilter = (e: React.ChangeEvent<HTMLInputElement>) => {
const { checked, name } = e.target;
const token = `-protocol:${name}`;
if (checked) {
removeToken(token);
} else {
addToken(token);
}
};
return (
<HStack alignItems='center' space={2}>
<Checkbox
name={protocol}
checked={checked}
onChange={handleProtocolFilter}
aria-label={intl.formatMessage(message)}
/>
<Text size='md'>
{intl.formatMessage(message)}
</Text>
</HStack>
);
};
const PlatformFilters = () => {
const intl = useIntl();
return (
<HStack className='flex-wrap whitespace-normal' alignItems='center' space={2}>
<Text size='md' weight='bold'>
{intl.formatMessage(messages.platforms)}
</Text>
<ProtocolCheckBox protocol='nostr' />
<ProtocolCheckBox protocol='atproto' />
<ProtocolCheckBox protocol='activitypub' />
</HStack>
);
};
const WordFilter = () => {
const intl = useIntl();
const { addToken, clearTokens } = useSearchTokens();
const [word, setWord] = useState('');
const [negative, setNegative] = useState(false);
const hasValue = !!word;
const handleReset = () => {
clearTokens();
};
const handleClearValue = () => {
setWord('');
};
const handleAddFilter = () => {
if (word) {
addToken(`${negative ? '-' : ''}${word}`);
handleClearValue();
} else {
toast.error(intl.formatMessage(messages.empty));
}
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
const key = e.key;
switch (key) {
case 'Enter':
e.stopPropagation();
e.preventDefault();
handleAddFilter();
break;
case 'Escape':
e.stopPropagation();
e.preventDefault();
handleClearValue();
break;
}
};
const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setWord(e.target.value);
};
return (
<Stack space={3}>
<HStack justifyContent='between' alignItems='center'>
<Text size='md' weight='bold'>
{intl.formatMessage(messages.createYourFilter)}
</Text>
<IconButton src={refreshIcon} iconClassName='w-4' className='px-4' text={intl.formatMessage(messages.resetFilter)} theme='secondary' onClick={handleReset} />
</HStack>
<Stack space={2}>
<Text size='md'>
{intl.formatMessage(messages.filterByWords)}
</Text>
<HStack space={6}>
<div className='relative w-full items-center p-0.5'>
<Input theme='search' value={word} className='h-9' onChange={handleOnChange} onKeyDown={onKeyDown} />
<div
tabIndex={0}
role='button'
className='absolute inset-y-0 right-0 flex cursor-pointer items-center px-3 rtl:left-0 rtl:right-auto'
>
<SvgIcon
src={searchIcon}
className={clsx('size-4 text-gray-600', { hidden: hasValue })}
/>
<SvgIcon
src={xIcon}
onClick={() => setWord('')}
aria-label={intl.formatMessage(messages.clearSearch)}
className={clsx('size-4 text-gray-600', { hidden: !hasValue })}
/>
</div>
</div>
<HStack alignItems='center' space={2}>
<Checkbox
name='negative'
checked={negative}
onChange={() => setNegative(!negative)}
/>
<Text size='md'>
{intl.formatMessage(messages.negative)}
</Text>
</HStack>
</HStack>
</Stack>
<HStack className='w-full p-0.5' space={2}>
<Button
className='w-1/2' theme='muted' onClick={handleClearValue}
>
{intl.formatMessage(messages.cancel)}
</Button>
<Button className='w-1/2' theme='secondary' onClick={handleAddFilter}>
{intl.formatMessage(messages.addFilter)}
</Button>
</HStack>
</Stack>
);
};
const MediaFilter = () => {
const intl = useIntl();
const { tokens, addTokens, removeTokens } = useSearchTokens();
const mediaFilters = {
all: {
tokens: [],
label: intl.formatMessage(messages.allMedia),
},
image: {
tokens: ['media:true', '-video:true'],
label: intl.formatMessage(messages.imageOnly),
},
video: {
tokens: ['video:true'],
label: intl.formatMessage(messages.videoOnly),
},
none: {
tokens: ['-media:true'],
label: intl.formatMessage(messages.noMedia),
},
};
const handleSelectChange: React.ChangeEventHandler<HTMLSelectElement> = e => {
const filter = e.target.value as keyof typeof mediaFilters;
removeTokens(['media:true', '-video:true', 'video:true', '-media:true']);
addTokens(mediaFilters[filter].tokens);
};
// FIXME: The `items` prop of `SelectDropdown` should become an array of objects.
const items = Object
.entries(mediaFilters)
.reduce((acc, [key, value]) => {
acc[key] = value.label;
return acc;
}, {} as Record<string, string>);
const currentFilter = Object
.entries(mediaFilters)
.find(([, f]) => f.tokens.every(token => tokens.has(token)))?.[0] || 'all';
return (
<HStack alignItems='center' space={2}>
<Text size='md' weight='bold'>
{intl.formatMessage(messages.media)}
</Text>
<SelectDropdown
className='max-w-[130px]'
items={items}
defaultValue={currentFilter}
onChange={handleSelectChange}
/>
</HStack>
);
};
const LanguageFilter = () => {
const intl = useIntl();
const { tokens, addToken, removeToken } = useSearchTokens();
const handleSelectChange: React.ChangeEventHandler<HTMLSelectElement> = e => {
const language = e.target.value;
for (const token in tokens) {
if (token.startsWith('language:')) {
removeToken(token);
}
}
addToken(`language:${language}`);
};
const token = [...tokens].find((token) => token.startsWith('language:'));
const [, language = 'default'] = token?.split(':') ?? [];
return (
<HStack alignItems='center' space={2}>
<Text size='md' weight='bold'>
{intl.formatMessage(messages.language)}
</Text>
<SelectDropdown
className='max-w-[130px]'
items={languages}
defaultValue={language}
onChange={handleSelectChange}
/>
</HStack>
);
};
const ToggleRepliesFilter = () => {
const intl = useIntl();
const { tokens, addToken, removeToken } = useSearchTokens();
const handleToggle = () => {
if (tokens.has('reply:false')) {
removeToken('reply:false');
} else {
addToken('reply:false');
}
};
return (
<HStack className='flex-wrap whitespace-normal' alignItems='center' space={2}>
<Text size='md' weight='bold'>
{intl.formatMessage(messages.showReplies)}
</Text>
<Toggle
checked={!tokens.has('reply:false')}
onChange={handleToggle}
/>
</HStack>
);
};
export { WordFilter, PlatformFilters, MediaFilter, LanguageFilter, ToggleRepliesFilter };

Wyświetl plik

@ -0,0 +1,153 @@
import arrowIcon from '@tabler/icons/outline/chevron-down.svg';
import { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { Swiper, SwiperSlide } from 'swiper/react';
import { useAccount } from 'soapbox/api/hooks/index.ts';
import { InstanceFavicon } from 'soapbox/components/instance-favicon.tsx';
import Avatar from 'soapbox/components/ui/avatar.tsx';
import HStack from 'soapbox/components/ui/hstack.tsx';
import IconButton from 'soapbox/components/ui/icon-button.tsx';
import Spinner from 'soapbox/components/ui/spinner.tsx';
import Stack from 'soapbox/components/ui/stack.tsx';
import Text from 'soapbox/components/ui/text.tsx';
import ActionButton from 'soapbox/features/ui/components/action-button.tsx';
import { useIsMobile } from 'soapbox/hooks/useIsMobile.ts';
import { useSoapboxConfig } from 'soapbox/hooks/useSoapboxConfig.ts';
import {
useSuggestions,
} from 'soapbox/queries/suggestions.ts';
import 'swiper/swiper-bundle.css';
const messages = defineMessages({
title: { id: 'column.explore.popular_accounts', defaultMessage: 'Popular Accounts' },
collapse: { id: 'column.explore.popular_accounts.collapse', defaultMessage: 'Collapse popular accounts' },
expand: { id: 'column.explore.popular_accounts.expand', defaultMessage: 'Expand popular accounts' },
error: { id: 'column.explore.popular_accounts.error', defaultMessage: 'Could not load popular accounts. Please try again later.' },
});
const PopularAccounts = ({ id }: { id: string }) => {
const { account } = useAccount(id);
const { logo } = useSoapboxConfig();
return (
<Stack className='rounded-lg' >
<Stack
justifyContent='between' className='h-72 min-w-44 rounded-lg border border-primary-300 shadow-card-inset'
style={{
backgroundImage: `url(${account?.header ?? logo})`,
backgroundSize: `${account?.header ? 'cover' : 'auto' }`,
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}
space={3}
>
{account && (<>
<HStack className='p-2'>
<HStack
alignItems='center' space={1} className='max-w-28 rounded-full border bg-primary-500 px-2 py-0.5 !text-white'
>
<InstanceFavicon account={account} />
<Text className='!text-white' size='xs' truncate>
{account.domain}
</Text>
</HStack>
</HStack>
<Stack alignItems='center' justifyContent='center' className='pb-6' space={2}>
<Link to={`/@${account.acct}`} className='flex flex-col items-center justify-center gap-2' >
<Avatar className='border border-white' src={account.avatar} size={60} />
<Text className='w-32 text-center text-white' truncate>
{account.display_name}
</Text>
</Link>
<ActionButton account={account} />
</Stack>
</>
)}
</Stack>
</Stack>
);
};
const AccountsCarousel = () => {
const intl = useIntl();
const isMobile = useIsMobile();
const { data: suggestions, isFetching, error } = useSuggestions();
const [isOpen, setIsOpen] = useState(true);
const handleClick = () => {
setIsOpen((prev) => {
const newValue = !prev;
localStorage.setItem('soapbox:explore:accounts:status', JSON.stringify(newValue));
return newValue;
});
};
useEffect(
() => {
const isOpenStatus = localStorage.getItem('soapbox:explore:accounts:status');
if (isOpenStatus) {
setIsOpen(JSON.parse(isOpenStatus));
}
}
, []);
if (isFetching) {
return <Stack><Spinner /></Stack>;
}
if (error) {
return (
<Stack space={4} className='px-4'>
<Text theme='muted'>{intl.formatMessage(messages.error)}</Text>
</Stack>
);
}
if (!suggestions || !suggestions.length) {
return null;
}
return (
<Stack space={4} className={`px-4 ${isOpen && 'pb-4'}`}>
<HStack alignItems='center' justifyContent='between'>
<Text size='xl' weight='bold'>
{intl.formatMessage(messages.title)}
</Text>
<IconButton
src={arrowIcon}
theme='transparent'
className={`transition-transform duration-300 ${ isOpen ? 'rotate-180' : 'rotate-0'}`}
onClick={handleClick}
aria-label={isOpen ? intl.formatMessage(messages.collapse) : intl.formatMessage(messages.expand)}
/>
</HStack>
<HStack className={`transition-all duration-500 ease-in-out ${isOpen ? 'max-h-[1000px] opacity-100' : 'hidden max-h-0 opacity-0'}`}>
<Swiper
spaceBetween={10}
slidesPerView={isMobile ? 2 : 3}
grabCursor
loop
className='w-full'
>
{suggestions.map((suggestion) => (
<SwiperSlide key={suggestion.account}>
<PopularAccounts id={suggestion.account} />
</SwiperSlide>
))}
</Swiper>
</HStack>
</Stack>
);
};
export default AccountsCarousel;

Wyświetl plik

@ -0,0 +1,167 @@
import globeIcon from '@tabler/icons/outline/globe.svg';
import trendIcon from '@tabler/icons/outline/trending-up.svg';
import userIcon from '@tabler/icons/outline/user.svg';
import { useEffect, useMemo, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Route, Switch, useLocation } from 'react-router-dom';
import { useNavigate } from 'react-router-dom-v5-compat';
import { clearSearch, setFilter } from 'soapbox/actions/search.ts';
import { Column } from 'soapbox/components/ui/column.tsx';
import Divider from 'soapbox/components/ui/divider.tsx';
import Stack from 'soapbox/components/ui/stack.tsx';
import Tabs from 'soapbox/components/ui/tabs.tsx';
import SearchResults from 'soapbox/features/compose/components/search-results.tsx';
import Search from 'soapbox/features/compose/components/search.tsx';
import ExploreCards from 'soapbox/features/explore/components/explore-cards.tsx';
import ExploreFilter from 'soapbox/features/explore/components/exploreFilter.tsx';
import AccountsCarousel from 'soapbox/features/explore/components/popular-accounts.tsx';
import { useSearchTokens } from 'soapbox/features/explore/useSearchTokens.ts';
import { PublicTimeline } from 'soapbox/features/ui/util/async-components.ts';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
import { SearchFilter } from 'soapbox/reducers/search.ts';
const messages = defineMessages({
heading: { id: 'column.explore', defaultMessage: 'Explore' },
accounts: { id: 'search_results.accounts', defaultMessage: 'Accounts' },
statuses: { id: 'search_results.posts', defaultMessage: 'Posts' },
trends: { id: 'search_results.trends', defaultMessage: 'Trends' },
filters: { id: 'column.explore.filters', defaultMessage: 'Filters:' },
});
const PostsTab = () => {
const intl = useIntl();
const features = useFeatures();
const { tokens } = useSearchTokens();
const { pathname } = useLocation();
return (
<Stack space={4}>
{pathname === '/explore' && (
<>
{features.nostr && (
<>
<ExploreCards />
<Divider text={intl.formatMessage(messages.filters)} />
<ExploreFilter />
<Divider />
</>
)}
{tokens.size ? <SearchResults /> : <PublicTimeline />}
</>
)}
</Stack>
);
};
const TrendsTab = () => {
return (
<Stack>
<SearchResults />
</Stack>
);
};
const AccountsTab = () => {
return (
<Stack space={4} className='pt-1'>
<AccountsCarousel />
<Divider />
<Stack space={3}>
<div className='px-4'>
<Search autoSubmit />
</div>
<SearchResults />
</Stack>
</Stack>
);
};
const ExplorePage = () => {
const features = useFeatures();
const intl = useIntl();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const path = useLocation().pathname;
const selectFilter = (newActiveFilter: SearchFilter) => dispatch(setFilter(newActiveFilter));
const selectedValue = useMemo(() => {
if (path === '/explore') return 'posts';
if (path === '/explore/trends') return 'statuses';
return 'accounts';
}, [path]);
useEffect(() => {
if (selectedValue === 'accounts') {
dispatch(setFilter('accounts'));
}
}, [selectedValue, dispatch]);
const [selectedFilter, setSelectedFilter] = useState(selectedValue);
const renderFilterBar = () => {
const items = [
{
text: intl.formatMessage(messages.statuses),
action: () => handleTabs(''),
name: 'posts',
icon: globeIcon,
},
...(features.nostr ? [{
text: intl.formatMessage(messages.trends),
action: () => handleTabs('/trends', 'statuses'),
name: 'statuses',
icon: trendIcon,
}] : []),
{
text: intl.formatMessage(messages.accounts),
action: () => handleTabs('/accounts', 'accounts'),
name: 'accounts',
icon: userIcon,
},
];
const handleTabs = (path: string, filter?: SearchFilter) => {
if (filter) {
selectFilter(filter);
dispatch(clearSearch());
} else {
selectFilter('statuses');
}
setSelectedFilter(filter ?? 'posts');
navigate(`/explore${path}`);
};
return <Tabs items={items} activeItem={selectedFilter} />;
};
return (
<Column label={intl.formatMessage(messages.heading)} withHeader={false} slim>
<Stack space={2}>
<div className='relative px-4'>
{renderFilterBar()}
</div>
<Switch>
<Route exact path='/explore' component={PostsTab} />
{features.nostr && <Route path='/explore/trends' component={TrendsTab} />}
<Route path='/explore/accounts' component={AccountsTab} />
</Switch>
</Stack>
</Column>
);
};
export default ExplorePage;

Wyświetl plik

@ -0,0 +1,67 @@
import { produce, enableMapSet } from 'immer';
import { create } from 'zustand';
enableMapSet();
interface SearchTokensState {
tokens: Set<string>;
addToken(token: string): void;
addTokens(tokens: string[]): void;
removeToken(token: string): void;
removeTokens(tokens: string[]): void;
clearTokens(): void;
}
export const useSearchTokens = create<SearchTokensState>()(
(setState) => ({
tokens: new Set(),
addToken(token: string): void {
if (token) {
setState((state) => {
return produce(state, (draft) => {
draft.tokens.add(token);
});
});
}
},
addTokens(tokens: string[]): void {
setState((state) => {
return produce(state, (draft) => {
for (const token of tokens) {
if (token) {
draft.tokens.add(token);
}
}
});
});
},
removeToken(token: string): void {
setState((state) => {
return produce(state, (draft) => {
draft.tokens.delete(token);
});
});
},
removeTokens(tokens: string[]): void {
setState((state) => {
return produce(state, (draft) => {
for (const token of tokens) {
draft.tokens.delete(token);
}
});
});
},
clearTokens(): void {
setState((state) => {
return produce(state, (draft) => {
draft.tokens.clear();
});
});
},
}),
);

Wyświetl plik

@ -120,6 +120,7 @@ interface ISelectDropdown {
className?: string;
label?: React.ReactNode;
hint?: React.ReactNode;
/** @deprecated FIXME: JavaScript does not guarantee key ordering of objects. This should be turned into an array of tuples. */
items: Record<string, string>;
defaultValue?: string;
onChange?: React.ChangeEventHandler;

Wyświetl plik

@ -13,7 +13,7 @@ import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
const messages = defineMessages({
search: { id: 'lists.search', defaultMessage: 'Search among people you follow' },
searchTitle: { id: 'tabs_bar.search', defaultMessage: 'Discover' },
searchTitle: { id: 'tabs_bar.search', defaultMessage: 'Explore' },
});
const Search = () => {

Wyświetl plik

@ -70,6 +70,7 @@ const PublicTimeline = () => {
return (
<Column
withHeader={false}
label={intl.formatMessage(messages.title)}
action={features.publicTimelineLanguage ? <LanguageDropdown language={language} setLanguage={setLanguage} /> : null}
slim
@ -77,7 +78,7 @@ const PublicTimeline = () => {
<PinnedHostsPicker />
{showExplanationBox && (
<div className='mb-4 black:mx-4'>
<div className='mb-4 p-2 black:mx-4'>
<Accordion
headline={<FormattedMessage id='fediverse_tab.explanation_box.title' defaultMessage='What is the Fediverse?' />}
action={dismissExplanationBox}

Wyświetl plik

@ -1,27 +0,0 @@
import { defineMessages, useIntl } from 'react-intl';
import { Column } from 'soapbox/components/ui/column.tsx';
import Stack from 'soapbox/components/ui/stack.tsx';
import SearchResults from 'soapbox/features/compose/components/search-results.tsx';
import Search from 'soapbox/features/compose/components/search.tsx';
const messages = defineMessages({
heading: { id: 'column.search', defaultMessage: 'Discover' },
});
const SearchPage = () => {
const intl = useIntl();
return (
<Column label={intl.formatMessage(messages.heading)} slim>
<Stack space={4}>
<div className='px-4'>
<Search autoSubmit />
</div>
<SearchResults />
</Stack>
</Column>
);
};
export default SearchPage;

Wyświetl plik

@ -38,7 +38,7 @@ const TrendsPanel = ({ limit }: ITrendsPanel) => {
<Widget
title={<FormattedMessage id='trends.title' defaultMessage='Trends' />}
action={
<Link className='text-right' to='/search' onClick={setHashtagsFilter}>
<Link className='text-right' to='/explore' onClick={setHashtagsFilter}>
<Text tag='span' theme='primary' size='sm' className='hover:underline'>
{intl.formatMessage(messages.viewAll)}
</Text>

Wyświetl plik

@ -39,7 +39,7 @@ import LandingPage from 'soapbox/pages/landing-page.tsx';
import ManageGroupsPage from 'soapbox/pages/manage-groups-page.tsx';
import ProfilePage from 'soapbox/pages/profile-page.tsx';
import RemoteInstancePage from 'soapbox/pages/remote-instance-page.tsx';
import SearchPage from 'soapbox/pages/search-page.tsx';
import ExplorePage from 'soapbox/pages/search-page.tsx';
import StatusPage from 'soapbox/pages/status-page.tsx';
import WidePage from 'soapbox/pages/wide-page.tsx';
@ -47,8 +47,8 @@ import FloatingActionButton from './components/floating-action-button.tsx';
import Navbar from './components/navbar.tsx';
import {
Status,
PublicTimeline,
RemoteTimeline,
PublicTimeline,
AccountTimeline,
AccountGallery,
HomeTimeline,
@ -67,7 +67,7 @@ import {
Filters,
EditFilter,
PinnedStatuses,
Search,
Explore,
ListTimeline,
Lists,
Bookmarks,
@ -208,11 +208,11 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
<Redirect from='/web/:path' to='/:path' />
<Redirect from='/timelines/home' to='/' />
<Redirect from='/timelines/public/local' to='/timeline/local' />
<Redirect from='/timelines/public' to='/timeline/global' />
<Redirect from='/timelines/public' to='/explore' />
<Redirect from='/timelines/direct' to='/messages' />
{/* Pleroma FE web routes */}
<Redirect from='/main/all' to='/timeline/global' />
<Redirect from='/main/all' to='/explore' />
<Redirect from='/main/public' to='/timeline/local' />
<Redirect from='/main/friends' to='/' />
<Redirect from='/tag/:id' to='/tags/:id' />
@ -250,7 +250,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
<Redirect from='/auth/mfa' to='/settings/mfa' />
<Redirect from='/auth/password/new' to='/reset-password' />
<Redirect from='/auth/password/edit' to={`/edit-password${search}`} />
<Redirect from='/timeline/fediverse' to='/timeline/global' />
<Redirect from='/timeline/fediverse' to='/explore' />
<WrappedRoute path='/tags/:id' publicRoute page={DefaultPage} component={HashtagTimeline} content={children} />
@ -260,7 +260,7 @@ const SwitchingColumnsArea: React.FC<ISwitchingColumnsArea> = ({ children }) =>
<WrappedRoute path='/notifications' page={DefaultPage} component={Notifications} content={children} />
<WrappedRoute path='/search' page={SearchPage} component={Search} content={children} publicRoute />
<WrappedRoute path='/explore' page={ExplorePage} component={Explore} content={children} publicRoute />
{features.suggestionsLocal && <WrappedRoute path='/suggestions/local' publicRoute page={DefaultPage} component={FollowRecommendations} content={children} componentParams={{ local: true }} />}
{features.suggestions && <WrappedRoute path='/suggestions' exact publicRoute page={DefaultPage} component={FollowRecommendations} content={children} />}
{features.profileDirectory && <WrappedRoute path='/directory' exact publicRoute page={DefaultPage} component={Directory} content={children} />}

Wyświetl plik

@ -57,7 +57,7 @@ export const BirthdaysModal = lazy(() => import('soapbox/features/ui/components/
export const BirthdayPanel = lazy(() => import('soapbox/components/birthday-panel.tsx'));
export const ListEditor = lazy(() => import('soapbox/features/list-editor/index.tsx'));
export const ListAdder = lazy(() => import('soapbox/features/list-adder/index.tsx'));
export const Search = lazy(() => import('soapbox/features/search/index.tsx'));
export const Explore = lazy(() => import('soapbox/features/explore/index.tsx'));
export const LoginPage = lazy(() => import('soapbox/features/auth-login/components/login-page.tsx'));
export const ExternalLogin = lazy(() => import('soapbox/features/external-login/index.tsx'));
export const LogoutPage = lazy(() => import('soapbox/features/auth-login/components/logout.tsx'));

3
src/global.d.ts vendored
Wyświetl plik

@ -1,5 +1,8 @@
import type { NostrSigner } from '@nostrify/nostrify';
declare module '*.css';
declare module 'swiper/css';
declare global {
interface Window {
nostr?: NostrSigner;

Wyświetl plik

@ -368,6 +368,35 @@
"column.event_map": "Event location",
"column.event_participants": "Event participants",
"column.events": "Events",
"column.explore": "Explore",
"column.explore.bridge": "Bridges",
"column.explore.filters": "Filters:",
"column.explore.filters.add_filter": "Add Filter",
"column.explore.filters.bluesky": "Bluesky",
"column.explore.filters.cancel": "Cancel",
"column.explore.filters.clear_input": "Clear filter input",
"column.explore.filters.create_your_filter": "Create your filter",
"column.explore.filters.empty": "Hey there... You forget to write the filter!",
"column.explore.filters.fediverse": "Fediverse",
"column.explore.filters.filter_by_words": "Filter word",
"column.explore.filters.invert": "Invert",
"column.explore.filters.language": "Language",
"column.explore.filters.media": "Media",
"column.explore.filters.nostr": "Nostr",
"column.explore.filters.platforms": "Platforms",
"column.explore.filters.remove_filter": "Remove filter",
"column.explore.filters.reset": "Reset Filters",
"column.explore.media_filters.all_media": "All media",
"column.explore.media_filters.image": "Image only",
"column.explore.media_filters.no_media": "No media",
"column.explore.media_filters.video": "Video only",
"column.explore.nostr": "Nostr",
"column.explore.popular_accounts": "Popular Accounts",
"column.explore.popular_accounts.collapse": "Collapse popular accounts",
"column.explore.popular_accounts.error": "Could not load popular accounts. Please try again later.",
"column.explore.popular_accounts.expand": "Expand popular accounts",
"column.explore.welcome_card.text": "Explore the world of decentralized social media, dive into {nostrLink} or cross {bridgeLink} to other networks, and connect with a global community. All in one place.",
"column.explore.welcome_card.title": "Welcome to Explore",
"column.export_data": "Export data",
"column.familiar_followers": "People you know following {name}",
"column.favourited_statuses": "Liked posts",
@ -430,7 +459,6 @@
"column.reblogs": "Reposts",
"column.registration": "Sign Up",
"column.scheduled_statuses": "Scheduled Posts",
"column.search": "Discover",
"column.settings_store": "Settings store",
"column.soapbox_config": "Soapbox config",
"column.test": "Test timeline",
@ -1122,7 +1150,7 @@
"navigation.direct_messages": "Messages",
"navigation.home": "Home",
"navigation.notifications": "Notifications",
"navigation.search": "Discover",
"navigation.search": "Explore",
"navigation_bar.account_aliases": "Account aliases",
"navigation_bar.account_migration": "Move account",
"navigation_bar.blocks": "Blocks",
@ -1421,10 +1449,10 @@
"scheduled_status.cancel": "Cancel",
"search.action": "Search for “{query}”",
"search.placeholder": "Search",
"search_results.accounts": "People",
"search_results.accounts": "Accounts",
"search_results.filter_message": "You are searching for posts from @{acct}.",
"search_results.hashtags": "Hashtags",
"search_results.statuses": "Posts",
"search_results.posts": "Posts",
"search_results.trends": "Trends",
"security.codes.fail": "Failed to fetch backup codes",
"security.confirm.fail": "Incorrect code or password. Try again.",
"security.delete_account.fail": "Account deletion failed.",
@ -1614,13 +1642,12 @@
"sw.url": "Script URL",
"tabs_bar.dashboard": "Dashboard",
"tabs_bar.follows": "Follows",
"tabs_bar.global": "Global",
"tabs_bar.groups": "Groups",
"tabs_bar.home": "Home",
"tabs_bar.more": "More",
"tabs_bar.notifications": "Notifications",
"tabs_bar.profile": "Profile",
"tabs_bar.search": "Discover",
"tabs_bar.search": "Explore",
"tabs_bar.settings": "Settings",
"textarea.counter.label": "{count} characters remaining",
"theme_editor.colors.accent": "Accent",

Wyświetl plik

@ -1,3 +1,5 @@
import { useLocation } from 'react-router-dom';
import Layout from 'soapbox/components/ui/layout.tsx';
import LinkFooter from 'soapbox/features/ui/components/link-footer.tsx';
import {
@ -5,18 +7,20 @@ import {
TrendsPanel,
SignUpPanel,
CtaBanner,
LatestAccountsPanel,
SuggestedGroupsPanel,
} from 'soapbox/features/ui/util/async-components.ts';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
interface ISearchPage {
interface IExplorePage {
children: React.ReactNode;
}
const SearchPage: React.FC<ISearchPage> = ({ children }) => {
const ExplorePage: React.FC<IExplorePage> = ({ children }) => {
const me = useAppSelector(state => state.me);
const features = useFeatures();
const accountsPath = useLocation().pathname === '/explore/accounts';
return (
<>
@ -37,8 +41,9 @@ const SearchPage: React.FC<ISearchPage> = ({ children }) => {
<TrendsPanel limit={5} />
)}
{features.suggestions && (
<WhoToFollowPanel limit={3} />
{features.suggestions && (accountsPath
? <LatestAccountsPanel limit={3} />
: <WhoToFollowPanel limit={3} />
)}
{features.groups && (
@ -51,4 +56,4 @@ const SearchPage: React.FC<ISearchPage> = ({ children }) => {
);
};
export default SearchPage;
export default ExplorePage;

Wyświetl plik

@ -22,6 +22,10 @@ const config: Config = {
boxShadow: ({ theme }) => ({
'3xl': '0 25px 75px -15px rgba(0, 0, 0, 0.25)',
'inset-ring': `inset 0 0 0 2px ${theme('colors.accent-blue')}`,
'card': `rgba(0, 0, 0, 0.35, 0.1) 0px 4px 16px,
rgba(0, 0, 0, 0.35, 0.1) 0px 8px 24px,
rgba(0, 0, 0, 0.35, 0.1) 0px 16px 56px`,
'card-inset': 'rgba(0, 0, 0, 0.60) 0px -120px 36px -28px inset',
}),
fontSize: {
base: '0.9375rem',

Wyświetl plik

@ -7989,6 +7989,11 @@ svgo@^3.0.2:
csso "^5.0.5"
picocolors "^1.0.0"
swiper@^11.2.5:
version "11.2.5"
resolved "https://registry.yarnpkg.com/swiper/-/swiper-11.2.5.tgz#8168b85ff858773e674823acb8962a1c5cdee689"
integrity sha512-nG0kbIyBfeE2BPFt9nPUX03qUBF75o6+enzjIT/DfCmbh8ORlwhc4eZz1+4H/yseAgb3H+OoEYzmb64i0tYNnQ==
symbol-tree@^3.2.4:
version "3.2.4"
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"