diff --git a/src/features/feed/PostCommentFeed.tsx b/src/features/feed/PostCommentFeed.tsx index e5ebb7cc81..78213382e5 100644 --- a/src/features/feed/PostCommentFeed.tsx +++ b/src/features/feed/PostCommentFeed.tsx @@ -9,6 +9,7 @@ import { receivedComments } from "../comment/commentSlice"; import Post from "../post/inFeed/Post"; import CommentHr from "../comment/CommentHr"; import { FeedContext } from "./FeedContext"; +import { postHasFilteredKeywords } from "../../helpers/lemmy"; const thickBorderCss = css` border-bottom: 8px solid var(--thick-separator-color); @@ -28,12 +29,14 @@ interface PostCommentFeed extends Omit, "renderItemContent"> { communityName?: string; filterHiddenPosts?: boolean; + filterKeywords?: boolean; } export default function PostCommentFeed({ communityName, fetchFn: _fetchFn, filterHiddenPosts = true, + filterKeywords = true, ...rest }: PostCommentFeed) { const dispatch = useAppDispatch(); @@ -41,6 +44,9 @@ export default function PostCommentFeed({ (state) => state.settings.appearance.posts.type, ); const postHiddenById = useAppSelector(postHiddenByIdSelector); + const filteredKeywords = useAppSelector( + (state) => state.settings.blocks.keywords, + ); const itemsRef = useRef(); @@ -107,8 +113,13 @@ export default function PostCommentFeed({ ); const filterFn = useCallback( - (item: PostCommentItem) => !postHiddenById[item.post.id], - [postHiddenById], + (item: PostCommentItem) => + !postHiddenById[item.post.id] && + !postHasFilteredKeywords( + item.post, + filterKeywords ? filteredKeywords : [], + ), + [postHiddenById, filteredKeywords, filterKeywords], ); const getIndex = useCallback( diff --git a/src/features/settings/blocks/FilteredKeywords.tsx b/src/features/settings/blocks/FilteredKeywords.tsx new file mode 100644 index 0000000000..f2a4b29886 --- /dev/null +++ b/src/features/settings/blocks/FilteredKeywords.tsx @@ -0,0 +1,82 @@ +import { + IonItemOption, + IonItemOptions, + IonItemSliding, + IonLabel, + IonList, + useIonAlert, +} from "@ionic/react"; +import { InsetIonItem } from "../../../pages/profile/ProfileFeedItemsPage"; +import { useAppDispatch, useAppSelector } from "../../../store"; +import { ListHeader } from "../shared/formatting"; +import { updateFilteredKeywords } from "../settingsSlice"; +import { uniq, without } from "lodash"; + +export default function FilteredKeywords() { + const [presentAlert] = useIonAlert(); + const dispatch = useAppDispatch(); + const filteredKeywords = useAppSelector( + (state) => state.settings.blocks.keywords, + ); + + async function remove(keyword: string) { + dispatch(updateFilteredKeywords(without(filteredKeywords, keyword))); + } + + async function add() { + presentAlert({ + message: "Add Filtered Keyword", + buttons: [ + { + text: "OK", + handler: ({ keyword }) => { + if (!keyword.trim()) return; + + dispatch( + updateFilteredKeywords( + uniq([...filteredKeywords, keyword.trim()]), + ), + ); + }, + }, + "Cancel", + ], + inputs: [ + { + placeholder: "Keyword", + name: "keyword", + }, + ], + }); + } + + return ( + <> + + Filtered Keywords + + + {filteredKeywords.map((keyword) => ( + + remove(keyword)}> + remove(keyword)} + > + Unfilter + + + + {keyword} + + + ))} + + + Add Keyword + + + + ); +} diff --git a/src/features/settings/settingsSlice.tsx b/src/features/settings/settingsSlice.tsx index e72899cf54..89fb24ff4a 100644 --- a/src/features/settings/settingsSlice.tsx +++ b/src/features/settings/settingsSlice.tsx @@ -91,6 +91,9 @@ interface SettingsState { enableHapticFeedback: boolean; linkHandler: LinkHandlerType; }; + blocks: { + keywords: string[]; + }; } const LOCALSTORAGE_KEYS = { @@ -153,6 +156,9 @@ const initialState: SettingsState = { enableHapticFeedback: true, linkHandler: OLinkHandlerType.InApp, }, + blocks: { + keywords: [], + }, }; // We continue using localstorage for specific items because indexeddb is slow @@ -240,6 +246,10 @@ export const appearanceSlice = createSlice({ state.appearance.posts.blurNsfw = action.payload; // Per user setting is updated in StoreProvider }, + setFilteredKeywords(state, action: PayloadAction) { + state.blocks.keywords = action.payload; + // Per user setting is updated in StoreProvider + }, setShowVotingButtons(state, action: PayloadAction) { state.appearance.compact.showVotingButtons = action.payload; db.setSetting("compact_show_voting_buttons", action.payload); @@ -353,6 +363,31 @@ export const getBlurNsfw = dispatch(setNsfwBlur(blurNsfw ?? initialState.appearance.posts.blurNsfw)); }; +export const getFilteredKeywords = + () => async (dispatch: AppDispatch, getState: () => RootState) => { + const userHandle = getState().auth.accountData?.activeHandle; + + const filteredKeywords = await db.getSetting("filtered_keywords", { + user_handle: userHandle, + }); + + dispatch( + setFilteredKeywords(filteredKeywords ?? initialState.blocks.keywords), + ); + }; + +export const updateFilteredKeywords = + (filteredKeywords: string[]) => + async (dispatch: AppDispatch, getState: () => RootState) => { + const userHandle = getState().auth.accountData?.activeHandle; + + dispatch(setFilteredKeywords(filteredKeywords)); + + db.setSetting("filtered_keywords", filteredKeywords, { + user_handle: userHandle, + }); + }; + export const fetchSettingsFromDatabase = createAsyncThunk( "appearance/fetchSettingsFromDatabase", async (_, thunkApi) => { @@ -391,6 +426,7 @@ export const fetchSettingsFromDatabase = createAsyncThunk( "enable_haptic_feedback", ); const link_handler = await db.getSetting("link_handler"); + const filtered_keywords = await db.getSetting("filtered_keywords"); return { ...state.settings, @@ -452,6 +488,9 @@ export const fetchSettingsFromDatabase = createAsyncThunk( enableHapticFeedback: enable_haptic_feedback ?? initialState.general.enableHapticFeedback, }, + blocks: { + keywords: filtered_keywords ?? initialState.blocks.keywords, + }, }; }); @@ -475,6 +514,7 @@ export const { setShowJumpButton, setJumpButtonPosition, setNsfwBlur, + setFilteredKeywords, setPostAppearance, setThumbnailPosition, setShowVotingButtons, diff --git a/src/features/user/Profile.tsx b/src/features/user/Profile.tsx index fd86c2b474..3191bb1a29 100644 --- a/src/features/user/Profile.tsx +++ b/src/features/user/Profile.tsx @@ -114,6 +114,7 @@ export default function Profile({ person }: ProfileProps) { fetchFn={fetchFn} header={header} filterHiddenPosts={false} + filterKeywords={false} /> ); } diff --git a/src/helpers/lemmy.test.ts b/src/helpers/lemmy.test.ts new file mode 100644 index 0000000000..894b1cfd3a --- /dev/null +++ b/src/helpers/lemmy.test.ts @@ -0,0 +1,59 @@ +import { keywordFoundInSentence } from "./lemmy"; + +describe("keywordFoundInSentence", () => { + it("false when empty", () => { + expect(keywordFoundInSentence("", "")).toBe(false); + }); + + it("false with keyword in empty string", () => { + expect(keywordFoundInSentence("keyword", "")).toBe(false); + }); + + it("false with keyword in sentence", () => { + expect(keywordFoundInSentence("keyword", "Hello, this is Voyager!")).toBe( + false, + ); + }); + + it("true with keyword in middle", () => { + expect(keywordFoundInSentence("this", "Hello, this is Voyager!")).toBe( + true, + ); + }); + + it("false with partial keyword in middle", () => { + expect(keywordFoundInSentence("thi", "Hello, this is Voyager!")).toBe( + false, + ); + }); + + it("true with multi word keyword", () => { + expect(keywordFoundInSentence("this is", "Hello, this is Voyager!")).toBe( + true, + ); + }); + + it("true with keyword without comma", () => { + expect(keywordFoundInSentence("Hello", "Hello, this is Voyager!")).toBe( + true, + ); + }); + + it("true with multi keyword with comma", () => { + expect( + keywordFoundInSentence("Hello, this", "Hello, this is Voyager!"), + ).toBe(true); + }); + + it("true at beginning", () => { + expect(keywordFoundInSentence("Hello", "Hello, this is Voyager!")).toBe( + true, + ); + }); + + it("true case insensitive", () => { + expect(keywordFoundInSentence("voyageR", "Hello, this is Voyager!")).toBe( + true, + ); + }); +}); diff --git a/src/helpers/lemmy.ts b/src/helpers/lemmy.ts index 8499ac7d09..d48045770a 100644 --- a/src/helpers/lemmy.ts +++ b/src/helpers/lemmy.ts @@ -263,3 +263,25 @@ export function isUrlVideo(url: string): boolean { export function share(item: Post | Comment) { return Share.share({ url: item.ap_id }); } + +export function postHasFilteredKeywords( + post: Post, + keywords: string[], +): boolean { + for (const keyword of keywords) { + if (keywordFoundInSentence(keyword, post.name)) return true; + } + + return false; +} + +export function keywordFoundInSentence( + keyword: string, + sentence: string, +): boolean { + // Create a regular expression pattern to match the keyword as a whole word + const pattern = new RegExp(`\\b${keyword}\\b`, "i"); + + // Use the RegExp test method to check if the pattern is found in the sentence + return pattern.test(sentence); +} diff --git a/src/pages/profile/ProfileFeedHiddenPostsPage.tsx b/src/pages/profile/ProfileFeedHiddenPostsPage.tsx index 26a55850af..bb952f6810 100644 --- a/src/pages/profile/ProfileFeedHiddenPostsPage.tsx +++ b/src/pages/profile/ProfileFeedHiddenPostsPage.tsx @@ -104,9 +104,10 @@ export default function ProfileFeedHiddenPostsPage() { diff --git a/src/pages/profile/ProfileFeedItemsPage.tsx b/src/pages/profile/ProfileFeedItemsPage.tsx index 7c035b2b58..7033b453c8 100644 --- a/src/pages/profile/ProfileFeedItemsPage.tsx +++ b/src/pages/profile/ProfileFeedItemsPage.tsx @@ -86,7 +86,11 @@ export default function ProfileFeedItemsPage({ - + ); diff --git a/src/pages/settings/BlocksSettingsPage.tsx b/src/pages/settings/BlocksSettingsPage.tsx index 516d165c10..4f604bf622 100644 --- a/src/pages/settings/BlocksSettingsPage.tsx +++ b/src/pages/settings/BlocksSettingsPage.tsx @@ -22,6 +22,7 @@ import FilterNsfw from "../../features/settings/blocks/FilterNsfw"; import BlockedCommunities from "../../features/settings/blocks/BlockedCommunities"; import { CenteredSpinner } from "../posts/PostPage"; import BlockedUsers from "../../features/settings/blocks/BlockedUsers"; +import FilteredKeywords from "../../features/settings/blocks/FilteredKeywords"; export default function BlocksSettingsPage() { const userHandle = useAppSelector(handleSelector); @@ -48,6 +49,7 @@ export default function BlocksSettingsPage() { {localUser ? ( + diff --git a/src/services/db.ts b/src/services/db.ts index 9aa58c1e5c..8d80557e68 100644 --- a/src/services/db.ts +++ b/src/services/db.ts @@ -210,6 +210,7 @@ export type SettingValueTypes = { link_handler: LinkHandlerType; show_jump_button: boolean; jump_button_position: JumpButtonPositionType; + filtered_keywords: string[]; }; export interface ISettingItem { diff --git a/src/store.tsx b/src/store.tsx index e847142bb2..848c174fa1 100644 --- a/src/store.tsx +++ b/src/store.tsx @@ -17,6 +17,7 @@ import inboxSlice from "./features/inbox/inboxSlice"; import settingsSlice, { fetchSettingsFromDatabase, getBlurNsfw, + getFilteredKeywords, } from "./features/settings/settingsSlice"; import gestureSlice, { fetchGesturesFromDatabase, @@ -61,6 +62,7 @@ const activeHandleChange = () => { store.dispatch(getFavoriteCommunities()); store.dispatch(getBlurNsfw()); + store.dispatch(getFilteredKeywords()); }; export function StoreProvider({ children }: { children: ReactNode }) {