Complete search_filter reducer implementation and refactor code

merge-requests/3337/head
danidfra 2025-02-25 18:34:06 -03:00
rodzic 781aaefbe1
commit bba853893d
5 zmienionych plików z 236 dodań i 200 usunięć

Wyświetl plik

@ -11,57 +11,43 @@ import Text from 'soapbox/components/ui/text.tsx';
import {
CreateFilter,
LanguageFilter,
MediaFilter,
PlatformFilters,
RepliesFilter,
ToggleFilter,
generateFilter,
} from 'soapbox/features/explorer/components/filters.tsx';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
const messages = defineMessages({
filters: { id: 'column.explorer.filters', defaultMessage: 'Filters:' },
showReplies: { id: 'column.explorer.filters.show_replies', defaultMessage: 'Show replies:' },
language: { id: 'column.explorer.filters.language', defaultMessage: 'Language:' },
platforms: { id: 'column.explorer.filters.platforms', defaultMessage: 'Platforms:' },
createYourFilter: { id: 'column.explorer.filters.create_your_filter', defaultMessage: 'Create your filter' },
filterByWords: { id: 'column.explorer.filters.filter_by_words', defaultMessage: 'Filter by this/these words' },
include: { id: 'column.explorer.filters.include', defaultMessage: 'Include' },
exclude: { id: 'column.explorer.filters.exclude', defaultMessage: 'Exclude' },
nostr: { id: 'column.explorer.filters.nostr', defaultMessage: 'Nostr' },
bluesky: { id: 'column.explorer.filters.bluesky', defaultMessage: 'Bluesky' },
fediverse: { id: 'column.explorer.filters.fediverse', defaultMessage: 'Fediverse' },
cancel: { id: 'column.explorer.filters.cancel', defaultMessage: 'Cancel' },
addFilter: { id: 'column.explorer.filters.add_filter', defaultMessage: 'Add Filter' },
});
interface IGenerateFilter {
name: string;
state: boolean | null;
status: boolean | null;
value: string;
}
const ExplorerFilter = () => {
const dispatch = useAppDispatch();
const filters = useAppSelector((state) => state.search_filter);
const intl = useIntl();
const [isOpen, setIsOpen] = useState(false);
const [tagFilters, setTagFilters] = useState<IGenerateFilter[]>([
{ 'name': 'Nostr', state: null, 'value': 'protocol:nostr' },
{ 'name': 'Bluesky', state: null, 'value': 'protocol:atproto' },
{ 'name': 'Fediverse', state: null, 'value': 'protocol:activitypub' },
]);
const [isOpen, setIsOpen] = useState(true);
useEffect(
() => {
const value = tagFilters
.filter((searchFilter) => !searchFilter.value.startsWith('protocol:'))
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, 7).filter((x) => x.status).map((filter) => filter.value).join(' ');
const newFilters = filters.slice(7)
.map((searchFilter) => searchFilter.value)
.join(' ');
const value = [ language, protocols, defaultFilters, newFilters ].join(' ');
dispatch(changeSearch(value));
dispatch(submitSearch(undefined, value));
}, [tagFilters, dispatch],
}, [filters, dispatch],
);
return (
@ -74,7 +60,7 @@ const ExplorerFilter = () => {
{intl.formatMessage(messages.filters)}
</Text>
{tagFilters.length > 0 && [...tagFilters.slice(0, 3).filter((x)=> x.value[0] !== '-' && x.state === null).map((value) => generateFilter(value, setTagFilters)), ...tagFilters.slice(3).map((value) => generateFilter(value, setTagFilters))]}
{filters.length > 0 && [...filters.slice(0, 7).filter((value) => value.status).map((value) => generateFilter(dispatch, value)), ...filters.slice(7).map((value) => generateFilter(dispatch, value))]}
</HStack>
<IconButton
@ -88,21 +74,24 @@ const ExplorerFilter = () => {
<Stack className={`overflow-hidden transition-all duration-500 ease-in-out ${isOpen ? 'max-h-[1000px] opacity-100' : 'max-h-0 opacity-0'}`} space={3}>
{/* Show Reply toggle */}
<RepliesFilter onChangeFilters={setTagFilters} />
<ToggleFilter type='reply' />
{/* Media toggle */}
<MediaFilter onChangeFilters={setTagFilters} />
<ToggleFilter type='media' />
{/* Video toggle */}
<ToggleFilter type='video' />
{/* Language */}
<LanguageFilter onChangeFilters={setTagFilters} />
<LanguageFilter />
{/* Platforms */}
<PlatformFilters onChangeFilters={setTagFilters} filters={tagFilters} />
<PlatformFilters />
<Divider />
{/* Create your filter */}
<CreateFilter onChangeFilters={setTagFilters} />
<CreateFilter />
</Stack>

Wyświetl plik

@ -15,11 +15,16 @@ import Text from 'soapbox/components/ui/text.tsx';
import Toggle from 'soapbox/components/ui/toggle.tsx';
import { IGenerateFilter } from 'soapbox/features/explorer/components/explorerFilter.tsx';
import { SelectDropdown } from 'soapbox/features/forms/index.tsx';
import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts';
import { useAppSelector } from 'soapbox/hooks/useAppSelector.ts';
import { changeLanguage, createFilter, handleToggle, removeFilter, selectProtocol } from 'soapbox/reducers/search-filter.ts';
import { AppDispatch, RootState } from 'soapbox/store.ts';
import toast from 'soapbox/toast.tsx';
const messages = defineMessages({
filters: { id: 'column.explorer.filters', defaultMessage: 'Filters:' },
showReplies: { id: 'column.explorer.filters.show_replies', defaultMessage: 'Show replies:' },
showMedia: { id: 'column.explorer.filters.show_text_posts', defaultMessage: 'Just text posts:' },
showVideo: { id: 'column.explorer.filters.show_video_posts', defaultMessage: 'Just posts with video:' },
language: { id: 'column.explorer.filters.language', defaultMessage: 'Language:' },
platforms: { id: 'column.explorer.filters.platforms', defaultMessage: 'Platforms:' },
createYourFilter: { id: 'column.explorer.filters.create_your_filter', defaultMessage: 'Create your filter' },
@ -93,36 +98,46 @@ const languages = {
zh: '中文',
};
interface IFilter {
onChangeFilters: React.Dispatch<React.SetStateAction<IGenerateFilter[]>>;
}
interface IPlatformFilters {
filters: IGenerateFilter[];
onChangeFilters: React.Dispatch<React.SetStateAction<IGenerateFilter[]>>;
}
const PlatformFilters = ({ onChangeFilters, filters }: IPlatformFilters) => {
const PlatformFilters = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const filterList = useAppSelector((state: RootState) => state.search_filter);
const toggleProtocolFilter = (protocolName: string, protocolValue: string) => {
onChangeFilters(prevFilters => {
const handleProtocolFilter = (e: React.ChangeEvent<HTMLInputElement>) => {
const protocol = e.target.name;
const exists = prevFilters.some(tag => tag.name.toLowerCase() === protocolName.toLowerCase() && tag.value[0] !== '-');
const newFilterList = prevFilters.filter(tag => tag.name.toLowerCase() !== protocolName.toLowerCase());
dispatch(selectProtocol(protocol));
};
const newFilter = {
name: protocolName,
state: null,
value: exists ? `-protocol:${protocolValue}` : `protocol:${protocolValue}`,
};
const CheckBox = ({ protocolN } : { protocolN: string }) => {
const filter = filterList.find((filter) => filter.name.toLowerCase() === protocolN);
const checked = filter?.status;
if (newFilterList.length === 0) {
return [newFilter];
}
let message;
switch (protocolN) {
case 'nostr':
message = messages.nostr;
break;
case 'bluesky':
message = messages.bluesky;
break;
default:
message = messages.fediverse;
}
return [newFilterList[0], newFilter, ...newFilterList.slice(1)];
});
return (
<HStack alignItems='center' space={2}>
<Checkbox
name={protocolN}
checked={checked}
onChange={handleProtocolFilter}
/>
<Text size='lg'>
{intl.formatMessage(message)}
</Text>
</HStack>
);
};
return (
@ -132,56 +147,33 @@ const PlatformFilters = ({ onChangeFilters, filters }: IPlatformFilters) => {
</Text>
{/* Nostr */}
<HStack alignItems='center' space={2}>
<Checkbox
name='nostr'
checked={filters.some(tag => tag.name.toLowerCase() === 'nostr' && tag.value[0] !== '-')}
onChange={() => toggleProtocolFilter('Nostr', 'nostr')}
/>
<Text size='lg'>
{intl.formatMessage(messages.nostr)}
</Text>
</HStack>
<CheckBox protocolN={'nostr'} />
{/* Bluesky */}
<HStack alignItems='center' space={2}>
<Checkbox
name='bluesky'
checked={filters.some(tag => tag.name.toLowerCase() === 'bluesky' && tag.value[0] !== '-')}
onChange={() => toggleProtocolFilter('Bluesky', 'atproto')}
/>
<Text size='lg'>
{intl.formatMessage(messages.bluesky)}
</Text>
</HStack>
<CheckBox protocolN={'bluesky'} />
{/* Fediverse */}
<HStack alignItems='center' space={2}>
<Checkbox
name='fediverse'
checked={filters.some(tag => tag.name.toLowerCase() === 'fediverse' && tag.value[0] !== '-')}
onChange={() => toggleProtocolFilter('Fediverse', 'activitypub')}
/>
<Text size='lg'>
{intl.formatMessage(messages.fediverse)}
</Text>
</HStack>
<CheckBox protocolN={'fediverse'} />
</HStack>
);
};
const CreateFilter = ({ onChangeFilters }: IFilter) => {
const CreateFilter = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const [inputValue, setInputValue] = useState('');
const [include, setInclude] = useState('');
const [include, setInclude] = useState(true);
const hasValue = inputValue.length > 0;
const handleAddFilter = () => {
onChangeFilters((prev) => {
return [...prev, { name: inputValue, state: include === '', value: `${include}${inputValue.split(' ').join(` ${include}`)}` }];
});
if (inputValue.length > 0) {
dispatch(createFilter({ name: inputValue, status: include }));
} else {
toast.error('Hey there... you forget to write the filter!');
}
};
return (
@ -223,9 +215,9 @@ const CreateFilter = ({ onChangeFilters }: IFilter) => {
<HStack alignItems='center' space={2}>
<Checkbox
name='include'
checked={!(include.length > 0)}
checked={include}
onChange={() => {
setInclude('');
setInclude(true);
}}
/>
<Text size='lg'>
@ -237,9 +229,9 @@ const CreateFilter = ({ onChangeFilters }: IFilter) => {
<HStack alignItems='center' space={2}>
<Checkbox
name='exclude'
checked={(include.length > 0)}
checked={!include}
onChange={() => {
setInclude('-');
setInclude(false);
}}
/>
<Text size='lg'>
@ -252,7 +244,7 @@ const CreateFilter = ({ onChangeFilters }: IFilter) => {
<HStack className='w-full p-0.5' space={2}>
<Button
className='w-1/2' theme='secondary' onClick={() => {
setInclude('');
setInclude(false);
setInputValue('');
}
}
@ -270,19 +262,13 @@ const CreateFilter = ({ onChangeFilters }: IFilter) => {
};
const LanguageFilter = ({ onChangeFilters }: IFilter) => {
const LanguageFilter = () => {
const intl = useIntl();
const dispatch = useAppDispatch();
const handleSelectChange: React.ChangeEventHandler<HTMLSelectElement> = e => {
const value = e.target.value;
if (value.toLowerCase() === 'default') {
onChangeFilters((prevValue) => prevValue.filter((value) => !value.value.includes('language:')));
} else {
onChangeFilters((prevValue) => {
return [{ name: value.toUpperCase(), state: null, value: `language:${value}` }, ...prevValue.filter((value) => !value.value.includes('language:'))];
});
}
const language = e.target.value;
dispatch(changeLanguage(language));
};
return (
@ -302,105 +288,97 @@ const LanguageFilter = ({ onChangeFilters }: IFilter) => {
};
const RepliesFilter = ({ onChangeFilters }: IFilter) => {
const ToggleFilter = ({ type }: {type: 'reply' | 'media' | 'video'}) => {
const intl = useIntl();
const [showReplies, setShowReplies] = useState(false);
const dispatch = useAppDispatch();
const filterType = type.toLowerCase();
let label;
const handleToggleReplies: React.ChangeEventHandler<HTMLInputElement> = (e) => {
setShowReplies(!showReplies);
const isOn = e.target.checked;
switch (type) {
case 'reply':
label = intl.formatMessage(messages.showReplies);
break;
case 'media':
label = intl.formatMessage(messages.showMedia);
break;
default:
label = intl.formatMessage(messages.showVideo);
}
if (isOn) {
onChangeFilters((prevValue) => [...prevValue.filter((prev) => prev.name.toLowerCase() !== 'reply'), { name: 'Reply', state: null, value: 'reply:true' }]);
} else {
onChangeFilters((prevValue) => [...prevValue.filter((prev) => prev.name.toLowerCase() !== 'reply')]);
}
const filters = useAppSelector((state) => state.search_filter);
const repliesFilter = filters.find((filter) => filter.name.toLowerCase() === filterType);
const checked = repliesFilter?.status;
const handleToggleComponent = () => {
dispatch(handleToggle({ type: filterType, checked: !checked }));
};
return (
<HStack className='flex-wrap whitespace-normal' alignItems='center' space={2}>
<Text size='lg' weight='bold'>
{intl.formatMessage(messages.showReplies)}
{label}
</Text>
<Toggle
checked={showReplies}
onChange={handleToggleReplies}
checked={checked}
onChange={handleToggleComponent}
/>
</HStack>
);
};
const MediaFilter = ({ onChangeFilters }: IFilter) => {
const intl = useIntl();
const [showMedia, setShowMedia] = useState(false);
const handleToggleReplies: React.ChangeEventHandler<HTMLInputElement> = (e) => {
setShowMedia(!showMedia);
const isOn = e.target.checked;
if (isOn) {
onChangeFilters((prevValue) => [...prevValue.filter((prev) => prev.name.toLowerCase() !== 'text'), { name: 'Text', state: null, value: 'media:false' }]);
} else {
onChangeFilters((prevValue) => [...prevValue.filter((prev) => prev.name.toLowerCase() !== 'text')]);
}
};
return (
<HStack className='flex-wrap whitespace-normal' alignItems='center' space={2}>
<Text size='lg' weight='bold'>
{intl.formatMessage(messages.showMedia)}
</Text>
<Toggle
checked={showMedia}
onChange={handleToggleReplies}
/>
</HStack>
);
};
const generateFilter = ({ name, state }: IGenerateFilter, onChangeFilters: React.Dispatch<React.SetStateAction<IGenerateFilter[]>>) => {
const generateFilter = (dispatch: AppDispatch, { name, status }: IGenerateFilter) => {
let borderColor = '';
let textColor = '';
let hasButton = false;
switch (name.toLowerCase()) {
case 'nostr':
borderColor = 'border-purple-500';
textColor = 'text-purple-500';
break;
case 'bluesky':
borderColor = 'border-blue-500';
textColor = 'text-blue-500';
break;
case 'fediverse':
borderColor = 'border-indigo-500';
textColor = 'text-indigo-500';
break;
default:
if (name.toLowerCase() === 'reply' || name.toLowerCase() === 'text' || Object.keys(languages).some((lang) => lang === name.toLowerCase())) {
borderColor = 'border-grey-500';
textColor = 'text-grey-500';
const nameLowCase = name.toLowerCase();
const handleChangeFilters = () => {
dispatch(removeFilter(name));
};
if (Object.keys(languages).some((lang) => lang.toLowerCase() === nameLowCase)) {
borderColor = 'border-gray-500';
textColor = 'text-gray-500';
} else {
switch (nameLowCase) {
case 'reply':
case 'media':
case 'video':
borderColor = 'border-gray-500';
textColor = 'text-gray-500';
break;
}
borderColor = state ? 'border-green-500' : 'border-red-500';
textColor = state ? 'text-green-500' : 'text-red-500';
hasButton = true;
case 'nostr':
borderColor = 'border-purple-500';
textColor = 'text-purple-500';
break;
case 'bluesky':
borderColor = 'border-blue-500';
textColor = 'text-blue-500';
break;
case 'fediverse':
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';
hasButton = true;
}
}
return (
<div
key={name}
className={`group m-1 flex items-center gap-0.5 whitespace-normal break-words rounded-full border-2 bg-transparent px-3 text-base font-medium shadow-sm hover:cursor-pointer ${hasButton ? 'hover:pr-1' : '' } ${borderColor} `}
className={`group m-1 flex items-center gap-0.5 whitespace-normal break-words rounded-full border-2 bg-transparent px-3 text-base font-medium shadow-sm hover:cursor-pointer ${hasButton ? 'hover:pr-1' : '' } ${borderColor} ${textColor} `}
>
{name}
{hasButton && <IconButton
iconClassName='!w-4' className={`hidden !p-0 px-1 group-hover:block ${textColor}`} src={xIcon} onClick={() => onChangeFilters((prevValue) => {
return prevValue.filter((x) => x.name !== name);
})}
iconClassName='!w-4' className={`hidden !p-0 px-1 group-hover:block ${textColor}`} src={xIcon}
onClick={handleChangeFilters}
/>}
</div>
);
};
export { CreateFilter, PlatformFilters, LanguageFilter, RepliesFilter, MediaFilter, generateFilter };
export { CreateFilter, PlatformFilters, LanguageFilter, ToggleFilter, generateFilter };

Wyświetl plik

@ -385,6 +385,7 @@
"column.explorer.filters.platforms": "Platforms:",
"column.explorer.filters.show_replies": "Show replies:",
"column.explorer.filters.show_text_posts": "Just text posts:",
"column.explorer.filters.show_video_posts": "Just posts with video:",
"column.explorer.nostr_card.text": "Wondering about Nostr? <a>Click here</a>",
"column.explorer.nostr_card.title": "Nostr",
"column.explorer.popular_accounts": "Popular Accounts",

Wyświetl plik

@ -41,6 +41,7 @@ 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';
@ -98,6 +99,7 @@ export default combineReducers({
reports,
scheduled_statuses,
search,
search_filter,
security,
settings,
sidebar,

Wyświetl plik

@ -1,43 +1,109 @@
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
interface IFilters {
name: string;
state: boolean;
value: string;
name: string; // The name of the filter.
status: boolean; // Whether the filter is active or not.
value: string; // The filter value used for searching.
}
interface IToggle {
type: string; // The filter type to toggle.
checked: boolean; // The new status of the filter.
}
interface INewFilter {
name: string; // The name of the new filter.
status: boolean; // Whether the filter should be active by default.
}
const initialState: IFilters[] = [
{ name: 'Nostr', state: true, value: 'protocol:nostr' },
{ name: 'Bluesky', state: true, value: 'protocol:atproto' },
{ name: 'Fediverse', state: true, value: 'protocol:activitypub' },
{ name: 'Global', state: false, value: 'language' },
{ name: 'Reply', state: false, value: 'reply:true' },
{ name: 'Media', state: false, value: 'media:true' },
{ name: 'Video', state: false, value: 'video:true' },
{ 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: 'Reply', status: false, value: 'reply:true' },
{ name: 'Media', status: false, value: 'media:true' },
{ name: 'Video', status: false, value: 'video:true' },
];
const search_filter = createSlice({
name: 'search_filter',
initialState,
reducers: {
handleToggleReplies: (state, action: PayloadAction<boolean>) => {
return state.map((prevEstate) => {
const checked = action.payload;
return prevEstate.name.toLowerCase() === 'reply'
?
{
...prevEstate,
state: checked,
value: `reply:${checked}`,
/**
* Toggles the status of a filter.
*/
handleToggle: (state, action: PayloadAction<IToggle>) => {
return state.map((currentState) => {
const checked = action.payload.checked;
const type = action.payload.type.toLowerCase();
return currentState.name.toLowerCase() === type
? {
...currentState,
status: checked,
value: `${type}:${checked}`,
}
:
prevEstate;
},
: currentState;
});
},
/**
* Changes the language filter.
*/
changeLanguage: (state, action: PayloadAction<string>) => {
const selected = action.payload.toLowerCase();
return state.map((currentState) =>
currentState.value.includes('language:')
? {
name: selected.toUpperCase(),
status: selected !== 'default',
value: `language:${selected}`,
}
: currentState,
);
},
/**
* Toggles the status of a protocol filter.
*/
selectProtocol: (state, action: PayloadAction<string>) => {
const protocol = action.payload.toLowerCase();
return state.map((currentState) => {
const newStatus = !currentState.status;
if (currentState.name.toLowerCase() !== protocol) return currentState;
return {
...currentState,
status: newStatus,
value: newStatus ? currentState.value.slice(1) : `-${currentState.value}`,
};
});
},
/**
* 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(' -')}`;
return state.some((currentState) => currentState.name === filterWords)
? state
: [...state, { name: filterWords, status: status, value: value }];
},
/**
* Removes a filter.
*/
removeFilter: (state, action: PayloadAction<string>) => {
return state.filter((filter) => filter.name !== action.payload);
},
/**
* Resets the filters to the initial state.
*/
resetFilters: () => initialState,
},
});
export const { handleToggleReplies, resetFilters } = search_filter.actions;
export const { handleToggle, changeLanguage, selectProtocol, createFilter, removeFilter, resetFilters } = search_filter.actions;
export default search_filter.reducer;