Merge pull request #141 from tooot-app/main

Release v2.0.2
This commit is contained in:
xmflsct 2021-05-31 00:42:30 +02:00 committed by GitHub
commit 751de9ff30
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 212 additions and 51 deletions

View File

@ -197,7 +197,7 @@ private_lane :build_android do
end end
lane :build do lane :build do
releaseExists = get_github_release(url: GITHUB_REPO, version: GITHUB_RELEASE, api_token: ENV['GH_PAT_GET_RELEASE']) releaseExists = get_github_release(url: GITHUB_REPO, version: "v#{VERSION}", api_token: ENV['GH_PAT_GET_RELEASE'])
if releaseExists if releaseExists
puts("Release #{GITHUB_RELEASE} exists. Continue with building React Native only.") puts("Release #{GITHUB_RELEASE} exists. Continue with building React Native only.")
else else

View File

@ -4,7 +4,7 @@
"native": "210511", "native": "210511",
"major": 2, "major": 2,
"minor": 0, "minor": 0,
"patch": 1, "patch": 2,
"expo": "41.0.0" "expo": "41.0.0"
}, },
"description": "tooot app for Mastodon", "description": "tooot app for Mastodon",

View File

@ -261,6 +261,15 @@ declare namespace Mastodon {
verified_at: string | null verified_at: string | null
} }
type Filter = {
id: string
phrase: string
context: ('home' | 'notifications' | 'public' | 'thread' | 'account')[]
expires_at?: string
irreversible: boolean
whole_word: boolean
}
type List = { type List = {
id: string id: string
title: string title: string

View File

@ -13,6 +13,7 @@ import pushUseReceive from '@utils/push/useReceive'
import pushUseRespond from '@utils/push/useRespond' import pushUseRespond from '@utils/push/useRespond'
import { updatePreviousTab } from '@utils/slices/contextsSlice' import { updatePreviousTab } from '@utils/slices/contextsSlice'
import { updateAccountPreferences } from '@utils/slices/instances/updateAccountPreferences' import { updateAccountPreferences } from '@utils/slices/instances/updateAccountPreferences'
import { updateFilters } from '@utils/slices/instances/updateFilters'
import { getInstanceActive, getInstances } from '@utils/slices/instancesSlice' import { getInstanceActive, getInstances } from '@utils/slices/instancesSlice'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import { themes } from '@utils/styles/themes' import { themes } from '@utils/styles/themes'
@ -106,6 +107,7 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
// Lazily update users's preferences, for e.g. composing default visibility // Lazily update users's preferences, for e.g. composing default visibility
useEffect(() => { useEffect(() => {
if (instanceActive !== -1) { if (instanceActive !== -1) {
dispatch(updateFilters())
dispatch(updateAccountPreferences()) dispatch(updateAccountPreferences())
} }
}, [instanceActive]) }, [instanceActive])

View File

@ -69,7 +69,10 @@ const apiGeneral = async <T = unknown>({
error.response.status, error.response.status,
error.response.data.error error.response.data.error
) )
return Promise.reject(error.response.data.error) return Promise.reject({
status: error.response.status,
message: error.response.data.error
})
} else if (error.request) { } else if (error.request) {
// The request was made but no response was received // The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of // `error.request` is an instance of XMLHttpRequest in the browser and an instance of

View File

@ -98,7 +98,10 @@ const apiInstance = async <T = unknown>({
error.response.status, error.response.status,
error.response.data.error error.response.data.error
) )
return Promise.reject(error.response.data.error) return Promise.reject({
status: error.response.status,
message: error.response.data.error
})
} else if (error.request) { } else if (error.request) {
// The request was made but no response was received // The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of // `error.request` is an instance of XMLHttpRequest in the browser and an instance of

View File

@ -1,5 +1,5 @@
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useMemo, useRef, useState } from 'react' import React, { useCallback, useMemo, useState } from 'react'
import { import {
AccessibilityProps, AccessibilityProps,
Image, Image,
@ -52,32 +52,30 @@ const GracefullyImage = React.memo(
setImageDimensions setImageDimensions
}: Props) => { }: Props) => {
const { theme } = useTheme() const { theme } = useTheme()
const originalFailed = useRef(false) const [originalFailed, setOriginalFailed] = useState(false)
const [imageLoaded, setImageLoaded] = useState(false) const [imageLoaded, setImageLoaded] = useState(false)
const source = useMemo(() => { const source = useMemo(() => {
if (originalFailed.current) { if (originalFailed) {
return { uri: uri.remote || undefined } return { uri: uri.remote || undefined }
} else { } else {
return { uri: uri.original } return { uri: uri.original }
} }
}, [originalFailed.current]) }, [originalFailed])
const onLoad = useCallback(
({ nativeEvent }) => { const onLoad = useCallback(() => {
setImageLoaded(true) setImageLoaded(true)
setImageDimensions && if (setImageDimensions && source.uri) {
setImageDimensions({ Image.getSize(source.uri, (width, height) =>
width: nativeEvent.source.width, setImageDimensions({ width, height })
height: nativeEvent.source.height )
})
},
[source.uri]
)
const onError = useCallback(() => {
if (!originalFailed.current) {
originalFailed.current = true
} }
}, [originalFailed.current]) }, [source.uri])
const onError = useCallback(() => {
if (!originalFailed) {
setOriginalFailed(true)
}
}, [originalFailed])
const previewView = useMemo( const previewView = useMemo(
() => () =>

View File

@ -76,11 +76,10 @@ const ParseEmojis = React.memo(
: emojis[emojiIndex].url : emojis[emojiIndex].url
if (validUrl.isHttpsUri(uri)) { if (validUrl.isHttpsUri(uri)) {
return ( return (
<FastImage <Text key={emojiShortcode + i}>
key={emojiShortcode + i} {i === 0 ? ' ' : undefined}
source={{ uri }} <FastImage source={{ uri }} style={styles.image} />
style={styles.image} </Text>
/>
) )
} else { } else {
return null return null

View File

@ -10,14 +10,16 @@ import TimelinePoll from '@components/Timeline/Shared/Poll'
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 { QueryKeyTimeline } from '@utils/queryHooks/timeline' import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { getInstanceAccount } from '@utils/slices/instancesSlice' import { getInstance, getInstanceAccount } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants' import { 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 { uniqBy } from 'lodash' import { uniqBy } from 'lodash'
import React, { useCallback } from 'react' import React, { useCallback } from 'react'
import { Pressable, StyleSheet, View } from 'react-native' import { Pressable, StyleSheet, View } from 'react-native'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import TimelineActionsUsers from './Shared/ActionsUsers' import TimelineActionsUsers from './Shared/ActionsUsers'
import TimelineFiltered, { shouldFilter } from './Shared/Filtered'
import TimelineFullConversation from './Shared/FullConversation' import TimelineFullConversation from './Shared/FullConversation'
import TimelineTranslate from './Shared/Translate' import TimelineTranslate from './Shared/Translate'
@ -49,6 +51,16 @@ const TimelineDefault: React.FC<Props> = ({
let actualStatus = item.reblog ? item.reblog : item let actualStatus = item.reblog ? item.reblog : item
const ownAccount = actualStatus.account.id === instanceAccount?.id
if (
!highlighted &&
queryKey &&
shouldFilter({ status: actualStatus, queryKey })
) {
return <TimelineFiltered />
}
const onPress = useCallback(() => { const onPress = useCallback(() => {
analytics('timeline_default_press', { analytics('timeline_default_press', {
page: queryKey ? queryKey[1].page : origin page: queryKey ? queryKey[1].page : origin
@ -118,7 +130,7 @@ const TimelineDefault: React.FC<Props> = ({
statusId={actualStatus.id} statusId={actualStatus.id}
poll={actualStatus.poll} poll={actualStatus.poll}
reblog={item.reblog ? true : false} reblog={item.reblog ? true : false}
sameAccount={actualStatus.account.id === instanceAccount?.id} sameAccount={ownAccount}
/> />
) : null} ) : null}
{!disableDetails && {!disableDetails &&

View File

@ -17,6 +17,7 @@ import { uniqBy } from 'lodash'
import React, { useCallback } from 'react' import React, { useCallback } from 'react'
import { Pressable, StyleSheet, View } from 'react-native' import { Pressable, StyleSheet, View } from 'react-native'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import TimelineFiltered, { shouldFilter } from './Shared/Filtered'
import TimelineFullConversation from './Shared/FullConversation' import TimelineFullConversation from './Shared/FullConversation'
export interface Props { export interface Props {
@ -30,6 +31,13 @@ const TimelineNotifications: React.FC<Props> = ({
queryKey, queryKey,
highlighted = false highlighted = false
}) => { }) => {
if (
notification.status &&
shouldFilter({ status: notification.status, queryKey })
) {
return <TimelineFiltered />
}
const { theme } = useTheme() const { theme } = useTheme()
const instanceAccount = useSelector( const instanceAccount = useSelector(
getInstanceAccount, getInstanceAccount,
@ -38,6 +46,7 @@ const TimelineNotifications: React.FC<Props> = ({
const navigation = useNavigation< const navigation = useNavigation<
StackNavigationProp<Nav.TabLocalStackParamList> StackNavigationProp<Nav.TabLocalStackParamList>
>() >()
const actualAccount = notification.status const actualAccount = notification.status
? notification.status.account ? notification.status.account
: notification.account : notification.account

View File

@ -8,7 +8,7 @@ import AttachmentVideo from '@components/Timeline/Shared/Attachment/Video'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import layoutAnimation from '@utils/styles/layoutAnimation' import layoutAnimation from '@utils/styles/layoutAnimation'
import React, { useCallback, useMemo, useState } from 'react' import React, { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Pressable, StyleSheet, View } from 'react-native' import { Pressable, StyleSheet, View } from 'react-native'
@ -33,11 +33,13 @@ const TimelineAttachment = React.memo(
haptics('Light') haptics('Light')
}, []) }, [])
let imageUrls: Nav.RootStackParamList['Screen-ImagesViewer']['imageUrls'] = [] const imageUrls = useRef<
Nav.RootStackParamList['Screen-ImagesViewer']['imageUrls']
>([])
const navigation = useNavigation() const navigation = useNavigation()
const navigateToImagesViewer = (id: string) => const navigateToImagesViewer = (id: string) =>
navigation.navigate('Screen-ImagesViewer', { navigation.navigate('Screen-ImagesViewer', {
imageUrls, imageUrls: imageUrls.current,
id id
}) })
const attachments = useMemo( const attachments = useMemo(
@ -45,7 +47,7 @@ const TimelineAttachment = React.memo(
status.media_attachments.map((attachment, index) => { status.media_attachments.map((attachment, index) => {
switch (attachment.type) { switch (attachment.type) {
case 'image': case 'image':
imageUrls.push({ imageUrls.current.push({
id: attachment.id, id: attachment.id,
preview_url: attachment.preview_url, preview_url: attachment.preview_url,
url: attachment.url, url: attachment.url,
@ -106,7 +108,7 @@ const TimelineAttachment = React.memo(
attachment.remote_url?.endsWith('.png') || attachment.remote_url?.endsWith('.png') ||
attachment.remote_url?.endsWith('.gif') attachment.remote_url?.endsWith('.gif')
) { ) {
imageUrls.push({ imageUrls.current.push({
id: attachment.id, id: attachment.id,
preview_url: attachment.preview_url, preview_url: attachment.preview_url,
url: attachment.url, url: attachment.url,

View File

@ -0,0 +1,105 @@
import { store } from '@root/store'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { getInstance, getInstanceAccount } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import htmlparser2 from 'htmlparser2-without-node-native'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Text, View } from 'react-native'
const TimelineFiltered = React.memo(
() => {
const { theme } = useTheme()
const { t } = useTranslation('componentTimeline')
return (
<View style={{ backgroundColor: theme.backgroundDefault }}>
<Text
style={{
...StyleConstants.FontStyle.S,
color: theme.secondary,
textAlign: 'center',
paddingVertical: StyleConstants.Spacing.S,
paddingLeft: StyleConstants.Avatar.M + StyleConstants.Spacing.S
}}
>
{t('shared.filtered')}
</Text>
</View>
)
},
() => true
)
export const shouldFilter = ({
status,
queryKey
}: {
status: Mastodon.Status
queryKey: QueryKeyTimeline
}) => {
const instance = getInstance(store.getState())
const ownAccount =
getInstanceAccount(store.getState())?.id === status.account.id
let shouldFilter = false
if (queryKey && !ownAccount) {
const parser = new htmlparser2.Parser({
ontext (text: string) {
const checkFilter = (filter: Mastodon.Filter) => {
switch (filter.whole_word) {
case true:
if (new RegExp('\\b' + filter.phrase + '\\b').test(text)) {
shouldFilter = true
}
break
case false:
if (new RegExp(filter.phrase).test(text)) {
shouldFilter = true
}
break
}
}
instance?.filters.forEach(filter => {
if (filter.expires_at) {
if (new Date().getTime() > new Date(filter.expires_at).getTime()) {
return
}
}
switch (queryKey[1].page) {
case 'Following':
case 'Local':
case 'List':
case 'Account_Default':
if (filter.context.includes('home')) {
checkFilter(filter)
}
break
case 'Notifications':
if (filter.context.includes('notifications')) {
checkFilter(filter)
}
break
case 'LocalPublic':
if (filter.context.includes('public')) {
checkFilter(filter)
}
break
case 'Toot':
if (filter.context.includes('thread')) {
checkFilter(filter)
}
}
})
}
})
parser.write(status.content)
parser.end()
}
return shouldFilter
}
export default TimelineFiltered

View File

@ -31,7 +31,7 @@ const TimelineTranslate = React.memo(
const settingsLanguage = useSelector(getSettingsLanguage) const settingsLanguage = useSelector(getSettingsLanguage)
if (settingsLanguage.includes(tootLanguage)) { if (settingsLanguage?.includes(tootLanguage)) {
return null return null
} }

View File

@ -73,6 +73,7 @@
"content": { "content": {
"expandHint": "hidden content" "expandHint": "hidden content"
}, },
"filtered": "Filtered",
"fullConversation": "Read conversations", "fullConversation": "Read conversations",
"translate": { "translate": {
"default": "Translate", "default": "Translate",

View File

@ -73,6 +73,7 @@
"content": { "content": {
"expandHint": "隐藏内容" "expandHint": "隐藏内容"
}, },
"filtered": "已过滤",
"fullConversation": "阅读全部对话", "fullConversation": "阅读全部对话",
"translate": { "translate": {
"default": "翻译", "default": "翻译",

View File

@ -21,8 +21,6 @@ const ComposeTextInput: React.FC = () => {
borderBottomColor: theme.border borderBottomColor: theme.border
} }
]} ]}
autoCapitalize='none'
autoCorrect={false}
autoFocus autoFocus
enablesReturnKeyAutomatically enablesReturnKeyAutomatically
multiline multiline

View File

@ -52,8 +52,8 @@ const ImageItem = ({
const scrollViewRef = useRef<ScrollView>(null) const scrollViewRef = useRef<ScrollView>(null)
const [scaled, setScaled] = useState(false) const [scaled, setScaled] = useState(false)
const [imageDimensions, setImageDimensions] = useState({ const [imageDimensions, setImageDimensions] = useState({
width: imageSrc.width || 0, width: imageSrc.width || 1,
height: imageSrc.height || 0 height: imageSrc.height || 1
}) })
const handleDoubleTap = useDoubleTapToZoom(scrollViewRef, scaled, SCREEN) const handleDoubleTap = useDoubleTapToZoom(scrollViewRef, scaled, SCREEN)

View File

@ -58,7 +58,7 @@ const saveIos = async ({ messageRef, mode, image }: CommonProps) => {
} }
const saveAndroid = async ({ messageRef, mode, image }: CommonProps) => { const saveAndroid = async ({ messageRef, mode, image }: CommonProps) => {
const fileUri: string = `${FileSystem.documentDirectory}test.jpg` const fileUri: string = `${FileSystem.documentDirectory}${image.id}.jpg`
const downloadedFile: FileSystem.FileSystemDownloadResult = await FileSystem.downloadAsync( const downloadedFile: FileSystem.FileSystemDownloadResult = await FileSystem.downloadAsync(
image.url, image.url,
fileUri fileUri
@ -80,7 +80,7 @@ const saveAndroid = async ({ messageRef, mode, image }: CommonProps) => {
ref: messageRef, ref: messageRef,
mode, mode,
type: 'success', type: 'success',
message: 'test' message: i18next.t('screenImageViewer:content.save.succeed')
}) })
}) })
.catch(() => { .catch(() => {

View File

@ -43,11 +43,7 @@ const netInfo = async (): Promise<{
}) })
.catch(error => { .catch(error => {
log('error', 'netInfo', 'local credential check failed') log('error', 'netInfo', 'local credential check failed')
if ( if (error.status && error.status == 401) {
error.status &&
typeof error.status === 'number' &&
error.status === 401
) {
store.dispatch(removeInstance(instance)) store.dispatch(removeInstance(instance))
} }
return Promise.resolve({ return Promise.resolve({

View File

@ -0,0 +1,12 @@
import apiInstance from '@api/instance'
import { createAsyncThunk } from '@reduxjs/toolkit'
export const updateFilters = createAsyncThunk(
'instances/updateFilters',
async (): Promise<Mastodon.Filter[]> => {
return apiInstance<Mastodon.Filter[]>({
method: 'get',
url: `filters`
}).then(res => res.body)
}
)

View File

@ -6,6 +6,7 @@ import { findIndex } from 'lodash'
import addInstance from './instances/add' import addInstance from './instances/add'
import removeInstance from './instances/remove' import removeInstance from './instances/remove'
import { updateAccountPreferences } from './instances/updateAccountPreferences' import { updateAccountPreferences } from './instances/updateAccountPreferences'
import { updateFilters } from './instances/updateFilters'
import { updateInstancePush } from './instances/updatePush' import { updateInstancePush } from './instances/updatePush'
import { updateInstancePushAlert } from './instances/updatePushAlert' import { updateInstancePushAlert } from './instances/updatePushAlert'
import { updateInstancePushDecode } from './instances/updatePushDecode' import { updateInstancePushDecode } from './instances/updatePushDecode'
@ -29,6 +30,7 @@ export type Instance = {
avatarStatic: Mastodon.Account['avatar_static'] avatarStatic: Mastodon.Account['avatar_static']
preferences: Mastodon.Preferences preferences: Mastodon.Preferences
} }
filters: Mastodon.Filter[]
notifications_filter: { notifications_filter: {
follow: boolean follow: boolean
favourite: boolean favourite: boolean
@ -236,6 +238,15 @@ const instancesSlice = createSlice({
console.error(action.error) console.error(action.error)
}) })
// Update Instance Account Filters
.addCase(updateFilters.fulfilled, (state, action) => {
const activeIndex = findInstanceActive(state.instances)
state.instances[activeIndex].filters = action.payload
})
.addCase(updateFilters.rejected, (_, action) => {
console.error(action.error)
})
// Update Instance Account Preferences // Update Instance Account Preferences
.addCase(updateAccountPreferences.fulfilled, (state, action) => { .addCase(updateAccountPreferences.fulfilled, (state, action) => {
const activeIndex = findInstanceActive(state.instances) const activeIndex = findInstanceActive(state.instances)

View File

@ -4,7 +4,7 @@ import * as Analytics from 'expo-firebase-analytics'
import * as Localization from 'expo-localization' import * as Localization from 'expo-localization'
import { pickBy } from 'lodash' import { pickBy } from 'lodash'
enum availableLanguages { enum AvailableLanguages {
'zh-Hans', 'zh-Hans',
'en' 'en'
} }
@ -19,7 +19,7 @@ export const changeAnalytics = createAsyncThunk(
export type SettingsState = { export type SettingsState = {
fontsize: -1 | 0 | 1 | 2 | 3 fontsize: -1 | 0 | 1 | 2 | 3
language: keyof availableLanguages language: string
theme: 'light' | 'dark' | 'auto' theme: 'light' | 'dark' | 'auto'
browser: 'internal' | 'external' browser: 'internal' | 'external'
analytics: boolean analytics: boolean
@ -31,10 +31,10 @@ export const settingsInitialState = {
enabled: false enabled: false
}, },
language: Object.keys( language: Object.keys(
pickBy(availableLanguages, (_, key) => Localization.locale.includes(key)) pickBy(AvailableLanguages, (_, key) => Localization.locale.includes(key))
) )
? Object.keys( ? Object.keys(
pickBy(availableLanguages, (_, key) => pickBy(AvailableLanguages, (_, key) =>
Localization.locale.includes(key) Localization.locale.includes(key)
) )
)[0] )[0]