This commit is contained in:
xmflsct 2022-12-18 17:25:18 +01:00
parent c0aad41047
commit 2c7772d4c2
23 changed files with 251 additions and 196 deletions

View File

@ -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

View File

@ -1 +1,2 @@
toooting愉快此版本包括以下改进和修复 toooting愉快此版本包括以下改进和修复
- 改进过滤体验与v4.0以上版本一致

View File

@ -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 = {

View File

@ -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',

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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',

View File

@ -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',

View File

@ -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 }
}) })

View File

@ -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'

View File

@ -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
} }
] ]

View File

@ -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`
} }
}) })

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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`,

View File

@ -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)