From a023ad58f1a84fba05b7001ccb7c6b81fbc400e7 Mon Sep 17 00:00:00 2001 From: Zhiyuan Zheng Date: Sun, 30 May 2021 23:39:07 +0200 Subject: [PATCH] Fixed #118 --- package.json | 2 +- src/@types/mastodon.d.ts | 9 ++ src/Screens.tsx | 2 + src/components/Timeline/Default.tsx | 16 ++- src/components/Timeline/Notifications.tsx | 9 ++ src/components/Timeline/Shared/Filtered.tsx | 105 ++++++++++++++++++++ src/i18n/en/components/timeline.json | 1 + src/utils/slices/instances/updateFilters.ts | 12 +++ src/utils/slices/instancesSlice.ts | 11 ++ 9 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 src/components/Timeline/Shared/Filtered.tsx create mode 100644 src/utils/slices/instances/updateFilters.ts diff --git a/package.json b/package.json index cf04a80d..de994fe4 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "native": "210511", "major": 2, "minor": 0, - "patch": 1, + "patch": 2, "expo": "41.0.0" }, "description": "tooot app for Mastodon", diff --git a/src/@types/mastodon.d.ts b/src/@types/mastodon.d.ts index 5a0e88fa..c0154742 100644 --- a/src/@types/mastodon.d.ts +++ b/src/@types/mastodon.d.ts @@ -261,6 +261,15 @@ declare namespace Mastodon { verified_at: string | null } + type Filter = { + id: string + phrase: string + context: ('home' | 'notifications' | 'public' | 'thread' | 'account')[] + expires_at?: string + irreversible: boolean + whole_word: boolean + } + type List = { id: string title: string diff --git a/src/Screens.tsx b/src/Screens.tsx index 5cf8cf5b..955f9a31 100644 --- a/src/Screens.tsx +++ b/src/Screens.tsx @@ -13,6 +13,7 @@ import pushUseReceive from '@utils/push/useReceive' import pushUseRespond from '@utils/push/useRespond' import { updatePreviousTab } from '@utils/slices/contextsSlice' import { updateAccountPreferences } from '@utils/slices/instances/updateAccountPreferences' +import { updateFilters } from '@utils/slices/instances/updateFilters' import { getInstanceActive, getInstances } from '@utils/slices/instancesSlice' import { useTheme } from '@utils/styles/ThemeManager' import { themes } from '@utils/styles/themes' @@ -106,6 +107,7 @@ const Screens: React.FC = ({ localCorrupt }) => { // Lazily update users's preferences, for e.g. composing default visibility useEffect(() => { if (instanceActive !== -1) { + dispatch(updateFilters()) dispatch(updateAccountPreferences()) } }, [instanceActive]) diff --git a/src/components/Timeline/Default.tsx b/src/components/Timeline/Default.tsx index 89eb759d..a628aee3 100644 --- a/src/components/Timeline/Default.tsx +++ b/src/components/Timeline/Default.tsx @@ -10,14 +10,16 @@ import TimelinePoll from '@components/Timeline/Shared/Poll' import { useNavigation } from '@react-navigation/native' import { StackNavigationProp } from '@react-navigation/stack' import { QueryKeyTimeline } from '@utils/queryHooks/timeline' -import { getInstanceAccount } from '@utils/slices/instancesSlice' +import { getInstance, getInstanceAccount } from '@utils/slices/instancesSlice' import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' +import htmlparser2 from 'htmlparser2-without-node-native' import { uniqBy } from 'lodash' import React, { useCallback } from 'react' import { Pressable, StyleSheet, View } from 'react-native' import { useSelector } from 'react-redux' import TimelineActionsUsers from './Shared/ActionsUsers' +import TimelineFiltered, { shouldFilter } from './Shared/Filtered' import TimelineFullConversation from './Shared/FullConversation' import TimelineTranslate from './Shared/Translate' @@ -49,6 +51,16 @@ const TimelineDefault: React.FC = ({ let actualStatus = item.reblog ? item.reblog : item + const ownAccount = actualStatus.account.id === instanceAccount?.id + + if ( + !highlighted && + queryKey && + shouldFilter({ status: actualStatus, queryKey }) + ) { + return + } + const onPress = useCallback(() => { analytics('timeline_default_press', { page: queryKey ? queryKey[1].page : origin @@ -118,7 +130,7 @@ const TimelineDefault: React.FC = ({ statusId={actualStatus.id} poll={actualStatus.poll} reblog={item.reblog ? true : false} - sameAccount={actualStatus.account.id === instanceAccount?.id} + sameAccount={ownAccount} /> ) : null} {!disableDetails && diff --git a/src/components/Timeline/Notifications.tsx b/src/components/Timeline/Notifications.tsx index b458a111..94f1ad70 100644 --- a/src/components/Timeline/Notifications.tsx +++ b/src/components/Timeline/Notifications.tsx @@ -17,6 +17,7 @@ import { uniqBy } from 'lodash' import React, { useCallback } from 'react' import { Pressable, StyleSheet, View } from 'react-native' import { useSelector } from 'react-redux' +import TimelineFiltered, { shouldFilter } from './Shared/Filtered' import TimelineFullConversation from './Shared/FullConversation' export interface Props { @@ -30,6 +31,13 @@ const TimelineNotifications: React.FC = ({ queryKey, highlighted = false }) => { + if ( + notification.status && + shouldFilter({ status: notification.status, queryKey }) + ) { + return + } + const { theme } = useTheme() const instanceAccount = useSelector( getInstanceAccount, @@ -38,6 +46,7 @@ const TimelineNotifications: React.FC = ({ const navigation = useNavigation< StackNavigationProp >() + const actualAccount = notification.status ? notification.status.account : notification.account diff --git a/src/components/Timeline/Shared/Filtered.tsx b/src/components/Timeline/Shared/Filtered.tsx new file mode 100644 index 00000000..df71435a --- /dev/null +++ b/src/components/Timeline/Shared/Filtered.tsx @@ -0,0 +1,105 @@ +import { store } from '@root/store' +import { QueryKeyTimeline } from '@utils/queryHooks/timeline' +import { getInstance, getInstanceAccount } from '@utils/slices/instancesSlice' +import { StyleConstants } from '@utils/styles/constants' +import { useTheme } from '@utils/styles/ThemeManager' +import htmlparser2 from 'htmlparser2-without-node-native' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { Text, View } from 'react-native' + +const TimelineFiltered = React.memo( + () => { + const { theme } = useTheme() + const { t } = useTranslation('componentTimeline') + + return ( + + + {t('shared.filtered')} + + + ) + }, + () => true +) + +export const shouldFilter = ({ + status, + queryKey +}: { + status: Mastodon.Status + queryKey: QueryKeyTimeline +}) => { + const instance = getInstance(store.getState()) + const ownAccount = + getInstanceAccount(store.getState())?.id === status.account.id + + let shouldFilter = false + if (queryKey && !ownAccount) { + const parser = new htmlparser2.Parser({ + ontext (text: string) { + const checkFilter = (filter: Mastodon.Filter) => { + switch (filter.whole_word) { + case true: + if (new RegExp('\\b' + filter.phrase + '\\b').test(text)) { + shouldFilter = true + } + break + case false: + if (new RegExp(filter.phrase).test(text)) { + shouldFilter = true + } + break + } + } + instance?.filters.forEach(filter => { + if (filter.expires_at) { + if (new Date().getTime() > new Date(filter.expires_at).getTime()) { + return + } + } + + switch (queryKey[1].page) { + case 'Following': + case 'Local': + case 'List': + case 'Account_Default': + if (filter.context.includes('home')) { + checkFilter(filter) + } + break + case 'Notifications': + if (filter.context.includes('notifications')) { + checkFilter(filter) + } + break + case 'LocalPublic': + if (filter.context.includes('public')) { + checkFilter(filter) + } + break + case 'Toot': + if (filter.context.includes('thread')) { + checkFilter(filter) + } + } + }) + } + }) + parser.write(status.content) + parser.end() + } + + return shouldFilter +} + +export default TimelineFiltered diff --git a/src/i18n/en/components/timeline.json b/src/i18n/en/components/timeline.json index fe7aed9c..d301f76a 100644 --- a/src/i18n/en/components/timeline.json +++ b/src/i18n/en/components/timeline.json @@ -73,6 +73,7 @@ "content": { "expandHint": "hidden content" }, + "filtered": "Filtered", "fullConversation": "Read conversations", "translate": { "default": "Translate", diff --git a/src/utils/slices/instances/updateFilters.ts b/src/utils/slices/instances/updateFilters.ts new file mode 100644 index 00000000..5c722ac8 --- /dev/null +++ b/src/utils/slices/instances/updateFilters.ts @@ -0,0 +1,12 @@ +import apiInstance from '@api/instance' +import { createAsyncThunk } from '@reduxjs/toolkit' + +export const updateFilters = createAsyncThunk( + 'instances/updateFilters', + async (): Promise => { + return apiInstance({ + method: 'get', + url: `filters` + }).then(res => res.body) + } +) diff --git a/src/utils/slices/instancesSlice.ts b/src/utils/slices/instancesSlice.ts index 9a52c899..1183184c 100644 --- a/src/utils/slices/instancesSlice.ts +++ b/src/utils/slices/instancesSlice.ts @@ -6,6 +6,7 @@ import { findIndex } from 'lodash' import addInstance from './instances/add' import removeInstance from './instances/remove' import { updateAccountPreferences } from './instances/updateAccountPreferences' +import { updateFilters } from './instances/updateFilters' import { updateInstancePush } from './instances/updatePush' import { updateInstancePushAlert } from './instances/updatePushAlert' import { updateInstancePushDecode } from './instances/updatePushDecode' @@ -29,6 +30,7 @@ export type Instance = { avatarStatic: Mastodon.Account['avatar_static'] preferences: Mastodon.Preferences } + filters: Mastodon.Filter[] notifications_filter: { follow: boolean favourite: boolean @@ -236,6 +238,15 @@ const instancesSlice = createSlice({ console.error(action.error) }) + // Update Instance Account Filters + .addCase(updateFilters.fulfilled, (state, action) => { + const activeIndex = findInstanceActive(state.instances) + state.instances[activeIndex].filters = action.payload + }) + .addCase(updateFilters.rejected, (_, action) => { + console.error(action.error) + }) + // Update Instance Account Preferences .addCase(updateAccountPreferences.fulfilled, (state, action) => { const activeIndex = findInstanceActive(state.instances)