mirror of
https://github.com/tooot-app/app
synced 2025-04-04 05:31:05 +02:00
Fixed #572
This commit is contained in:
parent
c0aad41047
commit
2c7772d4c2
@ -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,129 +1,133 @@
|
|||||||
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 { colors } = useTheme()
|
}
|
||||||
const { t } = useTranslation('componentTimeline')
|
|
||||||
|
|
||||||
return (
|
const TimelineFiltered: React.FC<FilteredProps> = ({ filterResults }) => {
|
||||||
<View style={{ backgroundColor: colors.backgroundDefault }}>
|
const { colors } = useTheme()
|
||||||
<CustomText
|
const { t } = useTranslation('componentTimeline')
|
||||||
fontStyle='S'
|
|
||||||
style={{
|
|
||||||
color: colors.secondary,
|
|
||||||
textAlign: 'center',
|
|
||||||
paddingVertical: StyleConstants.Spacing.S,
|
|
||||||
paddingLeft: StyleConstants.Avatar.M + StyleConstants.Spacing.S
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('shared.filtered', { phrase })}
|
|
||||||
</CustomText>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
() => true
|
|
||||||
)
|
|
||||||
|
|
||||||
export const shouldFilter = ({
|
const main = () => {
|
||||||
copiableContent,
|
if (!filterResults?.length) {
|
||||||
status,
|
return <></>
|
||||||
queryKey
|
|
||||||
}: {
|
|
||||||
copiableContent: React.MutableRefObject<{
|
|
||||||
content: string
|
|
||||||
complete: boolean
|
|
||||||
}>
|
|
||||||
status: Mastodon.Status
|
|
||||||
queryKey: QueryKeyTimeline
|
|
||||||
}): string | null => {
|
|
||||||
const page = queryKey[1]
|
|
||||||
const instance = getInstance(store.getState())
|
|
||||||
const ownAccount = getInstanceAccount(store.getState())?.id === status.account?.id
|
|
||||||
|
|
||||||
let shouldFilter: string | null = null
|
|
||||||
|
|
||||||
if (!ownAccount) {
|
|
||||||
let rawContent = ''
|
|
||||||
const parser = new htmlparser2.Parser({
|
|
||||||
ontext: (text: string) => {
|
|
||||||
if (!copiableContent.current.complete) {
|
|
||||||
copiableContent.current.content = copiableContent.current.content + text
|
|
||||||
}
|
|
||||||
|
|
||||||
rawContent = rawContent + text
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (status.spoiler_text) {
|
|
||||||
parser.write(status.spoiler_text)
|
|
||||||
rawContent = rawContent + `\n\n`
|
|
||||||
}
|
}
|
||||||
parser.write(status.content)
|
switch (typeof filterResults[0]) {
|
||||||
parser.end()
|
case 'string': // v1 filter
|
||||||
|
return <>{t('shared.filtered.match', { context: 'v1', phrase: filterResults[0] })}</>
|
||||||
const checkFilter = (filter: Mastodon.Filter) => {
|
default:
|
||||||
const escapedPhrase = filter.phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string
|
return (
|
||||||
switch (filter.whole_word) {
|
<>
|
||||||
case true:
|
{t('shared.filtered.match', {
|
||||||
if (new RegExp(`\\b${escapedPhrase}\\b`, 'i').test(rawContent)) {
|
context: 'v2',
|
||||||
shouldFilter = filter.phrase
|
count: filterResults.length,
|
||||||
}
|
filters: filterResults.map(result => result.title).join(t('common:separator'))
|
||||||
break
|
})}
|
||||||
case false:
|
<CustomText
|
||||||
if (new RegExp(escapedPhrase, 'i').test(rawContent)) {
|
style={{ color: colors.blue }}
|
||||||
shouldFilter = filter.phrase
|
children={`\n${t('shared.filtered.reveal')}`}
|
||||||
}
|
/>
|
||||||
break
|
</>
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
instance?.filters?.forEach(filter => {
|
|
||||||
if (shouldFilter) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (filter.expires_at) {
|
|
||||||
if (new Date().getTime() > new Date(filter.expires_at).getTime()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (page.page) {
|
|
||||||
case 'Following':
|
|
||||||
case 'Local':
|
|
||||||
case 'List':
|
|
||||||
case 'Account':
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
copiableContent.current.complete = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return shouldFilter
|
return (
|
||||||
|
<View style={{ backgroundColor: colors.backgroundDefault }}>
|
||||||
|
<CustomText
|
||||||
|
fontStyle='S'
|
||||||
|
style={{
|
||||||
|
color: colors.secondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingVertical: StyleConstants.Spacing.S,
|
||||||
|
paddingLeft: StyleConstants.Avatar.M + StyleConstants.Spacing.S
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{main()}
|
||||||
|
</CustomText>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const shouldFilter = ({
|
||||||
|
queryKey,
|
||||||
|
status
|
||||||
|
}: {
|
||||||
|
queryKey: QueryKeyTimeline
|
||||||
|
status: Pick<Mastodon.Status, 'content' | 'spoiler_text'>
|
||||||
|
}): FilteredProps['filterResults'] | undefined => {
|
||||||
|
const page = queryKey[1]
|
||||||
|
const instance = getInstance(store.getState())
|
||||||
|
|
||||||
|
let returnFilter: FilteredProps['filterResults'] | undefined
|
||||||
|
|
||||||
|
const rawContentCombined = [
|
||||||
|
removeHTML(status.content),
|
||||||
|
status.spoiler_text ? removeHTML(status.spoiler_text) : ''
|
||||||
|
]
|
||||||
|
.filter(c => c.length)
|
||||||
|
.join(`\n`)
|
||||||
|
const checkFilter = (filter: Mastodon.Filter<'v1'>) => {
|
||||||
|
const escapedPhrase = filter.phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string
|
||||||
|
switch (filter.whole_word) {
|
||||||
|
case true:
|
||||||
|
if (new RegExp(`\\b${escapedPhrase}\\b`, 'i').test(rawContentCombined)) {
|
||||||
|
returnFilter = [{ title: filter.phrase, filter_action: 'warn' }]
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case false:
|
||||||
|
if (new RegExp(escapedPhrase, 'i').test(rawContentCombined)) {
|
||||||
|
returnFilter = [{ title: filter.phrase, filter_action: 'warn' }]
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
instance?.filters?.forEach(filter => {
|
||||||
|
if (returnFilter) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (filter.expires_at) {
|
||||||
|
if (new Date().getTime() > new Date(filter.expires_at).getTime()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (page.page) {
|
||||||
|
case 'Following':
|
||||||
|
case 'Local':
|
||||||
|
case 'List':
|
||||||
|
case 'Account':
|
||||||
|
if (filter.context.includes('home')) {
|
||||||
|
checkFilter(filter as Mastodon.Filter<'v1'>)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'Notifications':
|
||||||
|
if (filter.context.includes('notifications')) {
|
||||||
|
checkFilter(filter as Mastodon.Filter<'v1'>)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'LocalPublic':
|
||||||
|
if (filter.context.includes('public')) {
|
||||||
|
checkFilter(filter as Mastodon.Filter<'v1'>)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'Toot':
|
||||||
|
if (filter.context.includes('thread')) {
|
||||||
|
checkFilter(filter as Mastodon.Filter<'v1'>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return returnFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user