kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
Rework the data layer
rodzic
8ead53a2c9
commit
207b73b837
|
@ -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) {
|
||||
|
|
|
@ -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 };
|
|
@ -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 };
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
Ładowanie…
Reference in New Issue