mirror of
				https://github.com/tooot-app/app
				synced 2025-06-05 22:19:13 +02:00 
			
		
		
		
	Fixed #572
This commit is contained in:
		| @@ -1 +1,2 @@ | |||||||
| Enjoy toooting! This version includes following improvements and fixes: | Enjoy toooting! This version includes following improvements and fixes: | ||||||
|  | - Align filter experience with v4.0 and above | ||||||
|   | |||||||
| @@ -1 +1,2 @@ | |||||||
| toooting愉快!此版本包括以下改进和修复: | toooting愉快!此版本包括以下改进和修复: | ||||||
|  | - 改进过滤体验,与v4.0以上版本一致 | ||||||
|   | |||||||
							
								
								
									
										27
									
								
								src/@types/mastodon.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										27
									
								
								src/@types/mastodon.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -263,7 +263,8 @@ declare namespace Mastodon { | |||||||
|     verified_at: string | null |     verified_at: string | null | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   type Filter = { |   type Filter<T extends 'v1' | 'v2'> = T extends 'v2' ? Filter_V2 : Filter_V1 | ||||||
|  |   type Filter_V1 = { | ||||||
|     id: string |     id: string | ||||||
|     phrase: string |     phrase: string | ||||||
|     context: ('home' | 'notifications' | 'public' | 'thread' | 'account')[] |     context: ('home' | 'notifications' | 'public' | 'thread' | 'account')[] | ||||||
| @@ -271,6 +272,25 @@ declare namespace Mastodon { | |||||||
|     irreversible: boolean |     irreversible: boolean | ||||||
|     whole_word: boolean |     whole_word: boolean | ||||||
|   } |   } | ||||||
|  |   type Filter_V2 = { | ||||||
|  |     id: string | ||||||
|  |     title: string | ||||||
|  |     context: ('home' | 'notifications' | 'public' | 'thread' | 'account')[] | ||||||
|  |     expires_at?: string | ||||||
|  |     filter_action: 'warn' | 'hide' | ||||||
|  |     keywords: FilterKeyword[] | ||||||
|  |     statuses: FilterStatus[] | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   type FilterKeyword = { id: string; keyword: string; whole_word: boolean } | ||||||
|  |  | ||||||
|  |   type FilterStatus = { id: string; status_id: string } | ||||||
|  |  | ||||||
|  |   type FilterResult = { | ||||||
|  |     filter: Filter<'v2'> | ||||||
|  |     keyword_matches?: FilterKeyword['keyword'][] | ||||||
|  |     status_matches?: FilterStatus['id'][] | ||||||
|  |   } | ||||||
|  |  | ||||||
|   type List = { |   type List = { | ||||||
|     id: string |     id: string | ||||||
| @@ -461,7 +481,7 @@ declare namespace Mastodon { | |||||||
|     sensitive: boolean |     sensitive: boolean | ||||||
|     spoiler_text?: string |     spoiler_text?: string | ||||||
|     media_attachments: Attachment[] |     media_attachments: Attachment[] | ||||||
|     application: Application |     application?: Application | ||||||
|  |  | ||||||
|     // Attributes |     // Attributes | ||||||
|     mentions: Mention[] |     mentions: Mention[] | ||||||
| @@ -472,7 +492,7 @@ declare namespace Mastodon { | |||||||
|     reblogs_count: number |     reblogs_count: number | ||||||
|     favourites_count: number |     favourites_count: number | ||||||
|     replies_count: number |     replies_count: number | ||||||
|     edited_at?: string // FEATURE edit_post |     edited_at?: string | ||||||
|     favourited: boolean |     favourited: boolean | ||||||
|     reblogged: boolean |     reblogged: boolean | ||||||
|     muted: boolean |     muted: boolean | ||||||
| @@ -488,6 +508,7 @@ declare namespace Mastodon { | |||||||
|     card?: Card |     card?: Card | ||||||
|     language?: string |     language?: string | ||||||
|     text?: string |     text?: string | ||||||
|  |     filtered?: FilterResult[] | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   type StatusHistory = { |   type StatusHistory = { | ||||||
|   | |||||||
| @@ -9,11 +9,12 @@ import TimelineCard from '@components/Timeline/Shared/Card' | |||||||
| import TimelineContent from '@components/Timeline/Shared/Content' | import TimelineContent from '@components/Timeline/Shared/Content' | ||||||
| import TimelineHeaderDefault from '@components/Timeline/Shared/HeaderDefault' | import TimelineHeaderDefault from '@components/Timeline/Shared/HeaderDefault' | ||||||
| import TimelinePoll from '@components/Timeline/Shared/Poll' | import TimelinePoll from '@components/Timeline/Shared/Poll' | ||||||
|  | import removeHTML from '@helpers/removeHTML' | ||||||
| import { useNavigation } from '@react-navigation/native' | import { useNavigation } from '@react-navigation/native' | ||||||
| import { StackNavigationProp } from '@react-navigation/stack' | import { StackNavigationProp } from '@react-navigation/stack' | ||||||
| import { TabLocalStackParamList } from '@utils/navigation/navigators' | import { TabLocalStackParamList } from '@utils/navigation/navigators' | ||||||
| import { QueryKeyTimeline } from '@utils/queryHooks/timeline' | import { QueryKeyTimeline } from '@utils/queryHooks/timeline' | ||||||
| import { getInstanceAccount } from '@utils/slices/instancesSlice' | import { checkInstanceFeature, getInstanceAccount } from '@utils/slices/instancesSlice' | ||||||
| import { StyleConstants } from '@utils/styles/constants' | import { StyleConstants } from '@utils/styles/constants' | ||||||
| import { useTheme } from '@utils/styles/ThemeManager' | import { useTheme } from '@utils/styles/ThemeManager' | ||||||
| import React, { useRef, useState } from 'react' | import React, { useRef, useState } from 'react' | ||||||
| @@ -22,7 +23,7 @@ import { useSelector } from 'react-redux' | |||||||
| import * as ContextMenu from 'zeego/context-menu' | import * as ContextMenu from 'zeego/context-menu' | ||||||
| import StatusContext from './Shared/Context' | import StatusContext from './Shared/Context' | ||||||
| import TimelineFeedback from './Shared/Feedback' | import TimelineFeedback from './Shared/Feedback' | ||||||
| import TimelineFiltered, { shouldFilter } from './Shared/Filtered' | import TimelineFiltered, { FilteredProps, shouldFilter } from './Shared/Filtered' | ||||||
| import TimelineFullConversation from './Shared/FullConversation' | import TimelineFullConversation from './Shared/FullConversation' | ||||||
| import TimelineHeaderAndroid from './Shared/HeaderAndroid' | import TimelineHeaderAndroid from './Shared/HeaderAndroid' | ||||||
| import TimelineTranslate from './Shared/Translate' | import TimelineTranslate from './Shared/Translate' | ||||||
| @@ -47,12 +48,20 @@ const TimelineDefault: React.FC<Props> = ({ | |||||||
|   disableOnPress = false, |   disableOnPress = false, | ||||||
|   isConversation = false |   isConversation = false | ||||||
| }) => { | }) => { | ||||||
|  |   const status = item.reblog ? item.reblog : item | ||||||
|  |   const rawContent = useRef<string[]>([]) | ||||||
|  |   if (highlighted) { | ||||||
|  |     rawContent.current = [ | ||||||
|  |       removeHTML(status.content), | ||||||
|  |       status.spoiler_text ? removeHTML(status.spoiler_text) : '' | ||||||
|  |     ].filter(c => c.length) | ||||||
|  |   } | ||||||
|  |  | ||||||
|   const { colors } = useTheme() |   const { colors } = useTheme() | ||||||
|   const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>() |   const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>() | ||||||
|  |  | ||||||
|   const instanceAccount = useSelector(getInstanceAccount, () => true) |   const instanceAccount = useSelector(getInstanceAccount, () => true) | ||||||
|  |  | ||||||
|   const status = item.reblog ? item.reblog : item |  | ||||||
|   const ownAccount = status.account?.id === instanceAccount?.id |   const ownAccount = status.account?.id === instanceAccount?.id | ||||||
|   const [spoilerExpanded, setSpoilerExpanded] = useState( |   const [spoilerExpanded, setSpoilerExpanded] = useState( | ||||||
|     instanceAccount?.preferences?.['reading:expand:spoilers'] || false |     instanceAccount?.preferences?.['reading:expand:spoilers'] || false | ||||||
| @@ -60,17 +69,8 @@ const TimelineDefault: React.FC<Props> = ({ | |||||||
|   const spoilerHidden = status.spoiler_text?.length |   const spoilerHidden = status.spoiler_text?.length | ||||||
|     ? !instanceAccount?.preferences?.['reading:expand:spoilers'] && !spoilerExpanded |     ? !instanceAccount?.preferences?.['reading:expand:spoilers'] && !spoilerExpanded | ||||||
|     : false |     : false | ||||||
|   const copiableContent = useRef<{ content: string; complete: boolean }>({ |  | ||||||
|     content: '', |  | ||||||
|     complete: false |  | ||||||
|   }) |  | ||||||
|   const detectedLanguage = useRef<string>(status.language || '') |   const detectedLanguage = useRef<string>(status.language || '') | ||||||
|  |  | ||||||
|   const filtered = queryKey && shouldFilter({ copiableContent, status, queryKey }) |  | ||||||
|   if (queryKey && filtered && !highlighted) { |  | ||||||
|     return <TimelineFiltered phrase={filtered} /> |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   const mainStyle: StyleProp<ViewStyle> = { |   const mainStyle: StyleProp<ViewStyle> = { | ||||||
|     flex: 1, |     flex: 1, | ||||||
|     padding: disableDetails |     padding: disableDetails | ||||||
| @@ -125,11 +125,36 @@ const TimelineDefault: React.FC<Props> = ({ | |||||||
|     visibility: status.visibility, |     visibility: status.visibility, | ||||||
|     type: 'status', |     type: 'status', | ||||||
|     url: status.url || status.uri, |     url: status.url || status.uri, | ||||||
|     copiableContent |     rawContent | ||||||
|   }) |   }) | ||||||
|   const mStatus = menuStatus({ status, queryKey, rootQueryKey }) |   const mStatus = menuStatus({ status, queryKey, rootQueryKey }) | ||||||
|   const mInstance = menuInstance({ status, queryKey, rootQueryKey }) |   const mInstance = menuInstance({ status, queryKey, rootQueryKey }) | ||||||
|  |  | ||||||
|  |   if (!ownAccount) { | ||||||
|  |     let filterResults: FilteredProps['filterResults'] = [] | ||||||
|  |     const [filterRevealed, setFilterRevealed] = useState(false) | ||||||
|  |     const hasFilterServerSide = useSelector(checkInstanceFeature('filter_server_side')) | ||||||
|  |     if (hasFilterServerSide) { | ||||||
|  |       if (status.filtered?.length) { | ||||||
|  |         filterResults = status.filtered?.map(filter => filter.filter) | ||||||
|  |       } | ||||||
|  |     } else { | ||||||
|  |       if (queryKey) { | ||||||
|  |         const checkFilter = shouldFilter({ queryKey, status }) | ||||||
|  |         if (checkFilter?.length) { | ||||||
|  |           filterResults = checkFilter | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     if (queryKey && !highlighted && filterResults?.length && !filterRevealed) { | ||||||
|  |       return !filterResults.filter(result => result.filter_action === 'hide').length ? ( | ||||||
|  |         <Pressable onPress={() => setFilterRevealed(!filterRevealed)}> | ||||||
|  |           <TimelineFiltered filterResults={filterResults} /> | ||||||
|  |         </Pressable> | ||||||
|  |       ) : null | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <StatusContext.Provider |     <StatusContext.Provider | ||||||
|       value={{ |       value={{ | ||||||
| @@ -139,7 +164,7 @@ const TimelineDefault: React.FC<Props> = ({ | |||||||
|         reblogStatus: item.reblog ? item : undefined, |         reblogStatus: item.reblog ? item : undefined, | ||||||
|         ownAccount, |         ownAccount, | ||||||
|         spoilerHidden, |         spoilerHidden, | ||||||
|         copiableContent, |         rawContent, | ||||||
|         detectedLanguage, |         detectedLanguage, | ||||||
|         highlighted, |         highlighted, | ||||||
|         inThread: queryKey?.[1].page === 'Toot', |         inThread: queryKey?.[1].page === 'Toot', | ||||||
|   | |||||||
| @@ -13,7 +13,7 @@ import { useNavigation } from '@react-navigation/native' | |||||||
| import { StackNavigationProp } from '@react-navigation/stack' | import { StackNavigationProp } from '@react-navigation/stack' | ||||||
| import { TabLocalStackParamList } from '@utils/navigation/navigators' | import { TabLocalStackParamList } from '@utils/navigation/navigators' | ||||||
| import { QueryKeyTimeline } from '@utils/queryHooks/timeline' | import { QueryKeyTimeline } from '@utils/queryHooks/timeline' | ||||||
| import { getInstanceAccount } from '@utils/slices/instancesSlice' | import { checkInstanceFeature, getInstanceAccount } from '@utils/slices/instancesSlice' | ||||||
| import { StyleConstants } from '@utils/styles/constants' | import { StyleConstants } from '@utils/styles/constants' | ||||||
| import { useTheme } from '@utils/styles/ThemeManager' | import { useTheme } from '@utils/styles/ThemeManager' | ||||||
| import React, { useCallback, useRef, useState } from 'react' | import React, { useCallback, useRef, useState } from 'react' | ||||||
| @@ -21,7 +21,7 @@ import { Pressable, View } from 'react-native' | |||||||
| import { useSelector } from 'react-redux' | import { useSelector } from 'react-redux' | ||||||
| import * as ContextMenu from 'zeego/context-menu' | import * as ContextMenu from 'zeego/context-menu' | ||||||
| import StatusContext from './Shared/Context' | import StatusContext from './Shared/Context' | ||||||
| import TimelineFiltered, { shouldFilter } from './Shared/Filtered' | import TimelineFiltered, { FilteredProps, shouldFilter } from './Shared/Filtered' | ||||||
| import TimelineFullConversation from './Shared/FullConversation' | import TimelineFullConversation from './Shared/FullConversation' | ||||||
| import TimelineHeaderAndroid from './Shared/HeaderAndroid' | import TimelineHeaderAndroid from './Shared/HeaderAndroid' | ||||||
|  |  | ||||||
| @@ -52,17 +52,6 @@ const TimelineNotifications: React.FC<Props> = ({ notification, queryKey }) => { | |||||||
|     complete: false |     complete: false | ||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   const filtered = |  | ||||||
|     notification.status && |  | ||||||
|     shouldFilter({ |  | ||||||
|       copiableContent, |  | ||||||
|       status: notification.status, |  | ||||||
|       queryKey |  | ||||||
|     }) |  | ||||||
|   if (notification.status && filtered) { |  | ||||||
|     return <TimelineFiltered phrase={filtered} /> |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   const { colors } = useTheme() |   const { colors } = useTheme() | ||||||
|   const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>() |   const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>() | ||||||
|  |  | ||||||
| @@ -124,20 +113,44 @@ const TimelineNotifications: React.FC<Props> = ({ notification, queryKey }) => { | |||||||
|   const mShare = menuShare({ |   const mShare = menuShare({ | ||||||
|     visibility: notification.status?.visibility, |     visibility: notification.status?.visibility, | ||||||
|     type: 'status', |     type: 'status', | ||||||
|     url: notification.status?.url || notification.status?.uri, |     url: notification.status?.url || notification.status?.uri | ||||||
|     copiableContent |  | ||||||
|   }) |   }) | ||||||
|   const mStatus = menuStatus({ status: notification.status, queryKey }) |   const mStatus = menuStatus({ status: notification.status, queryKey }) | ||||||
|   const mInstance = menuInstance({ status: notification.status, queryKey }) |   const mInstance = menuInstance({ status: notification.status, queryKey }) | ||||||
|  |  | ||||||
|  |   if (!ownAccount) { | ||||||
|  |     let filterResults: FilteredProps['filterResults'] = [] | ||||||
|  |     const [filterRevealed, setFilterRevealed] = useState(false) | ||||||
|  |     const hasFilterServerSide = useSelector(checkInstanceFeature('filter_server_side')) | ||||||
|  |     if (notification.status) { | ||||||
|  |       if (hasFilterServerSide) { | ||||||
|  |         if (notification.status.filtered?.length) { | ||||||
|  |           filterResults = notification.status.filtered.map(filter => filter.filter) | ||||||
|  |         } | ||||||
|  |       } else { | ||||||
|  |         const checkFilter = shouldFilter({ queryKey, status: notification.status }) | ||||||
|  |         if (checkFilter?.length) { | ||||||
|  |           filterResults = checkFilter | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       if (filterResults?.length && !filterRevealed) { | ||||||
|  |         return !filterResults.filter(result => result.filter_action === 'hide').length ? ( | ||||||
|  |           <Pressable onPress={() => setFilterRevealed(!filterRevealed)}> | ||||||
|  |             <TimelineFiltered filterResults={filterResults} /> | ||||||
|  |           </Pressable> | ||||||
|  |         ) : null | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <StatusContext.Provider |     <StatusContext.Provider | ||||||
|       value={{ |       value={{ | ||||||
|         queryKey, |         queryKey, | ||||||
|         status, |         status, | ||||||
|         ownAccount, |         ownAccount, | ||||||
|         spoilerHidden, |         spoilerHidden | ||||||
|         copiableContent |  | ||||||
|       }} |       }} | ||||||
|     > |     > | ||||||
|       <ContextMenu.Root> |       <ContextMenu.Root> | ||||||
|   | |||||||
| @@ -1,7 +1,9 @@ | |||||||
| import { QueryKeyTimeline } from '@utils/queryHooks/timeline' | import { QueryKeyTimeline } from '@utils/queryHooks/timeline' | ||||||
| import { createContext } from 'react' | import { createContext } from 'react' | ||||||
|  |  | ||||||
| type ContextType = { | export type HighlightedStatusContextType = {} | ||||||
|  |  | ||||||
|  | type StatusContextType = { | ||||||
|   queryKey?: QueryKeyTimeline |   queryKey?: QueryKeyTimeline | ||||||
|   rootQueryKey?: QueryKeyTimeline |   rootQueryKey?: QueryKeyTimeline | ||||||
|  |  | ||||||
| @@ -10,10 +12,7 @@ type ContextType = { | |||||||
|   reblogStatus?: Mastodon.Status // When it is a reblog, pass the root status |   reblogStatus?: Mastodon.Status // When it is a reblog, pass the root status | ||||||
|   ownAccount?: boolean |   ownAccount?: boolean | ||||||
|   spoilerHidden?: boolean |   spoilerHidden?: boolean | ||||||
|   copiableContent?: React.MutableRefObject<{ |   rawContent?: React.MutableRefObject<string[]> // When highlighted, for translate, edit history | ||||||
|     content: string |  | ||||||
|     complete: boolean |  | ||||||
|   }> |  | ||||||
|   detectedLanguage?: React.MutableRefObject<string> |   detectedLanguage?: React.MutableRefObject<string> | ||||||
|  |  | ||||||
|   highlighted?: boolean |   highlighted?: boolean | ||||||
| @@ -22,6 +21,6 @@ type ContextType = { | |||||||
|   disableOnPress?: boolean |   disableOnPress?: boolean | ||||||
|   isConversation?: boolean |   isConversation?: boolean | ||||||
| } | } | ||||||
| const StatusContext = createContext<ContextType>({} as ContextType) | const StatusContext = createContext<StatusContextType>({} as StatusContextType) | ||||||
|  |  | ||||||
| export default StatusContext | export default StatusContext | ||||||
|   | |||||||
| @@ -1,19 +1,46 @@ | |||||||
| import CustomText from '@components/Text' | import CustomText from '@components/Text' | ||||||
|  | import removeHTML from '@helpers/removeHTML' | ||||||
| import { store } from '@root/store' | import { store } from '@root/store' | ||||||
| import { QueryKeyTimeline } from '@utils/queryHooks/timeline' | import { QueryKeyTimeline } from '@utils/queryHooks/timeline' | ||||||
| import { getInstance, getInstanceAccount } from '@utils/slices/instancesSlice' | import { getInstance } from '@utils/slices/instancesSlice' | ||||||
| import { StyleConstants } from '@utils/styles/constants' | import { StyleConstants } from '@utils/styles/constants' | ||||||
| import { useTheme } from '@utils/styles/ThemeManager' | import { useTheme } from '@utils/styles/ThemeManager' | ||||||
| import htmlparser2 from 'htmlparser2-without-node-native' |  | ||||||
| import React from 'react' | import React from 'react' | ||||||
| import { useTranslation } from 'react-i18next' | import { useTranslation } from 'react-i18next' | ||||||
| import { View } from 'react-native' | import { View } from 'react-native' | ||||||
|  |  | ||||||
| const TimelineFiltered = React.memo( | export interface FilteredProps { | ||||||
|   ({ phrase }: { phrase: string }) => { |   filterResults: { title: string; filter_action: Mastodon.Filter<'v2'>['filter_action'] }[] | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const TimelineFiltered: React.FC<FilteredProps> = ({ filterResults }) => { | ||||||
|   const { colors } = useTheme() |   const { colors } = useTheme() | ||||||
|   const { t } = useTranslation('componentTimeline') |   const { t } = useTranslation('componentTimeline') | ||||||
|  |  | ||||||
|  |   const main = () => { | ||||||
|  |     if (!filterResults?.length) { | ||||||
|  |       return <></> | ||||||
|  |     } | ||||||
|  |     switch (typeof filterResults[0]) { | ||||||
|  |       case 'string': // v1 filter | ||||||
|  |         return <>{t('shared.filtered.match', { context: 'v1', phrase: filterResults[0] })}</> | ||||||
|  |       default: | ||||||
|  |         return ( | ||||||
|  |           <> | ||||||
|  |             {t('shared.filtered.match', { | ||||||
|  |               context: 'v2', | ||||||
|  |               count: filterResults.length, | ||||||
|  |               filters: filterResults.map(result => result.title).join(t('common:separator')) | ||||||
|  |             })} | ||||||
|  |             <CustomText | ||||||
|  |               style={{ color: colors.blue }} | ||||||
|  |               children={`\n${t('shared.filtered.reveal')}`} | ||||||
|  |             /> | ||||||
|  |           </> | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <View style={{ backgroundColor: colors.backgroundDefault }}> |     <View style={{ backgroundColor: colors.backgroundDefault }}> | ||||||
|       <CustomText |       <CustomText | ||||||
| @@ -25,67 +52,47 @@ const TimelineFiltered = React.memo( | |||||||
|           paddingLeft: StyleConstants.Avatar.M + StyleConstants.Spacing.S |           paddingLeft: StyleConstants.Avatar.M + StyleConstants.Spacing.S | ||||||
|         }} |         }} | ||||||
|       > |       > | ||||||
|           {t('shared.filtered', { phrase })} |         {main()} | ||||||
|       </CustomText> |       </CustomText> | ||||||
|     </View> |     </View> | ||||||
|   ) |   ) | ||||||
|   }, | } | ||||||
|   () => true |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| export const shouldFilter = ({ | export const shouldFilter = ({ | ||||||
|   copiableContent, |   queryKey, | ||||||
|   status, |   status | ||||||
|   queryKey |  | ||||||
| }: { | }: { | ||||||
|   copiableContent: React.MutableRefObject<{ |  | ||||||
|     content: string |  | ||||||
|     complete: boolean |  | ||||||
|   }> |  | ||||||
|   status: Mastodon.Status |  | ||||||
|   queryKey: QueryKeyTimeline |   queryKey: QueryKeyTimeline | ||||||
| }): string | null => { |   status: Pick<Mastodon.Status, 'content' | 'spoiler_text'> | ||||||
|  | }): FilteredProps['filterResults'] | undefined => { | ||||||
|   const page = queryKey[1] |   const page = queryKey[1] | ||||||
|   const instance = getInstance(store.getState()) |   const instance = getInstance(store.getState()) | ||||||
|   const ownAccount = getInstanceAccount(store.getState())?.id === status.account?.id |  | ||||||
|  |  | ||||||
|   let shouldFilter: string | null = null |   let returnFilter: FilteredProps['filterResults'] | undefined | ||||||
|  |  | ||||||
|   if (!ownAccount) { |   const rawContentCombined = [ | ||||||
|     let rawContent = '' |     removeHTML(status.content), | ||||||
|     const parser = new htmlparser2.Parser({ |     status.spoiler_text ? removeHTML(status.spoiler_text) : '' | ||||||
|       ontext: (text: string) => { |   ] | ||||||
|         if (!copiableContent.current.complete) { |     .filter(c => c.length) | ||||||
|           copiableContent.current.content = copiableContent.current.content + text |     .join(`\n`) | ||||||
|         } |   const checkFilter = (filter: Mastodon.Filter<'v1'>) => { | ||||||
|  |  | ||||||
|         rawContent = rawContent + text |  | ||||||
|       } |  | ||||||
|     }) |  | ||||||
|     if (status.spoiler_text) { |  | ||||||
|       parser.write(status.spoiler_text) |  | ||||||
|       rawContent = rawContent + `\n\n` |  | ||||||
|     } |  | ||||||
|     parser.write(status.content) |  | ||||||
|     parser.end() |  | ||||||
|  |  | ||||||
|     const checkFilter = (filter: Mastodon.Filter) => { |  | ||||||
|     const escapedPhrase = filter.phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string |     const escapedPhrase = filter.phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string | ||||||
|     switch (filter.whole_word) { |     switch (filter.whole_word) { | ||||||
|       case true: |       case true: | ||||||
|           if (new RegExp(`\\b${escapedPhrase}\\b`, 'i').test(rawContent)) { |         if (new RegExp(`\\b${escapedPhrase}\\b`, 'i').test(rawContentCombined)) { | ||||||
|             shouldFilter = filter.phrase |           returnFilter = [{ title: filter.phrase, filter_action: 'warn' }] | ||||||
|         } |         } | ||||||
|         break |         break | ||||||
|       case false: |       case false: | ||||||
|           if (new RegExp(escapedPhrase, 'i').test(rawContent)) { |         if (new RegExp(escapedPhrase, 'i').test(rawContentCombined)) { | ||||||
|             shouldFilter = filter.phrase |           returnFilter = [{ title: filter.phrase, filter_action: 'warn' }] | ||||||
|         } |         } | ||||||
|         break |         break | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|   instance?.filters?.forEach(filter => { |   instance?.filters?.forEach(filter => { | ||||||
|       if (shouldFilter) { |     if (returnFilter) { | ||||||
|       return |       return | ||||||
|     } |     } | ||||||
|     if (filter.expires_at) { |     if (filter.expires_at) { | ||||||
| @@ -100,30 +107,27 @@ export const shouldFilter = ({ | |||||||
|       case 'List': |       case 'List': | ||||||
|       case 'Account': |       case 'Account': | ||||||
|         if (filter.context.includes('home')) { |         if (filter.context.includes('home')) { | ||||||
|             checkFilter(filter) |           checkFilter(filter as Mastodon.Filter<'v1'>) | ||||||
|         } |         } | ||||||
|         break |         break | ||||||
|       case 'Notifications': |       case 'Notifications': | ||||||
|         if (filter.context.includes('notifications')) { |         if (filter.context.includes('notifications')) { | ||||||
|             checkFilter(filter) |           checkFilter(filter as Mastodon.Filter<'v1'>) | ||||||
|         } |         } | ||||||
|         break |         break | ||||||
|       case 'LocalPublic': |       case 'LocalPublic': | ||||||
|         if (filter.context.includes('public')) { |         if (filter.context.includes('public')) { | ||||||
|             checkFilter(filter) |           checkFilter(filter as Mastodon.Filter<'v1'>) | ||||||
|         } |         } | ||||||
|         break |         break | ||||||
|       case 'Toot': |       case 'Toot': | ||||||
|         if (filter.context.includes('thread')) { |         if (filter.context.includes('thread')) { | ||||||
|             checkFilter(filter) |           checkFilter(filter as Mastodon.Filter<'v1'>) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|   }) |   }) | ||||||
|  |  | ||||||
|     copiableContent.current.complete = true |   return returnFilter | ||||||
|   } |  | ||||||
|  |  | ||||||
|   return shouldFilter |  | ||||||
| } | } | ||||||
|  |  | ||||||
| export default TimelineFiltered | export default TimelineFiltered | ||||||
|   | |||||||
| @@ -10,7 +10,7 @@ import * as DropdownMenu from 'zeego/dropdown-menu' | |||||||
| import StatusContext from './Context' | import StatusContext from './Context' | ||||||
|  |  | ||||||
| const TimelineHeaderAndroid: React.FC = () => { | const TimelineHeaderAndroid: React.FC = () => { | ||||||
|   const { queryKey, rootQueryKey, status, disableDetails, disableOnPress } = |   const { queryKey, rootQueryKey, status, disableDetails, disableOnPress, rawContent } = | ||||||
|     useContext(StatusContext) |     useContext(StatusContext) | ||||||
|  |  | ||||||
|   if (Platform.OS !== 'android' || !status || disableDetails || disableOnPress) return null |   if (Platform.OS !== 'android' || !status || disableDetails || disableOnPress) return null | ||||||
| @@ -21,7 +21,8 @@ const TimelineHeaderAndroid: React.FC = () => { | |||||||
|   const mShare = menuShare({ |   const mShare = menuShare({ | ||||||
|     visibility: status.visibility, |     visibility: status.visibility, | ||||||
|     type: 'status', |     type: 'status', | ||||||
|     url: status.url || status.uri |     url: status.url || status.uri, | ||||||
|  |     rawContent | ||||||
|   }) |   }) | ||||||
|   const mAccount = menuAccount({ |   const mAccount = menuAccount({ | ||||||
|     type: 'status', |     type: 'status', | ||||||
|   | |||||||
| @@ -16,7 +16,7 @@ import HeaderSharedMuted from './HeaderShared/Muted' | |||||||
| import HeaderSharedVisibility from './HeaderShared/Visibility' | import HeaderSharedVisibility from './HeaderShared/Visibility' | ||||||
|  |  | ||||||
| const TimelineHeaderDefault: React.FC = () => { | const TimelineHeaderDefault: React.FC = () => { | ||||||
|   const { queryKey, rootQueryKey, status, copiableContent, highlighted, disableDetails } = |   const { queryKey, rootQueryKey, status, highlighted, disableDetails, rawContent } = | ||||||
|     useContext(StatusContext) |     useContext(StatusContext) | ||||||
|   if (!status) return null |   if (!status) return null | ||||||
|  |  | ||||||
| @@ -28,7 +28,7 @@ const TimelineHeaderDefault: React.FC = () => { | |||||||
|     visibility: status.visibility, |     visibility: status.visibility, | ||||||
|     type: 'status', |     type: 'status', | ||||||
|     url: status.url || status.uri, |     url: status.url || status.uri, | ||||||
|     copiableContent |     rawContent | ||||||
|   }) |   }) | ||||||
|   const mAccount = menuAccount({ |   const mAccount = menuAccount({ | ||||||
|     type: 'status', |     type: 'status', | ||||||
|   | |||||||
| @@ -13,38 +13,19 @@ import { Circle } from 'react-native-animated-spinkit' | |||||||
| import StatusContext from './Context' | import StatusContext from './Context' | ||||||
|  |  | ||||||
| const TimelineTranslate = () => { | const TimelineTranslate = () => { | ||||||
|   const { status, highlighted, copiableContent, detectedLanguage } = useContext(StatusContext) |   const { status, highlighted, rawContent, detectedLanguage } = useContext(StatusContext) | ||||||
|   if (!status || !highlighted) return null |   if (!status || !highlighted || !rawContent?.current.length) return null | ||||||
|  |  | ||||||
|   const { t } = useTranslation('componentTimeline') |   const { t } = useTranslation('componentTimeline') | ||||||
|   const { colors } = useTheme() |   const { colors } = useTheme() | ||||||
|  |  | ||||||
|   const backupTextProcessing = (): string[] => { |  | ||||||
|     const text = status.spoiler_text ? [status.spoiler_text, status.content] : [status.content] |  | ||||||
|  |  | ||||||
|     for (const i in text) { |  | ||||||
|       for (const emoji of status.emojis) { |  | ||||||
|         text[i] = text[i].replaceAll(`:${emoji.shortcode}:`, ' ') |  | ||||||
|       } |  | ||||||
|       text[i] = text[i] |  | ||||||
|         .replace(/(<([^>]+)>)/gi, ' ') |  | ||||||
|         .replace(/@.*? /gi, ' ') |  | ||||||
|         .replace(/#.*? /gi, ' ') |  | ||||||
|         .replace(/http(s):\/\/.*? /gi, ' ') |  | ||||||
|     } |  | ||||||
|     return text |  | ||||||
|   } |  | ||||||
|   const text = copiableContent?.current.content |  | ||||||
|     ? [copiableContent?.current.content] |  | ||||||
|     : backupTextProcessing() |  | ||||||
|  |  | ||||||
|   const [detected, setDetected] = useState<{ |   const [detected, setDetected] = useState<{ | ||||||
|     language: string |     language: string | ||||||
|     confidence: number |     confidence: number | ||||||
|   }>({ language: status.language || '', confidence: 0 }) |   }>({ language: status.language || '', confidence: 0 }) | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     const detect = async () => { |     const detect = async () => { | ||||||
|       const result = await detectLanguage(text.join('\n\n')) |       const result = await detectLanguage(rawContent.current.join('\n\n')) | ||||||
|       if (result) { |       if (result) { | ||||||
|         setDetected(result) |         setDetected(result) | ||||||
|         if (detectedLanguage) { |         if (detectedLanguage) { | ||||||
| @@ -64,7 +45,7 @@ const TimelineTranslate = () => { | |||||||
|   const { refetch, data, isFetching, isSuccess, isError } = useTranslateQuery({ |   const { refetch, data, isFetching, isSuccess, isError } = useTranslateQuery({ | ||||||
|     source: detected.language, |     source: detected.language, | ||||||
|     target: targetLanguage, |     target: targetLanguage, | ||||||
|     text, |     text: rawContent.current, | ||||||
|     options: { enabled } |     options: { enabled } | ||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -7,10 +7,7 @@ const menuShare = ( | |||||||
|   params: |   params: | ||||||
|     | { |     | { | ||||||
|         visibility?: Mastodon.Status['visibility'] |         visibility?: Mastodon.Status['visibility'] | ||||||
|         copiableContent?: React.MutableRefObject<{ |         rawContent?: React.MutableRefObject<string[]> | ||||||
|           content?: string | undefined |  | ||||||
|           complete: boolean |  | ||||||
|         }> |  | ||||||
|         type: 'status' |         type: 'status' | ||||||
|         url?: string |         url?: string | ||||||
|       } |       } | ||||||
| @@ -48,17 +45,17 @@ const menuShare = ( | |||||||
|       icon: 'square.and.arrow.up' |       icon: 'square.and.arrow.up' | ||||||
|     }) |     }) | ||||||
|   } |   } | ||||||
|   if (params.type === 'status' && Platform.OS === 'ios') |   if (params.type === 'status') | ||||||
|     menus[0].push({ |     menus[0].push({ | ||||||
|       key: 'copy', |       key: 'copy', | ||||||
|       item: { |       item: { | ||||||
|         onSelect: () => { |         onSelect: () => { | ||||||
|           Clipboard.setString(params.copiableContent?.current.content || '') |           Clipboard.setString(params.rawContent?.current.join(`\n\n`) || '') | ||||||
|           displayMessage({ type: 'success', message: t(`copy.succeed`) }) |           displayMessage({ type: 'success', message: t(`copy.succeed`) }) | ||||||
|         }, |         }, | ||||||
|         disabled: false, |         disabled: false, | ||||||
|         destructive: false, |         destructive: false, | ||||||
|         hidden: !params.copiableContent?.current.content?.length |         hidden: !params.rawContent?.current.length | ||||||
|       }, |       }, | ||||||
|       title: t('copy.action'), |       title: t('copy.action'), | ||||||
|       icon: 'doc.on.doc' |       icon: 'doc.on.doc' | ||||||
|   | |||||||
| @@ -42,5 +42,9 @@ | |||||||
|   { |   { | ||||||
|     "feature": "notification_type_admin_report", |     "feature": "notification_type_admin_report", | ||||||
|     "version": 4.0 |     "version": 4.0 | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     "feature": "filter_server_side", | ||||||
|  |     "version": 4.0 | ||||||
|   } |   } | ||||||
| ] | ] | ||||||
| @@ -6,6 +6,9 @@ const removeHTML = (text: string): string => { | |||||||
|   const parser = new htmlparser2.Parser({ |   const parser = new htmlparser2.Parser({ | ||||||
|     ontext: (text: string) => { |     ontext: (text: string) => { | ||||||
|       raw = raw + text |       raw = raw + text | ||||||
|  |     }, | ||||||
|  |     onclosetag: (tag: string) => { | ||||||
|  |       if (['p', 'br'].includes(tag)) raw = raw + `\n` | ||||||
|     } |     } | ||||||
|   }) |   }) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -91,7 +91,12 @@ | |||||||
|     "content": { |     "content": { | ||||||
|       "expandHint": "Hidden content" |       "expandHint": "Hidden content" | ||||||
|     }, |     }, | ||||||
|     "filtered": "Filtered: {{phrase}}.", |     "filtered": { | ||||||
|  |       "reveal": "Show anyway", | ||||||
|  |       "match_v1": "Filtered: {{phrase}}.", | ||||||
|  |       "match_v2_one": "Filtered by {{filters}}.", | ||||||
|  |       "match_v2_other": "Filtered by {{count}} filters, {{filters}}." | ||||||
|  |     }, | ||||||
|     "fullConversation": "Read conversations", |     "fullConversation": "Read conversations", | ||||||
|     "translate": { |     "translate": { | ||||||
|       "default": "Translate", |       "default": "Translate", | ||||||
|   | |||||||
| @@ -19,7 +19,7 @@ export type InstanceV10 = { | |||||||
|   } |   } | ||||||
|   version: string |   version: string | ||||||
|   configuration?: Mastodon.Instance['configuration'] |   configuration?: Mastodon.Instance['configuration'] | ||||||
|   filters: Mastodon.Filter[] |   filters: Mastodon.Filter<any>[] | ||||||
|   notifications_filter: { |   notifications_filter: { | ||||||
|     follow: boolean |     follow: boolean | ||||||
|     follow_request: boolean |     follow_request: boolean | ||||||
|   | |||||||
| @@ -18,7 +18,7 @@ export type InstanceV11 = { | |||||||
|   } |   } | ||||||
|   version: string |   version: string | ||||||
|   configuration?: Mastodon.Instance['configuration'] |   configuration?: Mastodon.Instance['configuration'] | ||||||
|   filters: Mastodon.Filter[] |   filters: Mastodon.Filter<any>[] | ||||||
|   notifications_filter: { |   notifications_filter: { | ||||||
|     follow: boolean |     follow: boolean | ||||||
|     follow_request: boolean |     follow_request: boolean | ||||||
|   | |||||||
| @@ -17,7 +17,7 @@ type Instance = { | |||||||
|     avatarStatic: Mastodon.Account['avatar_static'] |     avatarStatic: Mastodon.Account['avatar_static'] | ||||||
|     preferences: Mastodon.Preferences |     preferences: Mastodon.Preferences | ||||||
|   } |   } | ||||||
|   filters: Mastodon.Filter[] |   filters: Mastodon.Filter<any>[] | ||||||
|   notifications_filter: { |   notifications_filter: { | ||||||
|     follow: boolean |     follow: boolean | ||||||
|     favourite: boolean |     favourite: boolean | ||||||
|   | |||||||
| @@ -18,7 +18,7 @@ type Instance = { | |||||||
|   } |   } | ||||||
|   max_toot_chars?: number // To be deprecated in v4 |   max_toot_chars?: number // To be deprecated in v4 | ||||||
|   configuration?: Mastodon.Instance['configuration'] |   configuration?: Mastodon.Instance['configuration'] | ||||||
|   filters: Mastodon.Filter[] |   filters: Mastodon.Filter<any>[] | ||||||
|   notifications_filter: { |   notifications_filter: { | ||||||
|     follow: boolean |     follow: boolean | ||||||
|     favourite: boolean |     favourite: boolean | ||||||
|   | |||||||
| @@ -19,7 +19,7 @@ type Instance = { | |||||||
|   } |   } | ||||||
|   max_toot_chars?: number // To be deprecated in v4 |   max_toot_chars?: number // To be deprecated in v4 | ||||||
|   configuration?: Mastodon.Instance['configuration'] |   configuration?: Mastodon.Instance['configuration'] | ||||||
|   filters: Mastodon.Filter[] |   filters: Mastodon.Filter<any>[] | ||||||
|   notifications_filter: { |   notifications_filter: { | ||||||
|     follow: boolean |     follow: boolean | ||||||
|     favourite: boolean |     favourite: boolean | ||||||
|   | |||||||
| @@ -19,7 +19,7 @@ type Instance = { | |||||||
|   } |   } | ||||||
|   max_toot_chars?: number // To be deprecated in v4 |   max_toot_chars?: number // To be deprecated in v4 | ||||||
|   configuration?: Mastodon.Instance['configuration'] |   configuration?: Mastodon.Instance['configuration'] | ||||||
|   filters: Mastodon.Filter[] |   filters: Mastodon.Filter<any>[] | ||||||
|   notifications_filter: { |   notifications_filter: { | ||||||
|     follow: boolean |     follow: boolean | ||||||
|     favourite: boolean |     favourite: boolean | ||||||
|   | |||||||
| @@ -19,7 +19,7 @@ export type InstanceV9 = { | |||||||
|   } |   } | ||||||
|   version: string |   version: string | ||||||
|   configuration?: Mastodon.Instance['configuration'] |   configuration?: Mastodon.Instance['configuration'] | ||||||
|   filters: Mastodon.Filter[] |   filters: Mastodon.Filter<any>[] | ||||||
|   notifications_filter: { |   notifications_filter: { | ||||||
|     follow: boolean |     follow: boolean | ||||||
|     favourite: boolean |     favourite: boolean | ||||||
|   | |||||||
| @@ -52,7 +52,7 @@ const addInstance = createAsyncThunk( | |||||||
|       headers: { Authorization: `Bearer ${token}` } |       headers: { Authorization: `Bearer ${token}` } | ||||||
|     }) |     }) | ||||||
|  |  | ||||||
|     const { body: filters } = await apiGeneral<Mastodon.Filter[]>({ |     const { body: filters } = await apiGeneral<Mastodon.Filter<any>[]>({ | ||||||
|       method: 'get', |       method: 'get', | ||||||
|       domain, |       domain, | ||||||
|       url: `api/v1/filters`, |       url: `api/v1/filters`, | ||||||
|   | |||||||
| @@ -3,8 +3,8 @@ import { createAsyncThunk } from '@reduxjs/toolkit' | |||||||
|  |  | ||||||
| export const updateFilters = createAsyncThunk( | export const updateFilters = createAsyncThunk( | ||||||
|   'instances/updateFilters', |   'instances/updateFilters', | ||||||
|   async (): Promise<Mastodon.Filter[]> => { |   async (): Promise<Mastodon.Filter<any>[]> => { | ||||||
|     return apiInstance<Mastodon.Filter[]>({ |     return apiInstance<Mastodon.Filter<any>[]>({ | ||||||
|       method: 'get', |       method: 'get', | ||||||
|       url: `filters` |       url: `filters` | ||||||
|     }).then(res => res.body) |     }).then(res => res.body) | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user