Rework the data layer

merge-requests/3337/head
Alex Gleason 2025-03-13 01:55:55 -05:00
rodzic 8ead53a2c9
commit 207b73b837
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 7211D1F99744FBB7
8 zmienionych plików z 267 dodań i 443 usunięć

Wyświetl plik

@ -4,7 +4,7 @@ import clsx from 'clsx';
import { debounce } from 'es-toolkit';
import { useCallback, useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useHistory, useLocation } from 'react-router-dom';
import { useHistory } from 'react-router-dom';
import {
changeSearch,
@ -17,7 +17,6 @@ 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 { formatFilters } from 'soapbox/features/explore/components/exploreFilter.tsx';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { selectAccount } from 'soapbox/selectors/index.ts';
@ -57,12 +56,9 @@ const Search = (props: ISearch) => {
const history = useHistory();
const intl = useIntl();
const [inputValue, setInputValue] = useState('');
const path = useLocation().pathname;
const value = useAppSelector((state) => state.search.value);
const submitted = useAppSelector((state) => state.search.submitted);
const filters = useAppSelector((state) => state.search_filter);
const formatFiltersString = formatFilters(filters);
const debouncedSubmit = useCallback(debounce(() => {
dispatch(submitSearch());
@ -71,11 +67,7 @@ const Search = (props: ISearch) => {
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const { value } = event.target;
if (formatFiltersString.length > 0 && path === '/explore') {
dispatch(changeSearch(`${formatFiltersString} ${value}`));
} else {
dispatch(changeSearch(value));
}
dispatch(changeSearch(value));
setInputValue(value);
if (autoSubmit) {

Wyświetl plik

@ -1,7 +1,7 @@
import arrowIcon from '@tabler/icons/outline/chevron-down.svg';
import { debounce } from 'es-toolkit';
import { useEffect, useMemo, useState } from 'react';
import { useIntl } from 'react-intl';
// import { useIntl } from 'react-intl';
import { changeSearch, submitSearch } from 'soapbox/actions/search.ts';
import Divider from 'soapbox/components/ui/divider.tsx';
@ -9,16 +9,14 @@ 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 {
CreateFilter,
WordFilter,
LanguageFilter,
MediaFilter,
PlatformFilters,
ToggleRepliesFilter,
generateFilter,
} from 'soapbox/features/explore/components/filters.tsx';
import { useSearchTokens } from 'soapbox/features/explore/useSearchTokens.ts';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { IFilters } from 'soapbox/reducers/search-filter.ts';
interface IGenerateFilter {
name: string;
@ -26,19 +24,10 @@ interface IGenerateFilter {
value: string;
}
export const formatFilters = (filters: IFilters[]): string => {
const language = filters[0].name.toLowerCase() !== 'default' ? filters[0].value : '';
const protocols = filters.slice(1, 4).filter((protocol) => !protocol.status).map((filter) => filter.value).join(' ');
const defaultFilters = filters.slice(4, 8).filter((x) => x.status).map((filter) => filter.value).join(' ');
const newFilters = filters.slice(8).map((searchFilter) => searchFilter.value).join(' ');
return [language, protocols, defaultFilters, newFilters].join(' ').trim();
};
const ExploreFilter = () => {
// const intl = useIntl();
const dispatch = useAppDispatch();
const filters = useAppSelector((state) => state.search_filter);
const intl = useIntl();
const { tokens } = useSearchTokens();
const [isOpen, setIsOpen] = useState(false);
const handleClick = () => {
@ -59,14 +48,13 @@ const ExploreFilter = () => {
useEffect(
() => {
const value = formatFilters(filters);
debouncedSearch(value);
debouncedSearch([...tokens].join(' '));
return () => {
debouncedSearch.cancel();
};
}, [filters, dispatch],
}, [tokens, dispatch],
);
useEffect(
@ -84,7 +72,7 @@ const ExploreFilter = () => {
{/* Filters */}
<HStack alignItems='start' justifyContent='between' space={1}>
<HStack className='flex-wrap whitespace-normal' alignItems='center'>
{filters.length > 0 && [...filters.slice(0, 8).filter((value) => value.status).map((value) => generateFilter(dispatch, intl, value)), ...filters.slice(8).map((value) => generateFilter(dispatch, intl, value))]}
{/* {tokens.size > 0 && [...filters.slice(0, 8).filter((value) => value.status).map((value) => generateFilter(dispatch, intl, value)), ...filters.slice(8).map((value) => generateFilter(dispatch, intl, value))]} */}
</HStack>
<IconButton
@ -112,7 +100,7 @@ const ExploreFilter = () => {
<Divider />
{/* Create your filter */}
<CreateFilter />
<WordFilter />
</Stack>
@ -120,6 +108,69 @@ const ExploreFilter = () => {
);
};
// const generateFilter = (dispatch: AppDispatch, intl: IntlShape, { name, value, status }: IGenerateFilter) => {
// let borderColor = '';
// let textColor = '';
// if (Object.keys(languages).some((lang) => value.includes('language:'))) {
// borderColor = 'border-gray-500';
// textColor = 'text-gray-500';
// } else {
// switch (value) {
// case 'reply:false':
// case 'media:true -video:true':
// case 'video:true':
// case '-media:true':
// borderColor = 'border-gray-500';
// textColor = 'text-gray-500';
// break;
// case 'protocol:nostr':
// borderColor = 'border-purple-500';
// textColor = 'text-purple-500';
// break;
// case 'protocol:atproto':
// borderColor = 'border-blue-500';
// textColor = 'text-blue-500';
// break;
// case 'protocol:activitypub':
// borderColor = 'border-indigo-500';
// textColor = 'text-indigo-500';
// break;
// default:
// borderColor = status ? 'border-green-500' : 'border-red-500';
// textColor = status ? 'text-green-500' : 'text-red-500';
// }
// }
// const handleChangeFilters = (e: React.MouseEvent<HTMLButtonElement>) => {
// e.stopPropagation();
// if (['protocol:nostr', 'protocol:atproto', 'protocol:activitypub'].includes(value)) {
// dispatch(selectProtocol(value));
// } else if (['reply:false', 'media:true -video:true', 'video:true', '-media:true'].includes(value)) {
// dispatch(changeStatus({ value: value, status: false }));
// } else if (value.includes('language:')) {
// dispatch(changeLanguage('default'));
// } else {
// dispatch(removeFilter(value));
// }
// };
// return (
// <div
// key={value}
// 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} `}
// >
// {name.toLowerCase() !== 'default' ? name : <FormattedMessage id='column.explore.filters.language.default' defaultMessage='Global' />}
// <IconButton
// iconClassName='!w-4' className={` !py-0 group-hover:block ${textColor}`} src={xIcon}
// onClick={handleChangeFilters}
// aria-label={intl.formatMessage(messages.removeFilter, { name })}
// />
// </div>
// );
// };
export default ExploreFilter;
export type { IGenerateFilter };

Wyświetl plik

@ -2,8 +2,8 @@ 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, { useEffect, useMemo, useState } from 'react';
import { FormattedMessage, IntlShape, defineMessages, useIntl } from 'react-intl';
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';
@ -14,29 +14,22 @@ 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 { IGenerateFilter } from 'soapbox/features/explore/components/exploreFilter.tsx';
import { useSearchTokens } from 'soapbox/features/explore/useSearchTokens.ts';
import { SelectDropdown } from 'soapbox/features/forms/index.tsx';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { changeStatus, changeLanguage, changeMedia, createFilter, removeFilter, selectProtocol, resetFilters } from 'soapbox/reducers/search-filter.ts';
import { AppDispatch, RootState } from 'soapbox/store.ts';
import toast from 'soapbox/toast.tsx';
const messages = defineMessages({
noReplies: { id: 'column.explore.filters.no_replies', defaultMessage: 'No Replies:' },
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:' },
platformsError: { id: 'column.explore.filters.platforms.error', defaultMessage: 'Protocol not found for: {name}' },
atLeast: { id: 'column.explore.filters.atLeast', defaultMessage: 'At least one platform must remain selected.' },
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 by this/these words' },
include: { id: 'column.explore.filters.include', defaultMessage: 'Include' },
exclude: { id: 'column.explore.filters.exclude', defaultMessage: 'Exclude' },
negative: { id: 'column.explore.filters.invert', defaultMessage: 'Invert' },
nostr: { id: 'column.explore.filters.nostr', defaultMessage: 'Nostr' },
bluesky: { id: 'column.explore.filters.bluesky', defaultMessage: 'Bluesky' },
fediverse: { id: 'column.explore.filters.fediverse', defaultMessage: 'Fediverse' },
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' },
all: { id: 'column.explore.media_filters.all', defaultMessage: 'All' },
@ -107,63 +100,43 @@ const languages = {
zh: '中文',
};
const PlatformFilters = () => {
const ProtocolCheckBox: React.FC<{ protocol: 'nostr' | 'atproto' | 'activitypub' }> = ({ protocol }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const filterList = useAppSelector((state: RootState) => state.search_filter);
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 isChecked = e.target.checked;
const matchingFilter = filterList.slice(1, 4).find(
(filter) => filter.name.toLowerCase() === e.target.name.toLowerCase(),
);
const { checked, name } = e.target;
const protocol = matchingFilter?.value;
const isLastChecked = filterList.slice(1, 4).filter((filter) => filter.status).length === 1;
const token = `-protocol:${name}`;
if (!isChecked && isLastChecked) {
toast.error(messages.atLeast);
return;
if (checked) {
removeToken(token);
} else {
addToken(token);
}
if (!protocol) {
console.error(intl.formatMessage(messages.platformsError, { name: e.target.name }));
return;
}
dispatch(selectProtocol(protocol));
};
const CustomCheckBox = ({ protocolN } : { protocolN: string }) => {
const filter = filterList.find((filter) => filter.name.toLowerCase() === protocolN);
const checked = filter?.status;
let message;
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>
);
};
switch (protocolN) {
case 'nostr':
message = messages.nostr;
break;
case 'bluesky':
message = messages.bluesky;
break;
default:
message = messages.fediverse;
}
return (
<HStack alignItems='center' space={2}>
<Checkbox
name={protocolN}
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}>
@ -171,39 +144,33 @@ const PlatformFilters = () => {
{intl.formatMessage(messages.platforms)}
</Text>
{/* Nostr */}
<CustomCheckBox protocolN='nostr' />
{/* Bluesky */}
<CustomCheckBox protocolN='bluesky' />
{/* Fediverse */}
<CustomCheckBox protocolN='fediverse' />
<ProtocolCheckBox protocol='nostr' />
<ProtocolCheckBox protocol='atproto' />
<ProtocolCheckBox protocol='activitypub' />
</HStack>
);
};
const CreateFilter = () => {
const WordFilter = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { addToken, clearTokens } = useSearchTokens();
const [inputValue, setInputValue] = useState('');
const [include, setInclude] = useState(true);
const hasValue = inputValue.length > 0;
const [word, setWord] = useState('');
const [negative, setNegative] = useState(false);
const hasValue = !!word;
const handleReset = () => {
dispatch(resetFilters());
clearTokens();
};
const handleClearValue = () => {
setInputValue('');
setWord('');
};
const handleAddFilter = () => {
if (inputValue.length > 0) {
dispatch(createFilter({ name: inputValue, status: include }));
if (word) {
addToken(`${negative ? '-' : ''}${word}`);
handleClearValue();
} else {
toast.error(intl.formatMessage(messages.empty));
@ -228,7 +195,7 @@ const CreateFilter = () => {
};
const handleOnChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
setWord(e.target.value);
};
return (
@ -247,10 +214,8 @@ const CreateFilter = () => {
</Text>
<HStack space={6}>
<div className='relative w-full items-center p-0.5'>
<Input theme='search' value={inputValue} className='h-9' onChange={handleOnChange} onKeyDown={onKeyDown} />
<Input theme='search' value={word} className='h-9' onChange={handleOnChange} onKeyDown={onKeyDown} />
<div
tabIndex={0}
role='button'
@ -263,43 +228,22 @@ const CreateFilter = () => {
<SvgIcon
src={xIcon}
onClick={() => setInputValue('')}
onClick={() => setWord('')}
aria-label={intl.formatMessage(messages.clearSearch)}
className={clsx('size-4 text-gray-600', { hidden: !hasValue })}
/>
</div>
</div>
{/* Include */}
<HStack alignItems='center' space={2}>
<Checkbox
name='include'
checked={include}
onChange={() => {
if (!include) {
setInclude(true);
}
}}
name='negative'
checked={negative}
onChange={() => setNegative(!negative)}
/>
<Text size='md'>
{intl.formatMessage(messages.include)}
</Text>
</HStack>
{/* Exclude */}
<HStack alignItems='center' space={2}>
<Checkbox
name='exclude'
checked={!include}
onChange={() => {
if (include) {
setInclude(false);
}
}}
/>
<Text size='md'>
{intl.formatMessage(messages.exclude)}
{intl.formatMessage(messages.negative)}
</Text>
</HStack>
</HStack>
@ -324,32 +268,45 @@ const CreateFilter = () => {
const MediaFilter = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const filters = useAppSelector((state) => state.search_filter.filter(filter => ['all', 'image only', 'video only', 'no media'].includes(filter.name.toLowerCase())));
const { tokens, addTokens, removeTokens } = useSearchTokens();
const mediaFilters = useMemo(() => ({
all: intl.formatMessage(messages.all),
image: intl.formatMessage(messages.imageOnly),
video: intl.formatMessage(messages.videoOnly),
none: intl.formatMessage(messages.none),
}), [intl]);
const [selectedMedia, setSelectedMedia] = useState<string>(mediaFilters.all);
useEffect(() => {
const newMediaValue = (Object.keys(mediaFilters) as Array<keyof typeof mediaFilters>)
.find((key) => mediaFilters[key] === filters.find((filter) => filter.status === true)?.name)
|| mediaFilters.all;
setSelectedMedia(newMediaValue);
}, [mediaFilters]);
const mediaFilters = {
all: {
tokens: [],
label: intl.formatMessage(messages.all),
},
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.none),
},
};
const handleSelectChange: React.ChangeEventHandler<HTMLSelectElement> = e => {
const filter = e.target.value;
dispatch(changeMedia(filter));
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'>
@ -357,10 +314,9 @@ const MediaFilter = () => {
</Text>
<SelectDropdown
key={selectedMedia}
className='max-w-[130px]'
items={mediaFilters}
defaultValue={selectedMedia}
items={items}
defaultValue={currentFilter}
onChange={handleSelectChange}
/>
</HStack>
@ -370,14 +326,23 @@ const MediaFilter = () => {
const LanguageFilter = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const filter = useAppSelector((state) => state.search_filter)[0];
const { tokens, addToken, removeToken } = useSearchTokens();
const handleSelectChange: React.ChangeEventHandler<HTMLSelectElement> = e => {
const language = e.target.value;
dispatch(changeLanguage(language));
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'>
@ -385,10 +350,9 @@ const LanguageFilter = () => {
</Text>
<SelectDropdown
key={filter?.value}
className='max-w-[130px]'
items={languages}
defaultValue={filter.name.toLowerCase()}
defaultValue={language}
onChange={handleSelectChange}
/>
</HStack>
@ -398,91 +362,28 @@ const LanguageFilter = () => {
const ToggleRepliesFilter = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const label = intl.formatMessage(messages.noReplies);
const filters = useAppSelector((state) => state.search_filter);
const repliesFilter = filters.find((filter) => filter.value.toLowerCase().includes('reply'));
const checked = repliesFilter?.status;
const { tokens, addToken, removeToken } = useSearchTokens();
const handleToggle = () => {
dispatch(changeStatus({ value: 'reply:false', status: !checked }));
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'>
{label}
{intl.formatMessage(messages.showReplies)}
</Text>
<Toggle
checked={checked}
checked={!tokens.has('reply:false')}
onChange={handleToggle}
/>
</HStack>
);
};
const generateFilter = (dispatch: AppDispatch, intl: IntlShape, { name, value, status }: IGenerateFilter) => {
let borderColor = '';
let textColor = '';
if (Object.keys(languages).some((lang) => value.includes('language:'))) {
borderColor = 'border-gray-500';
textColor = 'text-gray-500';
} else {
switch (value) {
case 'reply:false':
case 'media:true -video:true':
case 'video:true':
case '-media:true':
borderColor = 'border-gray-500';
textColor = 'text-gray-500';
break;
case 'protocol:nostr':
borderColor = 'border-purple-500';
textColor = 'text-purple-500';
break;
case 'protocol:atproto':
borderColor = 'border-blue-500';
textColor = 'text-blue-500';
break;
case 'protocol:activitypub':
borderColor = 'border-indigo-500';
textColor = 'text-indigo-500';
break;
default:
borderColor = status ? 'border-green-500' : 'border-red-500';
textColor = status ? 'text-green-500' : 'text-red-500';
}
}
const handleChangeFilters = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
if (['protocol:nostr', 'protocol:atproto', 'protocol:activitypub'].includes(value)) {
dispatch(selectProtocol(value));
} else if (['reply:false', 'media:true -video:true', 'video:true', '-media:true'].includes(value)) {
dispatch(changeStatus({ value: value, status: false }));
} else if (value.includes('language:')) {
dispatch(changeLanguage('default'));
} else {
dispatch(removeFilter(value));
}
};
return (
<div
key={value}
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} `}
>
{name.toLowerCase() !== 'default' ? name : <FormattedMessage id='column.explore.filters.language.default' defaultMessage='Global' />}
<IconButton
iconClassName='!w-4' className={` !py-0 group-hover:block ${textColor}`} src={xIcon}
onClick={handleChangeFilters}
aria-label={intl.formatMessage(messages.removeFilter, { name })}
/>
</div>
);
};
export { CreateFilter, PlatformFilters, MediaFilter, LanguageFilter, ToggleRepliesFilter, generateFilter };
export { WordFilter, PlatformFilters, MediaFilter, LanguageFilter, ToggleRepliesFilter };

Wyświetl plik

@ -16,11 +16,10 @@ 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 { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { useFeatures } from 'soapbox/hooks/useFeatures.ts';
import { IFilters, initialState as filterInitialState } from 'soapbox/reducers/search-filter.ts';
import { SearchFilter } from 'soapbox/reducers/search.ts';
const messages = defineMessages({
@ -31,45 +30,28 @@ const messages = defineMessages({
filters: { id: 'column.explore.filters', defaultMessage: 'Filters:' },
});
const checkFilters = (filters: IFilters[]) => {
return filters.length !== filterInitialState.length ||
!filters.every((filter, index) =>
filter.name === filterInitialState[index].name &&
filter.status === filterInitialState[index].status &&
filter.value === filterInitialState[index].value,
);
};
const PostsTab = () => {
const path = useLocation().pathname;
const intl = useIntl();
const inPosts = path === '/explore';
const filters = useAppSelector((state) => state.search_filter);
const isNostr = useFeatures().nostr;
const [withFilter, setWithFilter] = useState(checkFilters(filters));
useEffect(() => {
setWithFilter(checkFilters(filters));
}, [filters]);
const features = useFeatures();
const { tokens } = useSearchTokens();
const { pathname } = useLocation();
return (
<Stack space={4}>
{inPosts && <>
{pathname === '/explore' && (
<>
{features.nostr && (
<>
<ExploreCards />
<Divider text={intl.formatMessage(messages.filters)} />
<ExploreFilter />
<Divider />
</>
)}
{isNostr && <>
<ExploreCards />
<Divider text={intl.formatMessage(messages.filters)} />
<ExploreFilter />
<Divider />
</> }
{!withFilter ? <PublicTimeline /> : <SearchResults /> }
</>
}
{tokens.size ? <PublicTimeline /> : <SearchResults /> }
</>
)}
</Stack>
);

Wyświetl plik

@ -0,0 +1,63 @@
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 {
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) {
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

@ -41,7 +41,6 @@ import profile_hover_card from './profile-hover-card.ts';
import relationships from './relationships.ts';
import reports from './reports.ts';
import scheduled_statuses from './scheduled-statuses.ts';
import search_filter from './search-filter.ts';
import search from './search.ts';
import security from './security.ts';
import settings from './settings.ts';
@ -99,7 +98,6 @@ export default combineReducers({
reports,
scheduled_statuses,
search,
search_filter,
security,
settings,
sidebar,

Wyświetl plik

@ -1,164 +0,0 @@
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
interface IFilters {
name: string;
status: boolean;
value: string;
}
interface IToggle {
value: string;
status: boolean;
}
interface INewFilter {
name: string;
status: boolean;
}
const initialState: IFilters[] = [
{ name: 'default', status: false, value: 'language:default' },
{ name: 'Nostr', status: true, value: 'protocol:nostr' },
{ name: 'Bluesky', status: true, value: 'protocol:atproto' },
{ name: 'Fediverse', status: true, value: 'protocol:activitypub' },
{ name: 'No Replies', status: false, value: 'reply:false' },
{ name: 'Video only', status: false, value: 'video:true' },
{ name: 'Image only', status: false, value: 'media:true -video:true' },
{ name: 'No media', status: false, value: '-media:true' },
];
const search_filter = createSlice({
name: 'search_filter',
initialState,
reducers: {
/**
* Toggles the status of reply filter.
*/
changeStatus: (state, action: PayloadAction<IToggle>) => {
return state.map((currentState) => {
const status = action.payload.status;
const value = action.payload.value;
return currentState.value === value
? {
...currentState,
status: status,
}
: currentState;
});
},
/**
* Changes the media filter.
*/
changeMedia: (state, action: PayloadAction<string>) => {
const selected = action.payload.toLowerCase();
const resetMediaState = state.map((currentFilter, index) => {
return index > 3 && index <= 7
? {
...currentFilter,
status: false,
}
: currentFilter;
});
const applyMediaFilter = (searchFilter: string) => {
return resetMediaState.map((currentState) =>
currentState.name.toLowerCase().includes(searchFilter)
? {
...currentState,
status: true,
}
: currentState,
);
};
switch (selected) {
case 'text':
case 'video':
case 'image':
return applyMediaFilter(`${selected} only`);
case 'none':
return applyMediaFilter('no media');
default:
return resetMediaState;
}
},
/**
* Changes the language filter.
*/
changeLanguage: (state, action: PayloadAction<string>) => {
const selected = action.payload.toLowerCase();
const isDefault = selected === 'default';
return state.map((currentState) =>
currentState.value.includes('language:')
? {
name: isDefault ? selected : selected.toUpperCase(),
status: !isDefault,
value: `language:${selected}`,
}
: currentState,
);
},
/**
* Toggles the status of a protocol filter.
*/
selectProtocol: (state, action: PayloadAction<string>) => {
const protocol = action.payload;
return state.map((currentState) => {
if (currentState.value.toLowerCase() !== protocol) return currentState;
const newStatus = !currentState.status;
let newValue = currentState.value;
if (newStatus) {
newValue = currentState.value.startsWith('-')
? currentState.value.slice(1)
: currentState.value;
} else {
newValue = currentState.value.startsWith('-')
? currentState.value
: `-${currentState.value}`;
}
return {
...currentState,
status: newStatus,
value: newValue,
};
});
},
/**
* Creates a new filter.
*/
createFilter: (state, action: PayloadAction<INewFilter>) => {
const filterWords = action.payload.name.trim();
const status = action.payload.status;
const value = status ? filterWords : `-${filterWords.split(' ').join(' -')}`;
if (state.slice(7).some((currentState) => currentState.name === filterWords)) {
return state;
}
return [...state, { name: filterWords, status, value }];
},
/**
* Removes a filter.
*/
removeFilter: (state, action: PayloadAction<string>) => {
return state.filter((filter) => filter.value !== action.payload);
},
/**
* Resets the filters to the initial state.
*/
resetFilters: () => initialState,
},
});
export type { IFilters };
export { initialState };
export const { changeStatus, changeMedia, changeLanguage, selectProtocol, createFilter, removeFilter, resetFilters } = search_filter.actions;
export default search_filter.reducer;