Merge pull request #545 from tooot-app/main

Test latest updates
This commit is contained in:
xmflsct 2022-12-10 02:00:46 +01:00 committed by GitHub
commit 772459d4ae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
95 changed files with 1116 additions and 1545 deletions

View File

@ -108,6 +108,29 @@ private_lane :build_android do
end
end
desc "Build Android apk"
private_lane :build_android_apk do
sh("echo #{ENV["ANDROID_KEYSTORE"]} | base64 -d | tee #{File.expand_path('..', Dir.pwd)}/android/tooot.jks >/dev/null", log: false)
prepare_playstore_android
build_android_app(
task: 'assemble',
build_type: 'release',
project_dir: "./android",
print_command: true,
print_command_output: true,
properties: {
"android.injected.signing.store.file" => "#{File.expand_path('..', Dir.pwd)}/android/tooot.jks",
"android.injected.signing.store.password" => ENV["ANDROID_KEYSTORE_PASSWORD"],
"android.injected.signing.key.alias" => ENV["ANDROID_KEYSTORE_ALIAS"],
"android.injected.signing.key.password" => ENV["ANDROID_KEYSTORE_KEY_PASSWORD"],
}
)
sh "mv #{lane_context[SharedValues::GRADLE_APK_OUTPUT_PATH]} #{File.expand_path('..', Dir.pwd)}/tooot-#{GITHUB_RELEASE}.apk"
end
lane :ios do
cocoapods(clean_install: true, podfile: "./ios/Podfile")
build_ios
@ -121,6 +144,7 @@ end
lane :release do
if ENVIRONMENT == 'release'
build_android_apk
set_github_release(
repository_name: GITHUB_REPO,
name: GITHUB_RELEASE,
@ -128,6 +152,7 @@ lane :release do
description: "No changelog provided",
commitish: git_branch,
is_prerelease: false
upload_assets: ["#{File.expand_path('..', Dir.pwd)}/tooot-#{GITHUB_RELEASE}.apk"]
)
end
rocket

View File

@ -1 +1,5 @@
Enjoy using tooot
Enjoy toooting! This version includes following improvements and fixes:
- Automatic setting detected language when tooting
- Added notification for admins
- Fix whole word filter matching
- Fix tablet cannot delete toot drafts

View File

@ -1 +1,5 @@
tooot使用愉快
toooting愉快此版本包括以下改进和修复
- 自动识别发嘟语言
- 新增管理员推送通知
- 修复过滤整词功能
- 修复平板不能删除草稿

View File

@ -301,7 +301,7 @@ PODS:
- React-Core
- react-native-ios-context-menu (1.15.1):
- React-Core
- react-native-language-detection (0.1.0):
- react-native-language-detection (0.2.2):
- React
- react-native-live-text-image-view (0.4.0):
- React-Core
@ -739,7 +739,7 @@ SPEC CHECKSUMS:
react-native-cameraroll: a40b082318eb1ecd0336a2f29d9f74b7f2c8cae8
react-native-image-picker: bf34f3f516d139ed3e24c5f5a381a91819e349ea
react-native-ios-context-menu: b170594b4448c0cd10c79e13432216bac99de1ac
react-native-language-detection: 0e43195ad014974f1b7a31b64820eff34a243f2d
react-native-language-detection: f414937fa715108ab50a6269a3de0bcb95e4ceb0
react-native-live-text-image-view: 483bacfdba464162b8cf176bba555364f18b584c
react-native-menu: 8e172cfcf0e42e92f028e7781eddf84d430cae24
react-native-netinfo: 2517ad504b3d303e90d7a431b0fcaef76d207983

View File

@ -1,6 +1,6 @@
{
"name": "tooot",
"version": "4.6.6",
"version": "4.7.0",
"description": "tooot for Mastodon",
"author": "xmflsct <me@xmflsct.com>",
"license": "GPL-3.0-or-later",
@ -12,7 +12,7 @@
"start": "react-native start",
"android": "react-native run-android",
"iphone": "react-native run-ios --simulator 'iPhone 14 Pro'",
"ipad": "react-native run-ios --simulator 'iPad Pro (11-inch) (3rd generation)'",
"ipad": "react-native run-ios --simulator 'iPad Pro (11-inch) (4th generation)'",
"app:build": "bundle exec fastlane",
"clean": "react-native-clean-project",
"postinstall": "patch-package"
@ -78,7 +78,7 @@
"react-native-htmlview": "^0.16.0",
"react-native-image-picker": "^4.10.2",
"react-native-ios-context-menu": "^1.15.1",
"react-native-language-detection": "^0.1.0",
"react-native-language-detection": "^0.2.2",
"react-native-live-text-image-view": "^0.4.0",
"react-native-pager-view": "^6.1.2",
"react-native-reanimated": "^2.13.0",

View File

@ -30,6 +30,7 @@ declare namespace Mastodon {
bot: boolean
source?: Source
suspended?: boolean
role?: Role
}
type Announcement = {
@ -384,6 +385,8 @@ declare namespace Mastodon {
mention: boolean
poll: boolean
status: boolean
'admin.sign_up': boolean
'admin.report': boolean
}
server_key: string
}
@ -409,6 +412,18 @@ declare namespace Mastodon {
hashtags?: Tag[]
}
type Role = {
// Added since 4.0
id: string
name: string
color: string
position: number
permissions: string
highlighted: boolean
created_at: string
updated_at: string
}
type Status = {
// Base
id: string
@ -479,25 +494,4 @@ declare namespace Mastodon {
history: { day: string; accounts: string; uses: string }[]
following: boolean // Since v4.0
}
type WebSocketStream =
| 'user'
| 'public'
| 'public:local'
| 'hashtag'
| 'hashtag:local'
| 'list'
| 'direct'
type WebSocket =
| {
stream: WebSocketStream[]
event: 'update'
payload: string // Status
}
| { stream: WebSocketStream[]; event: 'delete'; payload: Status['id'] }
| {
stream: WebSocketStream[]
event: 'notification'
payload: string // Notification
}
}

View File

@ -39,7 +39,7 @@ export interface Props {
}
const Screens: React.FC<Props> = ({ localCorrupt }) => {
const { i18n, t } = useTranslation('screens')
const { t } = useTranslation('screens')
const dispatch = useAppDispatch()
const instanceActive = useSelector(getInstanceActive)
const { colors, theme } = useTheme()
@ -70,8 +70,7 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
displayMessage({
message: t('localCorrupt.message'),
description: localCorrupt.length ? localCorrupt : undefined,
type: 'error',
theme
type: 'danger'
})
// @ts-ignore
navigationRef.navigate('Screen-Tabs', {
@ -136,10 +135,7 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
instance => paths[0] === `@${instance.account.acct}@${instance.uri}`
)
if (instanceIndex !== -1 && instanceActive !== instanceIndex) {
initQuery({
instance: instances[instanceIndex],
prefetch: { enabled: true }
})
initQuery({ instance: instances[instanceIndex] })
}
}
}
@ -183,8 +179,7 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
message: t('shareError.imageNotSupported', {
type: mime.split('/')[1]
}),
type: 'error',
theme
type: 'danger'
})
return
}
@ -196,8 +191,7 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
message: t('shareError.videoNotSupported', {
type: mime.split('/')[1]
}),
type: 'error',
theme
type: 'danger'
})
return
}

View File

@ -22,13 +22,13 @@ const apiGeneral = async <T = unknown>({
}: Params): Promise<{ body: T }> => {
console.log(
ctx.bgGreen.bold(' API general ') +
' ' +
domain +
' ' +
method +
ctx.green(' -> ') +
`/${url}` +
(params ? ctx.green(' -> ') : ''),
' ' +
domain +
' ' +
method +
ctx.green(' -> ') +
`/${url}` +
(params ? ctx.green(' -> ') : ''),
params ? params : ''
)
@ -39,10 +39,7 @@ const apiGeneral = async <T = unknown>({
url,
params,
headers: {
'Content-Type':
body && body instanceof FormData
? 'multipart/form-data'
: 'application/json',
'Content-Type': body && body instanceof FormData ? 'multipart/form-data' : 'application/json',
Accept: '*/*',
...userAgent,
...headers
@ -54,7 +51,7 @@ const apiGeneral = async <T = unknown>({
body: response.data
})
})
.catch(handleError)
.catch(handleError())
}
export default apiGeneral

View File

@ -1,3 +1,4 @@
import * as Sentry from '@sentry/react-native'
import chalk from 'chalk'
import Constants from 'expo-constants'
import { Platform } from 'react-native'
@ -7,30 +8,59 @@ const userAgent = {
}
const ctx = new chalk.Instance({ level: 3 })
const handleError = (error: any) => {
if (error?.response) {
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
console.error(
ctx.bold(' API '),
ctx.bold('response'),
error.response.status,
error?.response.data?.error || error?.response.message || 'Unknown error'
)
return Promise.reject({
status: error?.response.status,
message: error?.response.data?.error || error?.response.message || 'Unknown error'
})
} else if (error?.request) {
// The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
// http.ClientRequest in node.js
console.error(ctx.bold(' API '), ctx.bold('request'), error)
return Promise.reject()
} else {
console.error(ctx.bold(' API '), ctx.bold('internal'), error?.message)
return Promise.reject()
const handleError =
(
config: {
message: string
captureRequest?: { url: string; params: any; body: any }
captureResponse?: boolean
} | void
) =>
(error: any) => {
const shouldReportToSentry = config && (config.captureRequest || config.captureResponse)
shouldReportToSentry && Sentry.setContext('Error object', error)
if (config?.captureRequest) {
Sentry.setContext('Error request', config.captureRequest)
}
if (error?.response) {
if (config?.captureResponse) {
Sentry.setContext('Error response', {
data: error.response.data,
status: error.response.status,
headers: error.response.headers
})
}
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
console.error(
ctx.bold(' API '),
ctx.bold('response'),
error.response.status,
error?.response.data?.error || error?.response.message || 'Unknown error'
)
shouldReportToSentry && Sentry.captureMessage(config.message)
return Promise.reject({
status: error?.response.status,
message: error?.response.data?.error || error?.response.message || 'Unknown error'
})
} else if (error?.request) {
// The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
// http.ClientRequest in node.js
console.error(ctx.bold(' API '), ctx.bold('request'), error)
shouldReportToSentry && Sentry.captureMessage(config.message)
return Promise.reject()
} else {
console.error(ctx.bold(' API '), ctx.bold('internal'), error?.message)
shouldReportToSentry && Sentry.captureMessage(config.message)
return Promise.reject()
}
}
}
export { ctx, handleError, userAgent }

View File

@ -86,7 +86,7 @@ const apiInstance = async <T = unknown>({
links: { prev, next }
})
})
.catch(handleError)
.catch(handleError())
}
export default apiInstance

View File

@ -55,16 +55,13 @@ const apiTooot = async <T = unknown>({
body: response.data
})
})
.catch(error => {
Sentry.setContext('API request', { url, params, body })
Sentry.setContext('Error response', {
...(error?.response && { response: error.response?._response })
.catch(
handleError({
message: 'API error',
captureRequest: { url, params, body },
captureResponse: true
})
Sentry.setContext('Error object', { error })
Sentry.captureMessage('API error')
return handleError(error)
})
)
}
export default apiTooot

View File

@ -12,11 +12,7 @@ interface Props {
additionalActions?: () => void
}
const AccountButton: React.FC<Props> = ({
instance,
selected = false,
additionalActions
}) => {
const AccountButton: React.FC<Props> = ({ instance, selected = false, additionalActions }) => {
const navigation = useNavigation()
return (
@ -27,12 +23,10 @@ const AccountButton: React.FC<Props> = ({
marginBottom: StyleConstants.Spacing.M,
marginRight: StyleConstants.Spacing.M
}}
content={`@${instance.account.acct}@${instance.uri}${
selected ? ' ✓' : ''
}`}
content={`@${instance.account.acct}@${instance.uri}${selected ? ' ✓' : ''}`}
onPress={() => {
haptics('Light')
initQuery({ instance, prefetch: { enabled: true } })
initQuery({ instance })
navigation.goBack()
if (additionalActions) {
additionalActions()

View File

@ -1,90 +0,0 @@
import browserPackage from '@helpers/browserPackage'
import { useNavigation } from '@react-navigation/native'
import { useAppDispatch } from '@root/store'
import { InstanceLatest } from '@utils/migrations/instances/migration'
import { TabMeStackNavigationProp } from '@utils/navigation/navigators'
import addInstance from '@utils/slices/instances/add'
import { checkInstanceFeature } from '@utils/slices/instancesSlice'
import * as AuthSession from 'expo-auth-session'
import React, { useEffect } from 'react'
import { useQueryClient } from 'react-query'
import { useSelector } from 'react-redux'
export interface Props {
instanceDomain: string
// Domain can be different than uri
instance: Mastodon.Instance
appData: InstanceLatest['appData']
goBack?: boolean
}
const InstanceAuth = React.memo(
({ instanceDomain, instance, appData, goBack }: Props) => {
const redirectUri = AuthSession.makeRedirectUri({
native: 'tooot://instance-auth',
useProxy: false
})
const navigation = useNavigation<TabMeStackNavigationProp<'Tab-Me-Root' | 'Tab-Me-Switch'>>()
const queryClient = useQueryClient()
const dispatch = useAppDispatch()
const deprecateAuthFollow = useSelector(checkInstanceFeature('deprecate_auth_follow'))
const [request, response, promptAsync] = AuthSession.useAuthRequest(
{
clientId: appData.clientId,
clientSecret: appData.clientSecret,
scopes: deprecateAuthFollow
? ['read', 'write', 'push']
: ['read', 'write', 'follow', 'push'],
redirectUri
},
{
authorizationEndpoint: `https://${instanceDomain}/oauth/authorize`
}
)
useEffect(() => {
;(async () => {
if (request?.clientId) {
await promptAsync({ browserPackage: await browserPackage() }).catch(e => console.log(e))
}
})()
}, [request])
useEffect(() => {
;(async () => {
if (response?.type === 'success') {
const { accessToken } = await AuthSession.exchangeCodeAsync(
{
clientId: appData.clientId,
clientSecret: appData.clientSecret,
scopes: ['read', 'write', 'follow', 'push'],
redirectUri,
code: response.params.code,
extraParams: {
grant_type: 'authorization_code'
}
},
{
tokenEndpoint: `https://${instanceDomain}/oauth/token`
}
)
queryClient.clear()
dispatch(
addInstance({
domain: instanceDomain,
token: accessToken,
instance,
appData
})
)
goBack && navigation.goBack()
}
})()
}, [response])
return <></>
},
() => true
)
export default InstanceAuth

View File

@ -1,22 +1,27 @@
import Button from '@components/Button'
import Icon from '@components/Icon'
import browserPackage from '@helpers/browserPackage'
import { useAppsQuery } from '@utils/queryHooks/apps'
import { redirectUri, useAppsMutation } from '@utils/queryHooks/apps'
import { useInstanceQuery } from '@utils/queryHooks/instance'
import { getInstances } from '@utils/slices/instancesSlice'
import { checkInstanceFeature, getInstances } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import * as AuthSession from 'expo-auth-session'
import * as WebBrowser from 'expo-web-browser'
import { debounce } from 'lodash'
import React, { RefObject, useCallback, useMemo, useState } from 'react'
import React, { RefObject, useCallback, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { Alert, Image, KeyboardAvoidingView, Platform, TextInput, View } from 'react-native'
import { ScrollView } from 'react-native-gesture-handler'
import { useSelector } from 'react-redux'
import { Placeholder } from 'rn-placeholder'
import InstanceAuth from './Instance/Auth'
import InstanceInfo from './Instance/Info'
import CustomText from './Text'
import InstanceInfo from './Info'
import CustomText from '../Text'
import { useNavigation } from '@react-navigation/native'
import { TabMeStackNavigationProp } from '@utils/navigation/navigators'
import queryClient from '@helpers/queryClient'
import { useAppDispatch } from '@root/store'
import addInstance from '@utils/slices/instances/add'
export interface Props {
scrollViewRef?: RefObject<ScrollView>
@ -31,30 +36,64 @@ const ComponentInstance: React.FC<Props> = ({
}) => {
const { t } = useTranslation('componentInstance')
const { colors, mode } = useTheme()
const navigation = useNavigation<TabMeStackNavigationProp<'Tab-Me-Root' | 'Tab-Me-Switch'>>()
const [domain, setDomain] = useState<string>('')
const dispatch = useAppDispatch()
const instances = useSelector(getInstances, () => true)
const [domain, setDomain] = useState<string>()
const instanceQuery = useInstanceQuery({
domain,
options: { enabled: !!domain, retry: false }
})
const appsQuery = useAppsQuery({
domain,
options: { enabled: false, retry: false }
})
const onChangeText = useCallback(
debounce(
text => {
setDomain(text.replace(/^http(s)?\:\/\//i, ''))
appsQuery.remove()
},
1000,
{ trailing: true }
),
[]
)
const deprecateAuthFollow = useSelector(checkInstanceFeature('deprecate_auth_follow'))
const appsMutation = useAppsMutation({
retry: false,
onSuccess: async (data, variables) => {
const clientId = data.client_id
const clientSecret = data.client_secret
const discovery = { authorizationEndpoint: `https://${domain}/oauth/authorize` }
const request = new AuthSession.AuthRequest({
clientId,
clientSecret,
scopes: deprecateAuthFollow
? ['read', 'write', 'push']
: ['read', 'write', 'follow', 'push'],
redirectUri
})
await request.makeAuthUrlAsync(discovery)
const promptResult = await request.promptAsync(discovery)
if (promptResult?.type === 'success') {
const { accessToken } = await AuthSession.exchangeCodeAsync(
{
clientId,
clientSecret,
scopes: ['read', 'write', 'follow', 'push'],
redirectUri,
code: promptResult.params.code,
extraParams: { grant_type: 'authorization_code' }
},
{ tokenEndpoint: `https://${variables.domain}/oauth/token` }
)
queryClient.clear()
dispatch(
addInstance({
domain,
token: accessToken,
instance: instanceQuery.data!,
appData: { clientId, clientSecret }
})
)
goBack && navigation.goBack()
}
}
})
const processUpdate = useCallback(() => {
if (domain) {
@ -66,39 +105,15 @@ const ComponentInstance: React.FC<Props> = ({
},
{
text: t('common:buttons.continue'),
onPress: () => {
appsQuery.refetch()
}
onPress: () => appsMutation.mutate({ domain })
}
])
} else {
appsQuery.refetch()
appsMutation.mutate({ domain })
}
}
}, [domain])
const requestAuth = useMemo(() => {
if (
domain &&
instanceQuery.data?.uri &&
appsQuery.data?.client_id &&
appsQuery.data.client_secret
) {
return (
<InstanceAuth
key={Math.random()}
instanceDomain={domain}
instance={instanceQuery.data}
appData={{
clientId: appsQuery.data.client_id,
clientSecret: appsQuery.data.client_secret
}}
goBack={goBack}
/>
)
}
}, [domain, instanceQuery.data, appsQuery.data])
return (
<KeyboardAvoidingView
style={{ flex: 1 }}
@ -145,7 +160,9 @@ const ComponentInstance: React.FC<Props> = ({
color: colors.primaryDefault,
borderBottomColor: instanceQuery.isError ? colors.red : colors.border
}}
onChangeText={onChangeText}
onChangeText={debounce(text => setDomain(text.replace(/^http(s)?\:\/\//i, '')), 1000, {
trailing: true
})}
autoCapitalize='none'
clearButtonMode='never'
keyboardType='url'
@ -176,7 +193,7 @@ const ComponentInstance: React.FC<Props> = ({
content={t('server.button')}
onPress={processUpdate}
disabled={!instanceQuery.data?.uri}
loading={instanceQuery.isFetching || appsQuery.isFetching}
loading={instanceQuery.isFetching || appsMutation.isLoading}
/>
</View>
@ -276,8 +293,6 @@ const ComponentInstance: React.FC<Props> = ({
</View>
</View>
</View>
{requestAuth}
</KeyboardAvoidingView>
)
}

View File

@ -1,10 +1,9 @@
import Icon from '@components/Icon'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import { getColors, Theme } from '@utils/styles/themes'
import React, { RefObject } from 'react'
import { AccessibilityInfo } from 'react-native'
import FlashMessage, { hideMessage, showMessage } from 'react-native-flash-message'
import FlashMessage, { MessageType, showMessage } from 'react-native-flash-message'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import haptics from './haptics'
@ -15,107 +14,80 @@ const displayMessage = ({
message,
description,
onPress,
theme,
type
}:
| {
ref?: RefObject<FlashMessage>
duration?: 'short' | 'long'
autoHide?: boolean
message: string
description?: string
onPress?: () => void
theme?: undefined
type?: undefined
}
| {
ref?: RefObject<FlashMessage>
duration?: 'short' | 'long'
autoHide?: boolean
message: string
description?: string
onPress?: () => void
theme: Theme
type: 'success' | 'error' | 'warning'
}) => {
}: {
ref?: RefObject<FlashMessage>
duration?: 'short' | 'long'
autoHide?: boolean
message: string
description?: string
onPress?: () => void
type?: MessageType
}) => {
AccessibilityInfo.announceForAccessibility(message + '.' + description)
enum iconMapping {
success = 'CheckCircle',
error = 'XCircle',
warning = 'AlertCircle'
}
enum colorMapping {
success = 'blue',
error = 'red',
warning = 'secondary'
}
if (type && type === 'error') {
if (type && type === 'danger') {
haptics('Error')
}
if (ref) {
ref.current?.showMessage({
duration: type === 'error' ? 8000 : duration === 'short' ? 3000 : 5000,
duration: type === 'danger' ? 8000 : duration === 'short' ? 3000 : 5000,
autoHide,
message,
description,
onPress,
...(theme &&
type && {
renderFlashMessageIcon: () => {
return (
<Icon
name={iconMapping[type]}
size={StyleConstants.Font.LineHeight.M}
color={getColors(theme)[colorMapping[type]]}
style={{ marginRight: StyleConstants.Spacing.S }}
/>
)
}
})
type
})
} else {
showMessage({
duration: type === 'error' ? 8000 : duration === 'short' ? 3000 : 5000,
duration: type === 'danger' ? 8000 : duration === 'short' ? 3000 : 5000,
autoHide,
message,
description,
onPress,
...(theme &&
type && {
renderFlashMessageIcon: () => {
return (
<Icon
name={iconMapping[type]}
size={StyleConstants.Font.LineHeight.M}
color={getColors(theme)[colorMapping[type]]}
style={{ marginRight: StyleConstants.Spacing.S }}
/>
)
}
})
type
})
}
}
const removeMessage = () => {
// if (ref) {
// ref.current?.hideMessage()
// } else {
hideMessage()
// }
}
const Message = React.forwardRef<FlashMessage>((_, ref) => {
const { colors, theme } = useTheme()
const insets = useSafeAreaInsets()
enum iconMapping {
success = 'CheckCircle',
danger = 'XCircle',
warning = 'AlertCircle',
none = '',
default = '',
info = '',
auto = ''
}
enum colorMapping {
success = 'blue',
danger = 'red',
warning = 'secondary',
none = 'secondary',
default = 'secondary',
info = 'secondary',
auto = 'secondary'
}
return (
<FlashMessage
ref={ref}
icon='auto'
renderFlashMessageIcon={type => {
return typeof type === 'string' && ['success', 'danger', 'warning'].includes(type) ? (
<Icon
name={iconMapping[type]}
size={StyleConstants.Font.LineHeight.M}
color={colors[colorMapping[type]]}
style={{ marginRight: StyleConstants.Spacing.S }}
/>
) : null
}}
position='top'
floating
style={{
@ -142,4 +114,4 @@ const Message = React.forwardRef<FlashMessage>((_, ref) => {
)
})
export { Message, displayMessage, removeMessage }
export { Message, displayMessage }

View File

@ -19,7 +19,7 @@ export interface Props {
const TimelineActioned: React.FC<Props> = ({ action, isNotification, ...rest }) => {
const { status, reblogStatus } = useContext(StatusContext)
const account = rest.account || (reblogStatus ? reblogStatus.account : status?.account)
if (!status || !account) return null
if (!account) return null
const { t } = useTranslation('componentTimeline')
const { colors } = useTheme()

View File

@ -7,6 +7,7 @@ import { Blurhash } from 'react-native-blurhash'
import attachmentAspectRatio from './aspectRatio'
import AttachmentAltText from './AltText'
import { Platform } from 'expo-modules-core'
import { useAccessibility } from '@utils/accessibility/AccessibilityManager'
export interface Props {
total: number
@ -23,6 +24,8 @@ const AttachmentVideo: React.FC<Props> = ({
video,
gifv = false
}) => {
const { reduceMotionEnabled } = useAccessibility()
const videoPlayer = useRef<Video>(null)
const [videoLoading, setVideoLoading] = useState(false)
const [videoLoaded, setVideoLoaded] = useState(false)
@ -57,7 +60,7 @@ const AttachmentVideo: React.FC<Props> = ({
resizeMode={videoResizeMode}
{...(gifv
? {
shouldPlay: true,
shouldPlay: reduceMotionEnabled ? false : true,
isMuted: true,
isLooping: true,
source: { uri: video.url }
@ -70,10 +73,10 @@ const AttachmentVideo: React.FC<Props> = ({
onFullscreenUpdate={event => {
if (event.fullscreenUpdate === VideoFullscreenUpdate.PLAYER_DID_DISMISS) {
Platform.OS === 'android' && setVideoResizeMode(ResizeMode.COVER)
if (!gifv) {
videoPlayer.current?.pauseAsync()
} else {
if (gifv && !reduceMotionEnabled) {
videoPlayer.current?.playAsync()
} else {
videoPlayer.current?.pauseAsync()
}
}
}}
@ -103,7 +106,7 @@ const AttachmentVideo: React.FC<Props> = ({
video.blurhash ? (
<Blurhash blurhash={video.blurhash} style={{ width: '100%', height: '100%' }} />
) : null
) : !gifv ? (
) : !gifv || (gifv && reduceMotionEnabled) ? (
<Button
round
overlay

View File

@ -72,12 +72,12 @@ export const shouldFilter = ({
const escapedPhrase = filter.phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string
switch (filter.whole_word) {
case true:
if (new RegExp(`\\B${escapedPhrase}\\b`).test(rawContent)) {
if (new RegExp(`\\b${escapedPhrase}\\b`, 'i').test(rawContent)) {
shouldFilter = filter.phrase
}
break
case false:
if (new RegExp(escapedPhrase).test(rawContent)) {
if (new RegExp(escapedPhrase, 'i').test(rawContent)) {
shouldFilter = filter.phrase
}
break

View File

@ -1,5 +1,6 @@
import { ParseHTML } from '@components/Parse'
import CustomText from '@components/Text'
import detectLanguage from '@helpers/detectLanguage'
import getLanguage from '@helpers/getLanguage'
import { useTranslateQuery } from '@utils/queryHooks/translate'
import { StyleConstants } from '@utils/styles/constants'
@ -7,38 +8,44 @@ import { useTheme } from '@utils/styles/ThemeManager'
import * as Localization from 'expo-localization'
import React, { useContext, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Pressable } from 'react-native'
import { Platform, Pressable } from 'react-native'
import { Circle } from 'react-native-animated-spinkit'
import detectLanguage from 'react-native-language-detection'
import StatusContext from './Context'
const TimelineTranslate = () => {
const { status, highlighted } = useContext(StatusContext)
const { status, highlighted, copiableContent } = useContext(StatusContext)
if (!status || !highlighted) return null
const { t } = useTranslation('componentTimeline')
const { colors } = useTheme()
const text = status.spoiler_text ? [status.spoiler_text, status.content] : [status.content]
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}:`, ' ')
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, ' ')
}
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 [detectedLanguage, setDetectedLanguage] = useState<string>('')
const [detectedLanguage, setDetectedLanguage] = useState<{
language: string
confidence: number
}>({ language: status.language || '', confidence: 0 })
useEffect(() => {
const detect = async () => {
const result = await detectLanguage(text.join(`\n\n`)).catch(() => {
// No need to log language detection failure
})
result?.detected && setDetectedLanguage(result.detected.slice(0, 2))
const result = await detectLanguage(text.join('\n\n'))
result && setDetectedLanguage(result)
}
detect()
}, [])
@ -50,20 +57,36 @@ const TimelineTranslate = () => {
const [enabled, setEnabled] = useState(false)
const { refetch, data, isLoading, isSuccess, isError } = useTranslateQuery({
source: detectedLanguage,
source: detectedLanguage.language,
target: targetLanguage,
text,
options: { enabled }
})
const devView = () => {
return __DEV__ ? (
<CustomText fontStyle='S' style={{ color: colors.secondary }}>{` Source: ${
detectedLanguage?.language
}; Confidence: ${
detectedLanguage?.confidence.toString().slice(0, 5) || 'null'
}; Target: ${targetLanguage}`}</CustomText>
) : null
}
if (!detectedLanguage) {
return null
return devView()
}
if (Localization.locale.slice(0, 2).includes(detectedLanguage)) {
return null
if (
Platform.OS === 'ios' &&
Localization.locale.slice(0, 2).includes(detectedLanguage.language.slice(0, 2))
) {
return devView()
}
if (settingsLanguage?.slice(0, 2).includes(detectedLanguage)) {
return null
if (
Platform.OS === 'android' &&
settingsLanguage?.slice(0, 2).includes(detectedLanguage.language.slice(0, 2))
) {
return devView()
}
return (
@ -102,9 +125,6 @@ const TimelineTranslate = () => {
})
: t('shared.translate.default')}
</CustomText>
<CustomText>
{__DEV__ ? ` Source: ${detectedLanguage}; Target: ${targetLanguage}` : undefined}
</CustomText>
{isLoading ? (
<Circle
size={StyleConstants.Font.Size.M}
@ -113,6 +133,7 @@ const TimelineTranslate = () => {
/>
) : null}
</Pressable>
{devView()}
{data && data.error === undefined
? data.text.map((d, i) => <ParseHTML key={i} content={d} size={'M'} numberOfLines={999} />)
: null}

View File

@ -14,7 +14,6 @@ import {
useTimelineMutation
} from '@utils/queryHooks/timeline'
import { getInstanceAccount } from '@utils/slices/instancesSlice'
import { useTheme } from '@utils/styles/ThemeManager'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Platform } from 'react-native'
@ -38,7 +37,6 @@ const menuAccount = ({
const navigation =
useNavigation<NativeStackNavigationProp<TabSharedStackParamList, any, undefined>>()
const { theme } = useTheme()
const { t } = useTranslation('componentContextMenu')
const menus: ContextMenu[][] = [[]]
@ -60,7 +58,6 @@ const menuAccount = ({
queryClient.refetchQueries(['Relationship', { id: account.id }])
const theParams = params as MutationVarsTimelineUpdateAccountProperty
displayMessage({
theme,
type: 'success',
message: t('common:message.success.message', {
function: t(`account.${theParams.payload.property}.action`, {
@ -74,8 +71,7 @@ const menuAccount = ({
onError: (err: any, params) => {
const theParams = params as MutationVarsTimelineUpdateAccountProperty
displayMessage({
theme,
type: 'error',
type: 'danger',
message: t('common:message.error.message', {
function: t(`account.${theParams.payload.property}.action`, {
...(theParams.payload.property !== 'reports' && {
@ -109,8 +105,7 @@ const menuAccount = ({
},
onError: (err: any, { payload: { action } }) => {
displayMessage({
theme,
type: 'error',
type: 'danger',
message: t('common:message.error.message', {
function: t(`${action}.function`)
}),

View File

@ -1,7 +1,6 @@
import { displayMessage } from '@components/Message'
import { QueryKeyTimeline, useTimelineMutation } from '@utils/queryHooks/timeline'
import { getInstanceUrl } from '@utils/slices/instancesSlice'
import { useTheme } from '@utils/styles/ThemeManager'
import { useTranslation } from 'react-i18next'
import { Alert } from 'react-native'
import { useQueryClient } from 'react-query'
@ -18,14 +17,12 @@ const menuInstance = ({
}): ContextMenu[][] => {
if (!status || !queryKey) return []
const { theme } = useTheme()
const { t } = useTranslation('componentContextMenu')
const queryClient = useQueryClient()
const mutation = useTimelineMutation({
onSettled: () => {
displayMessage({
theme,
type: 'success',
message: t('common:message.success.message', {
function: t(`instance.block.action`, { instance })

View File

@ -1,6 +1,5 @@
import { displayMessage } from '@components/Message'
import Clipboard from '@react-native-clipboard/clipboard'
import { useTheme } from '@utils/styles/ThemeManager'
import { useTranslation } from 'react-i18next'
import { Platform, Share } from 'react-native'
@ -22,7 +21,6 @@ const menuShare = (
): ContextMenu[][] => {
if (params.type === 'status' && params.visibility === 'direct') return []
const { theme } = useTheme()
const { t } = useTranslation('componentContextMenu')
const menus: ContextMenu[][] = [[]]
@ -56,11 +54,7 @@ const menuShare = (
item: {
onSelect: () => {
Clipboard.setString(params.copiableContent?.current.content || '')
displayMessage({
theme,
type: 'success',
message: t(`copy.succeed`)
})
displayMessage({ type: 'success', message: t(`copy.succeed`) })
},
disabled: false,
destructive: false,

View File

@ -0,0 +1,10 @@
import detect from 'react-native-language-detection'
const detectLanguage = async (
text: string
): Promise<{ language: string; confidence: number } | null> => {
const possibleLanguages = await detect(text).catch(() => {})
return possibleLanguages ? possibleLanguages.filter(lang => lang.confidence > 0.5)?.[0] : null
}
export default detectLanguage

View File

@ -19,6 +19,11 @@
"version": 3.5,
"reference": "https://github.com/mastodon/mastodon/releases/tag/v3.5.0"
},
{
"feature": "notification_type_admin_signup",
"version": 3.5,
"reference": "https://github.com/mastodon/mastodon/releases/tag/v3.5.0"
},
{
"feature": "notification_types_positive_filter",
"version": 3.5,
@ -33,5 +38,10 @@
"feature": "follow_tags",
"version": 4.0,
"reference": "https://github.com/mastodon/mastodon/releases/tag/v4.0.0"
},
{
"feature": "notification_type_admin_report",
"version": 4.0,
"reference": "https://github.com/mastodon/mastodon/releases/tag/v3.5.0"
}
]

View File

@ -0,0 +1,2 @@
export const PERMISSION_MANAGE_REPORTS = 0x0000000000000010
export const PERMISSION_MANAGE_USERS = 0x0000000000000400

View File

@ -1,5 +1,5 @@
import { QueryClient } from 'react-query'
const queryClient = new QueryClient()
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5 } } })
export default queryClient

View File

@ -178,6 +178,10 @@
"direct": "Habilita les notificacions push",
"settings": "Activa'ls a la configuració"
},
"missingServerKey": {
"message": "",
"description": ""
},
"global": {
"heading": "Activa per {{acct}}",
"description": "Els missatges s'envien a través del servidor del tooot"

View File

@ -178,6 +178,10 @@
"direct": "",
"settings": ""
},
"missingServerKey": {
"message": "",
"description": ""
},
"global": {
"heading": "",
"description": ""

View File

@ -178,6 +178,10 @@
"direct": "Push-Benachrichtigungen aktivieren",
"settings": "In den Einstellungen aktivieren"
},
"missingServerKey": {
"message": "",
"description": ""
},
"global": {
"heading": "Aktivieren für {{acct}}",
"description": "Nachrichten werden über den Tooot-Server geleitet"

View File

@ -178,6 +178,10 @@
"direct": "Enable push notification",
"settings": "Enable in settings"
},
"missingServerKey": {
"message": "Server misconfigured for push",
"description": "Please contact your server admin to configure push support"
},
"global": {
"heading": "Enable for {{acct}}",
"description": "Messages are routed through tooot's server"
@ -210,6 +214,12 @@
"status": {
"heading": "Toot from subscribed users"
},
"admin.sign_up": {
"heading": "Admin: sign up"
},
"admin.report": {
"heading": "Admin: reports"
},
"howitworks": "Learn how routing works"
},
"root": {
@ -221,10 +231,8 @@
}
},
"push": {
"content": {
"enabled": "Enabled",
"disabled": "Disabled"
}
"content_true": "Enabled",
"content_false": "Disabled"
},
"update": {
"title": "Update to latest version"
@ -286,9 +294,6 @@
"support": {
"heading": "Support tooot"
},
"review": {
"heading": "Review tooot"
},
"contact": {
"heading": "Contact tooot"
},

View File

@ -178,6 +178,10 @@
"direct": "Habilitar notificaciones push",
"settings": "Activar en Ajustes"
},
"missingServerKey": {
"message": "",
"description": ""
},
"global": {
"heading": "Habilitar para {{acct}}",
"description": "Los mensajes se envían a través del servidor de tooot"

View File

@ -178,6 +178,10 @@
"direct": "Activer les notifications push",
"settings": "Activer dans les paramètres"
},
"missingServerKey": {
"message": "",
"description": ""
},
"global": {
"heading": "Activer pour {{acct}}",
"description": "Les messages sont acheminés via le serveur de tooot"

View File

@ -178,6 +178,10 @@
"direct": "Abilita notifiche push",
"settings": "Abilita nelle impostazioni"
},
"missingServerKey": {
"message": "",
"description": ""
},
"global": {
"heading": "Abilita per {{acct}}",
"description": "I messaggi dovranno attraversare i server di tooot"

View File

@ -178,6 +178,10 @@
"direct": "プッシュ通知を有効にする",
"settings": "設定で有効にする"
},
"missingServerKey": {
"message": "",
"description": ""
},
"global": {
"heading": "{{acct}} の通知を有効にする",
"description": "メッセージは tooot のサーバー経由で到達します"

View File

@ -178,6 +178,10 @@
"direct": "푸시 알림 활성화",
"settings": "설정에서 활성화"
},
"missingServerKey": {
"message": "",
"description": ""
},
"global": {
"heading": "{{acct}} 활성화",
"description": "메시지는 tooot의 서버를 거쳐 전달돼요"

View File

@ -178,6 +178,10 @@
"direct": "Push meldingen inschakelen",
"settings": "Inschakelen in instellingen"
},
"missingServerKey": {
"message": "",
"description": ""
},
"global": {
"heading": "Inschakelen voor {{acct}}",
"description": "Berichten worden doorgestuurd via tooot's server"

View File

@ -178,6 +178,10 @@
"direct": "",
"settings": ""
},
"missingServerKey": {
"message": "",
"description": ""
},
"global": {
"heading": "",
"description": ""

View File

@ -178,6 +178,10 @@
"direct": "Habilitar notificações via push",
"settings": "Ativar em configurações"
},
"missingServerKey": {
"message": "",
"description": ""
},
"global": {
"heading": "Habilitar para {{acct}}",
"description": "Mensagens são encaminhadas pelo servidor do tooot"

View File

@ -178,6 +178,10 @@
"direct": "Aktivera pushnotiser",
"settings": "Aktivera i inställningar"
},
"missingServerKey": {
"message": "",
"description": ""
},
"global": {
"heading": "Aktivera för {{acct}}",
"description": "Meddelanden dirigeras via tooots server"

View File

@ -6,7 +6,7 @@
"discard": "Bỏ qua",
"continue": "Tiếp tục",
"delete": "Xóa",
"done": ""
"done": "Xong"
},
"customEmoji": {
"accessibilityLabel": "Tùy chỉnh emoji {{emoji}}"

View File

@ -6,7 +6,7 @@
"action_false": "Theo dõi người này",
"action_true": "Ngưng theo dõi người này"
},
"inLists": "",
"inLists": "Quản lý người trong danh sách",
"mute": {
"action_false": "Ẩn người này",
"action_true": "Bỏ ẩn người dùng"
@ -20,8 +20,8 @@
}
},
"at": {
"direct": "",
"public": ""
"direct": "Nhắn riêng",
"public": "Công khai"
},
"copy": {
"action": "Sao chép tút",

View File

@ -178,6 +178,10 @@
"direct": "Bật thông báo đẩy",
"settings": "Bật trong cài đặt"
},
"missingServerKey": {
"message": "",
"description": ""
},
"global": {
"heading": "Bật cho {{acct}}",
"description": "Thông báo được truyền qua máy chủ tooot"
@ -321,9 +325,9 @@
"suspended": "Người này đã bị vô hiệu hóa"
},
"accountInLists": {
"name": "",
"inLists": "",
"notInLists": ""
"name": "Danh sách của @{{username}}",
"inLists": "Trong danh sách",
"notInLists": "Danh sách khác"
},
"attachments": {
"name": "<0 /><1>'s media</1>"
@ -352,7 +356,7 @@
}
},
"trending": {
"tags": ""
"tags": "Hashtag xu hướng"
}
},
"sections": {

View File

@ -178,6 +178,10 @@
"direct": "启用推送通知",
"settings": "在系统设置中启用"
},
"missingServerKey": {
"message": "服务器推送配置不正确",
"description": "请联系您的服务器管理员设置推送支持"
},
"global": {
"heading": "启用 {{acct}}",
"description": "通知消息将经由tooot服务器转发"

View File

@ -178,6 +178,10 @@
"direct": "啟用推播通知",
"settings": "在設定中啟用"
},
"missingServerKey": {
"message": "",
"description": ""
},
"global": {
"heading": "啟用 {{acct}}",
"description": "通知訊息將經由 tooot 伺服器轉發"

View File

@ -1,123 +0,0 @@
import { MenuContainer, MenuHeader, MenuRow } from '@components/Menu'
import { displayMessage } from '@components/Message'
import {
MutationVarsTimelineUpdateAccountProperty,
QueryKeyTimeline,
useTimelineMutation
} from '@utils/queryHooks/timeline'
import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useQueryClient } from 'react-query'
export interface Props {
queryKey?: QueryKeyTimeline
rootQueryKey?: QueryKeyTimeline
account: Mastodon.Account
dismiss: () => void
}
const ActionsAccount: React.FC<Props> = ({
queryKey,
rootQueryKey,
account,
dismiss
}) => {
const { theme } = useTheme()
const { t } = useTranslation('componentTimeline')
const queryClient = useQueryClient()
const mutation = useTimelineMutation({
onSuccess: (_, params) => {
const theParams = params as MutationVarsTimelineUpdateAccountProperty
displayMessage({
theme,
type: 'success',
message: t('common:message.success.message', {
function: t(
`shared.header.actions.account.${theParams.payload.property}.function`,
{
acct: account.acct
}
)
})
})
},
onError: (err: any, params) => {
const theParams = params as MutationVarsTimelineUpdateAccountProperty
displayMessage({
theme,
type: 'error',
message: t('common:message.error.message', {
function: t(
`shared.header.actions.account.${theParams.payload.property}.function`
)
}),
...(err.status &&
typeof err.status === 'number' &&
err.data &&
err.data.error &&
typeof err.data.error === 'string' && {
description: err.data.error
})
})
},
onSettled: () => {
queryKey && queryClient.invalidateQueries(queryKey)
rootQueryKey && queryClient.invalidateQueries(rootQueryKey)
}
})
return (
<MenuContainer>
<MenuHeader heading={t('shared.header.actions.account.heading')} />
<MenuRow
onPress={() => {
dismiss()
mutation.mutate({
type: 'updateAccountProperty',
queryKey,
id: account.id,
payload: { property: 'mute' }
})
}}
iconFront='EyeOff'
title={t('shared.header.actions.account.mute.button', {
acct: account.acct
})}
/>
<MenuRow
onPress={() => {
dismiss()
mutation.mutate({
type: 'updateAccountProperty',
queryKey,
id: account.id,
payload: { property: 'block' }
})
}}
iconFront='XCircle'
title={t('shared.header.actions.account.block.button', {
acct: account.acct
})}
/>
<MenuRow
onPress={() => {
dismiss()
mutation.mutate({
type: 'updateAccountProperty',
queryKey,
id: account.id,
payload: { property: 'reports' }
})
}}
iconFront='Flag'
title={t('shared.header.actions.account.reports.button', {
acct: account.acct
})}
/>
</MenuContainer>
)
}
export default ActionsAccount

View File

@ -1,74 +0,0 @@
import MenuContainer from '@components/Menu/Container'
import MenuHeader from '@components/Menu/Header'
import MenuRow from '@components/Menu/Row'
import { displayMessage } from '@components/Message'
import { QueryKeyTimeline, useTimelineMutation } from '@utils/queryHooks/timeline'
import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Alert } from 'react-native'
import { useQueryClient } from 'react-query'
export interface Props {
queryKey: QueryKeyTimeline
rootQueryKey?: QueryKeyTimeline
domain: string
dismiss: () => void
}
const ActionsDomain: React.FC<Props> = ({ queryKey, rootQueryKey, domain, dismiss }) => {
const { theme } = useTheme()
const { t } = useTranslation('componentTimeline')
const queryClient = useQueryClient()
const mutation = useTimelineMutation({
onSettled: () => {
displayMessage({
theme,
type: 'success',
message: t('common:message.success.message', {
function: t(`shared.header.actions.domain.block.function`)
})
})
queryClient.invalidateQueries(queryKey)
rootQueryKey && queryClient.invalidateQueries(rootQueryKey)
}
})
return (
<MenuContainer>
<MenuHeader heading={t(`shared.header.actions.domain.heading`)} />
<MenuRow
onPress={() =>
Alert.alert(
t('shared.header.actions.domain.alert.title', { domain }),
t('shared.header.actions.domain.alert.message'),
[
{
text: t('shared.header.actions.domain.alert.buttons.confirm'),
style: 'destructive',
onPress: () => {
dismiss()
mutation.mutate({
type: 'domainBlock',
queryKey,
domain: domain
})
}
},
{
text: t('shared.header.actions.domain.alert.buttons.cancel'),
style: 'default'
}
]
)
}
iconFront='CloudOff'
title={t(`shared.header.actions.domain.block.button`, {
domain
})}
/>
</MenuContainer>
)
}
export default ActionsDomain

View File

@ -1,43 +0,0 @@
import MenuContainer from '@components/Menu/Container'
import MenuHeader from '@components/Menu/Header'
import MenuRow from '@components/Menu/Row'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Platform, Share } from 'react-native'
export interface Props {
type: 'status' | 'account'
url: string
dismiss: () => void
}
const ActionsShare: React.FC<Props> = ({ type, url, dismiss }) => {
const { t } = useTranslation('componentTimeline')
return (
<MenuContainer>
<MenuHeader heading={t(`shared.header.actions.share.${type}.heading`)} />
<MenuRow
iconFront='Share2'
title={t(`shared.header.actions.share.${type}.button`)}
onPress={async () => {
switch (Platform.OS) {
case 'ios':
await Share.share({
url
})
break
case 'android':
await Share.share({
message: url
})
break
}
dismiss()
}}
/>
</MenuContainer>
)
}
export default ActionsShare

View File

@ -1,231 +0,0 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Alert } from 'react-native'
import { useQueryClient } from 'react-query'
import { MenuContainer, MenuHeader, MenuRow } from '@components/Menu'
import {
MutationVarsTimelineUpdateStatusProperty,
QueryKeyTimeline,
useTimelineMutation
} from '@utils/queryHooks/timeline'
import { displayMessage } from '@components/Message'
import { useTheme } from '@utils/styles/ThemeManager'
import apiInstance from '@api/instance'
import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { RootStackParamList } from '@utils/navigation/navigators'
import { useSelector } from 'react-redux'
import { checkInstanceFeature } from '@utils/slices/instancesSlice'
export interface Props {
navigation: NativeStackNavigationProp<RootStackParamList, 'Screen-Actions'>
queryKey: QueryKeyTimeline
rootQueryKey?: QueryKeyTimeline
status: Mastodon.Status
dismiss: () => void
}
const ActionsStatus: React.FC<Props> = ({
navigation,
queryKey,
rootQueryKey,
status,
dismiss
}) => {
const { theme } = useTheme()
const { t } = useTranslation('componentTimeline')
const queryClient = useQueryClient()
const mutation = useTimelineMutation({
onMutate: true,
onError: (err: any, params, oldData) => {
const theFunction = (params as MutationVarsTimelineUpdateStatusProperty).payload
? (params as MutationVarsTimelineUpdateStatusProperty).payload.property
: 'delete'
displayMessage({
theme,
type: 'error',
message: t('common:message.error.message', {
function: t(`shared.header.actions.status.${theFunction}.function`)
}),
...(err?.status &&
typeof err.status === 'number' &&
err.data &&
err.data.error &&
typeof err.data.error === 'string' && {
description: err.data.error
})
})
queryClient.setQueryData(queryKey, oldData)
}
})
const canEditPost = useSelector(checkInstanceFeature('edit_post'))
return (
<MenuContainer>
<MenuHeader heading={t('shared.header.actions.status.heading')} />
{canEditPost ? (
<MenuRow
onPress={async () => {
let replyToStatus: Mastodon.Status | undefined = undefined
if (status.in_reply_to_id) {
replyToStatus = await apiInstance<Mastodon.Status>({
method: 'get',
url: `statuses/${status.in_reply_to_id}`
}).then(res => res.body)
}
apiInstance<{
id: Mastodon.Status['id']
text: NonNullable<Mastodon.Status['text']>
spoiler_text: Mastodon.Status['spoiler_text']
}>({
method: 'get',
url: `statuses/${status.id}/source`
}).then(res => {
dismiss()
navigation.navigate('Screen-Compose', {
type: 'edit',
incomingStatus: {
...status,
text: res.body.text,
spoiler_text: res.body.spoiler_text
},
...(replyToStatus && { replyToStatus }),
queryKey,
rootQueryKey
})
})
}}
iconFront='Edit3'
title={t('shared.header.actions.status.edit.button')}
/>
) : null}
<MenuRow
onPress={() => {
Alert.alert(
t('shared.header.actions.status.delete.alert.title'),
t('shared.header.actions.status.delete.alert.message'),
[
{
text: t('shared.header.actions.status.delete.alert.buttons.confirm'),
style: 'destructive',
onPress: async () => {
dismiss()
mutation.mutate({
type: 'deleteItem',
source: 'statuses',
queryKey,
rootQueryKey,
id: status.id
})
}
},
{
text: t('shared.header.actions.status.delete.alert.buttons.cancel'),
style: 'default'
}
]
)
}}
iconFront='Trash'
title={t('shared.header.actions.status.delete.button')}
/>
<MenuRow
onPress={() => {
Alert.alert(
t('shared.header.actions.status.deleteEdit.alert.title'),
t('shared.header.actions.status.deleteEdit.alert.message'),
[
{
text: t('shared.header.actions.status.deleteEdit.alert.buttons.confirm'),
style: 'destructive',
onPress: async () => {
let replyToStatus: Mastodon.Status | undefined = undefined
if (status.in_reply_to_id) {
replyToStatus = await apiInstance<Mastodon.Status>({
method: 'get',
url: `statuses/${status.in_reply_to_id}`
}).then(res => res.body)
}
mutation
.mutateAsync({
type: 'deleteItem',
source: 'statuses',
queryKey,
id: status.id
})
.then(res => {
dismiss()
navigation.navigate('Screen-Compose', {
type: 'deleteEdit',
incomingStatus: res.body as Mastodon.Status,
...(replyToStatus && { replyToStatus }),
queryKey
})
})
}
},
{
text: t('shared.header.actions.status.deleteEdit.alert.buttons.cancel'),
style: 'default'
}
]
)
}}
iconFront='Edit'
title={t('shared.header.actions.status.deleteEdit.button')}
/>
<MenuRow
onPress={() => {
dismiss()
mutation.mutate({
type: 'updateStatusProperty',
queryKey,
rootQueryKey,
id: status.id,
payload: {
property: 'muted',
currentValue: status.muted,
propertyCount: undefined,
countValue: undefined
}
})
}}
iconFront='VolumeX'
title={
status.muted
? t('shared.header.actions.status.mute.button.negative')
: t('shared.header.actions.status.mute.button.positive')
}
/>
{/* Also note that reblogs cannot be pinned. */}
{(status.visibility === 'public' || status.visibility === 'unlisted') && (
<MenuRow
onPress={() => {
dismiss()
mutation.mutate({
type: 'updateStatusProperty',
queryKey,
rootQueryKey,
id: status.id,
payload: {
property: 'pinned',
currentValue: status.pinned,
propertyCount: undefined,
countValue: undefined
}
})
}}
iconFront='Anchor'
title={
status.pinned
? t('shared.header.actions.status.pin.button.negative')
: t('shared.header.actions.status.pin.button.positive')
}
/>
)}
</MenuContainer>
)
}
export default ActionsStatus

View File

@ -1,3 +1,4 @@
import { handleError } from '@api/helpers'
import { ComponentEmojis } from '@components/Emojis'
import { EmojisState } from '@components/Emojis/helpers/EmojisContext'
import { HeaderLeft, HeaderRight } from '@components/Header'
@ -5,8 +6,7 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack'
import haptics from '@root/components/haptics'
import { useAppDispatch } from '@root/store'
import ComposeRoot from '@screens/Compose/Root'
import formatText from '@screens/Compose/utils/formatText'
import * as Sentry from '@sentry/react-native'
import { formatText } from '@screens/Compose/utils/processText'
import { RootStackScreenProps } from '@utils/navigation/navigators'
import { useTimelineMutation } from '@utils/queryHooks/timeline'
import { updateStoreReview } from '@utils/slices/contextsSlice'
@ -257,13 +257,17 @@ const ScreenCompose: React.FC<RootStackScreenProps<'Screen-Compose'>> = ({
() => (
<HeaderRight
type='text'
content={
params?.type
? params.type === 'conversation' && params.visibility === 'direct'
? t(`heading.right.button.${params.type}`)
: t('heading.right.button.default')
: t('heading.right.button.default')
}
content={t(
`heading.right.button.${
(params?.type &&
(params.type === 'conversation'
? params.visibility === 'direct'
? params.type
: 'default'
: params.type)) ||
'default'
}`
)}
onPress={() => {
composeDispatch({ type: 'posting', payload: true })
@ -319,9 +323,8 @@ const ScreenCompose: React.FC<RootStackScreenProps<'Screen-Compose'>> = ({
]
)
} else {
Sentry.setContext('Error object', { error })
Sentry.captureMessage('Posting error')
haptics('Error')
handleError({ message: 'Posting error', captureResponse: true })
composeDispatch({ type: 'posting', payload: false })
Alert.alert(t('heading.right.alert.default.title'), undefined, [
{

View File

@ -15,7 +15,7 @@ import { PanGestureHandler } from 'react-native-gesture-handler'
import { SwipeListView } from 'react-native-swipe-list-view'
import { useSelector } from 'react-redux'
import ComposeContext from '../utils/createContext'
import formatText from '../utils/formatText'
import { formatText } from '../utils/processText'
import { ComposeStateDraft, ExtendedAttachment } from '../utils/types'
export interface Props {
@ -159,22 +159,23 @@ const ComposeDraftsListRoot: React.FC<Props> = ({ timestamp }) => {
data={instanceDrafts}
renderItem={renderItem}
renderHiddenItem={({ item }) => (
<View
<Pressable
style={{
flex: 1,
flexDirection: 'row',
justifyContent: 'flex-end',
backgroundColor: colors.red
}}
onPress={() => dispatch(removeInstanceDraft(item.timestamp))}
children={
<Pressable
<View
style={{
flexBasis:
StyleConstants.Font.Size.L + StyleConstants.Spacing.Global.PagePadding * 4,
justifyContent: 'center',
alignItems: 'center'
alignItems: 'center',
backgroundColor: 'rgba(0, 255, 0, 0.2)'
}}
onPress={() => dispatch(removeInstanceDraft(item.timestamp))}
children={
<Icon
name='Trash'
@ -188,12 +189,6 @@ const ComposeDraftsListRoot: React.FC<Props> = ({ timestamp }) => {
)}
disableRightSwipe={true}
rightOpenValue={-actionWidth}
// previewRowKey={
// instanceDrafts?.length
// ? instanceDrafts[0].timestamp.toString()
// : undefined
// }
// previewDuration={350}
previewOpenValue={-actionWidth / 2}
ItemSeparatorComponent={ComponentSeparator}
keyExtractor={item => item.timestamp.toString()}

View File

@ -13,13 +13,13 @@ const ComposeRootFooter: React.FC<Props> = ({ accessibleRefAttachments }) => {
const { composeState } = useContext(ComposeContext)
return (
<>
<View>
{composeState.attachments.uploads.length ? (
<ComposeAttachments accessibleRefAttachments={accessibleRefAttachments} />
) : null}
{composeState.poll.active ? <ComposePoll /> : null}
{composeState.replyToStatus ? <ComposeReply /> : null}
</>
</View>
)
}

View File

@ -1,4 +1,5 @@
import TimelineDefault from '@components/Timeline/Default'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useContext } from 'react'
import { StyleSheet, View } from 'react-native'
@ -11,16 +12,24 @@ const ComposeReply: React.FC = () => {
const { colors } = useTheme()
return (
<View style={[styles.base, { borderTopColor: colors.border }]}>
<TimelineDefault item={replyToStatus!} disableDetails disableOnPress />
<View
style={{
flex: 1,
flexDirection: 'row',
minHeight: StyleConstants.Font.LineHeight.M * 5,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: StyleConstants.Spacing.S,
overflow: 'hidden',
borderColor: colors.border,
marginHorizontal: StyleConstants.Spacing.Global.PagePadding,
marginTop: StyleConstants.Spacing.M
}}
>
{replyToStatus ? (
<TimelineDefault item={replyToStatus} disableDetails disableOnPress />
) : null}
</View>
)
}
const styles = StyleSheet.create({
base: {
borderTopWidth: StyleSheet.hairlineWidth
}
})
export default React.memo(ComposeReply, () => true)

View File

@ -11,13 +11,10 @@ import ComposeTextInput from './Header/TextInput'
const ComposeRootHeader: React.FC = () => {
const { composeState } = useContext(ComposeContext)
const instanceActive = useSelector(getInstanceActive)
const localInstances = useSelector(
getInstances,
(prev, next) => prev.length === next.length
)
const localInstances = useSelector(getInstances, (prev, next) => prev.length === next.length)
return (
<>
<View>
{instanceActive !== -1 && localInstances.length > 1 ? (
<View style={styles.postingAs}>
<ComposePostingAs />
@ -25,7 +22,7 @@ const ComposeRootHeader: React.FC = () => {
) : null}
{composeState.spoiler.active ? <ComposeSpoilerInput /> : null}
<ComposeTextInput />
</>
</View>
)
}

View File

@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next'
import { TextInput } from 'react-native'
import { useSelector } from 'react-redux'
import ComposeContext from '../../utils/createContext'
import formatText from '../../utils/formatText'
import { formatText } from '../../utils/processText'
const ComposeSpoilerInput: React.FC = () => {
const { composeState, composeDispatch } = useContext(ComposeContext)

View File

@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next'
import { Alert } from 'react-native'
import { useSelector } from 'react-redux'
import ComposeContext from '../../utils/createContext'
import formatText from '../../utils/formatText'
import { formatText } from '../../utils/processText'
import { uploadAttachment } from '../Footer/addAttachment'
const ComposeTextInput: React.FC = () => {
@ -27,12 +27,11 @@ const ComposeTextInput: React.FC = () => {
return (
<PasteInput
keyboardAppearance={mode}
keyboardType='twitter'
style={{
...StyleConstants.FontStyle.M,
marginTop: StyleConstants.Spacing.S,
paddingBottom: StyleConstants.Spacing.M,
marginLeft: StyleConstants.Spacing.Global.PagePadding,
marginRight: StyleConstants.Spacing.Global.PagePadding,
marginHorizontal: StyleConstants.Spacing.Global.PagePadding,
color: colors.primaryDefault,
borderBottomColor: colors.border,
fontSize: adaptedFontsize,

View File

@ -3,7 +3,7 @@ import haptics from '@components/haptics'
import ComponentHashtag from '@components/Hashtag'
import React, { useContext, useEffect } from 'react'
import ComposeContext from '../utils/createContext'
import formatText from '../utils/formatText'
import { formatText } from '../utils/processText'
type Props = { item: Mastodon.Account & Mastodon.Tag }

View File

@ -1,7 +1,9 @@
import apiInstance, { InstanceResponse } from '@api/instance'
import detectLanguage from '@helpers/detectLanguage'
import { ComposeState } from '@screens/Compose/utils/types'
import { RootStackParamList } from '@utils/navigation/navigators'
import * as Crypto from 'expo-crypto'
import { getPureContent } from './processText'
const composePost = async (
params: RootStackParamList['Screen-Compose'],
@ -9,6 +11,13 @@ const composePost = async (
): Promise<InstanceResponse<Mastodon.Status>> => {
const formData = new FormData()
const detectedLanguage = await detectLanguage(
getPureContent([composeState.spoiler.raw, composeState.text.raw].join('\n\n'))
)
if (detectedLanguage) {
formData.append('language', detectedLanguage.language)
}
if (composeState.replyToStatus) {
try {
await apiInstance<Mastodon.Status>({

View File

@ -150,7 +150,7 @@ const formatText = ({ textInput, composeDispatch, content, disableDebounce = fal
})
children.push(_content)
contentLength = contentLength + _content.length
getPureContent(content)
composeDispatch({
type: textInput,
payload: {
@ -161,4 +161,18 @@ const formatText = ({ textInput, composeDispatch, content, disableDebounce = fal
})
}
export default formatText
const getPureContent = (content: string): string => {
const tags = linkify.match(content)
if (!tags) {
return content
}
let _content = content
for (const tag of tags) {
_content = _content.replace(tag.raw, '')
}
return _content.replace(/\s\s+/g, ' ')
}
export { formatText, getPureContent }

View File

@ -2,22 +2,19 @@ import haptics from '@components/haptics'
import { displayMessage } from '@components/Message'
import { CameraRoll } from '@react-native-camera-roll/camera-roll'
import { RootStackParamList } from '@utils/navigation/navigators'
import { Theme } from '@utils/styles/themes'
import * as FileSystem from 'expo-file-system'
import i18next from 'i18next'
import { PermissionsAndroid, Platform } from 'react-native'
type CommonProps = {
theme: Theme
image: RootStackParamList['Screen-ImagesViewer']['imageUrls'][0]
}
const saveIos = async ({ theme, image }: CommonProps) => {
const saveIos = async ({ image }: CommonProps) => {
CameraRoll.save(image.url)
.then(() => {
haptics('Success')
displayMessage({
theme,
type: 'success',
message: i18next.t('screenImageViewer:content.save.succeed')
})
@ -28,7 +25,6 @@ const saveIos = async ({ theme, image }: CommonProps) => {
.then(() => {
haptics('Success')
displayMessage({
theme,
type: 'success',
message: i18next.t('screenImageViewer:content.save.succeed')
})
@ -36,32 +32,31 @@ const saveIos = async ({ theme, image }: CommonProps) => {
.catch(() => {
haptics('Error')
displayMessage({
theme,
type: 'error',
type: 'danger',
message: i18next.t('screenImageViewer:content.save.failed')
})
})
} else {
haptics('Error')
displayMessage({
theme,
type: 'error',
type: 'danger',
message: i18next.t('screenImageViewer:content.save.failed')
})
}
})
}
const saveAndroid = async ({ theme, image }: CommonProps) => {
const saveAndroid = async ({ image }: CommonProps) => {
const fileUri: string = `${FileSystem.documentDirectory}${image.id}.jpg`
const downloadedFile: FileSystem.FileSystemDownloadResult =
await FileSystem.downloadAsync(image.url, fileUri)
const downloadedFile: FileSystem.FileSystemDownloadResult = await FileSystem.downloadAsync(
image.url,
fileUri
)
if (downloadedFile.status != 200) {
haptics('Error')
displayMessage({
theme,
type: 'error',
type: 'danger',
message: i18next.t('screenImageViewer:content.save.failed')
})
return
@ -75,8 +70,7 @@ const saveAndroid = async ({ theme, image }: CommonProps) => {
if (status !== 'granted') {
haptics('Error')
displayMessage({
theme,
type: 'error',
type: 'danger',
message: i18next.t('screenImageViewer:content.save.failed')
})
return
@ -87,7 +81,6 @@ const saveAndroid = async ({ theme, image }: CommonProps) => {
.then(() => {
haptics('Success')
displayMessage({
theme,
type: 'success',
message: i18next.t('screenImageViewer:content.save.succeed')
})
@ -95,8 +88,7 @@ const saveAndroid = async ({ theme, image }: CommonProps) => {
.catch(() => {
haptics('Error')
displayMessage({
theme,
type: 'error',
type: 'danger',
message: i18next.t('screenImageViewer:content.save.failed')
})
})

View File

@ -40,7 +40,7 @@ const ScreenImagesViewer = ({
const insets = useSafeAreaInsets()
const { mode, theme } = useTheme()
const { mode } = useTheme()
const { t } = useTranslation('screenImageViewer')
const initialIndex = imageUrls.findIndex(image => image.id === id)
@ -61,7 +61,7 @@ const ScreenImagesViewer = ({
async buttonIndex => {
switch (buttonIndex) {
case 0:
saveImage({ theme, image: imageUrls[currentIndex] })
saveImage({ image: imageUrls[currentIndex] })
break
case 1:
switch (Platform.OS) {
@ -188,7 +188,7 @@ const ScreenImagesViewer = ({
async buttonIndex => {
switch (buttonIndex) {
case 0:
saveImage({ theme, image: imageUrls[currentIndex] })
saveImage({ image: imageUrls[currentIndex] })
break
case 1:
switch (Platform.OS) {

View File

@ -6,8 +6,7 @@ import { ScreenTabsScreenProps, TabLocalStackParamList } from '@utils/navigation
import usePopToTop from '@utils/navigation/usePopToTop'
import { useListsQuery } from '@utils/queryHooks/lists'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import layoutAnimation from '@utils/styles/layoutAnimation'
import React, { useEffect, useState } from 'react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import * as DropdownMenu from 'zeego/dropdown-menu'
import TabShared from './Shared'
@ -19,9 +18,6 @@ const TabLocal = React.memo(
const { t } = useTranslation('screenTabs')
const { data: lists } = useListsQuery({})
useEffect(() => {
layoutAnimation()
}, [lists?.length])
const [queryKey, setQueryKey] = useState<QueryKeyTimeline>(['Timeline', { page: 'Following' }])

View File

@ -129,15 +129,11 @@ const TabMe = React.memo(
name='Tab-Me-Push'
component={TabMePush}
options={({ navigation }) => ({
presentation: 'modal',
headerShown: true,
title: t('me.stacks.push.name'),
...(Platform.OS === 'android' && {
headerCenter: () => <HeaderCenter content={t('me.stacks.push.name')} />
}),
headerLeft: () => (
<HeaderLeft content='ChevronDown' onPress={() => navigation.goBack()} />
)
headerLeft: () => <HeaderLeft onPress={() => navigation.goBack()} />
})}
/>
<Stack.Screen

View File

@ -41,8 +41,7 @@ const TabMeListAccounts: React.FC<TabMeStackScreenProps<'Tab-Me-List-Accounts'>>
},
onError: () => {
displayMessage({
theme,
type: 'error',
type: 'danger',
message: t('common:message.error.message', {
function: t('me.listAccounts.error')
})

View File

@ -41,8 +41,7 @@ const TabMeListEdit: React.FC<TabMeStackScreenProps<'Tab-Me-List-Edit'>> = ({
onError: () => {
displayMessage({
ref: messageRef,
theme,
type: 'error',
type: 'danger',
message: t('common:message.error.message', {
function:
params.type === 'add' ? t('me.stacks.listAdd.name') : t('me.stacks.listEdit.name')

View File

@ -17,7 +17,7 @@ const TabMeList: React.FC<TabMeStackScreenProps<'Tab-Me-List'>> = ({
navigation,
route: { key, params }
}) => {
const { colors, theme } = useTheme()
const { colors } = useTheme()
const { t } = useTranslation('screenTabs')
const queryKey: QueryKeyTimeline = ['Timeline', { page: 'List', list: params.id }]
@ -30,8 +30,7 @@ const TabMeList: React.FC<TabMeStackScreenProps<'Tab-Me-List'>> = ({
},
onError: () => {
displayMessage({
theme,
type: 'error',
type: 'danger',
message: t('common:message.error.message', {
function: t('me.listDelete.heading')
})

View File

@ -121,7 +121,6 @@ const TabMeProfileFields: React.FC<
content='Save'
onPress={async () => {
mutateAsync({
theme,
messageRef,
message: {
text: 'me.profile.root.note.title',

View File

@ -75,7 +75,6 @@ const TabMeProfileName: React.FC<
content='Save'
onPress={async () => {
mutateAsync({
theme,
messageRef,
message: {
text: 'me.profile.root.name.title',

View File

@ -75,7 +75,6 @@ const TabMeProfileNote: React.FC<
content='Save'
onPress={async () => {
mutateAsync({
theme,
messageRef,
message: {
text: 'me.profile.root.note.title',

View File

@ -5,7 +5,7 @@ import { TabMeProfileStackScreenProps } from '@utils/navigation/navigators'
import { useProfileMutation, useProfileQuery } from '@utils/queryHooks/profile'
import { updateAccountPreferences } from '@utils/slices/instances/updateAccountPreferences'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { RefObject, useCallback } from 'react'
import React, { RefObject } from 'react'
import { useTranslation } from 'react-i18next'
import FlashMessage from 'react-native-flash-message'
import { ScrollView } from 'react-native-gesture-handler'
@ -16,99 +16,15 @@ const TabMeProfileRoot: React.FC<
messageRef: RefObject<FlashMessage>
}
> = ({ messageRef, navigation }) => {
const { mode, theme } = useTheme()
const { mode } = useTheme()
const { t } = useTranslation('screenTabs')
const { showActionSheetWithOptions } = useActionSheet()
const { data, isLoading } = useProfileQuery({})
const { data, isLoading } = useProfileQuery()
const { mutateAsync } = useProfileMutation()
const dispatch = useAppDispatch()
const onPressVisibility = useCallback(() => {
showActionSheetWithOptions(
{
title: t('me.profile.root.visibility.title'),
options: [
t('me.profile.root.visibility.options.public'),
t('me.profile.root.visibility.options.unlisted'),
t('me.profile.root.visibility.options.private'),
t('common:buttons.cancel')
],
cancelButtonIndex: 3,
userInterfaceStyle: mode
},
async buttonIndex => {
switch (buttonIndex) {
case 0:
case 1:
case 2:
const indexVisibilityMapping = ['public', 'unlisted', 'private'] as [
'public',
'unlisted',
'private'
]
if (data?.source.privacy !== indexVisibilityMapping[buttonIndex]) {
mutateAsync({
theme,
messageRef,
message: {
text: 'me.profile.root.visibility.title',
succeed: false,
failed: true
},
type: 'source[privacy]',
data: indexVisibilityMapping[buttonIndex]
}).then(() => dispatch(updateAccountPreferences()))
}
break
}
}
)
}, [theme, data?.source?.privacy])
const onPressSensitive = useCallback(() => {
mutateAsync({
theme,
messageRef,
message: {
text: 'me.profile.root.sensitive.title',
succeed: false,
failed: true
},
type: 'source[sensitive]',
data: data?.source.sensitive === undefined ? true : !data.source.sensitive
}).then(() => dispatch(updateAccountPreferences()))
}, [data?.source.sensitive])
const onPressLock = useCallback(() => {
mutateAsync({
theme,
messageRef,
message: {
text: 'me.profile.root.lock.title',
succeed: false,
failed: true
},
type: 'locked',
data: data?.locked === undefined ? true : !data.locked
})
}, [theme, data?.locked])
const onPressBot = useCallback(() => {
mutateAsync({
theme,
messageRef,
message: {
text: 'me.profile.root.bot.title',
succeed: false,
failed: true
},
type: 'bot',
data: data?.bot === undefined ? true : !data.bot
})
}, [theme, data?.bot])
return (
<ScrollView>
<MenuContainer>
@ -166,12 +82,62 @@ const TabMeProfileRoot: React.FC<
}
loading={isLoading}
iconBack='ChevronRight'
onPress={onPressVisibility}
onPress={() =>
showActionSheetWithOptions(
{
title: t('me.profile.root.visibility.title'),
options: [
t('me.profile.root.visibility.options.public'),
t('me.profile.root.visibility.options.unlisted'),
t('me.profile.root.visibility.options.private'),
t('common:buttons.cancel')
],
cancelButtonIndex: 3,
userInterfaceStyle: mode
},
async buttonIndex => {
switch (buttonIndex) {
case 0:
case 1:
case 2:
const indexVisibilityMapping = ['public', 'unlisted', 'private'] as [
'public',
'unlisted',
'private'
]
if (data?.source.privacy !== indexVisibilityMapping[buttonIndex]) {
mutateAsync({
messageRef,
message: {
text: 'me.profile.root.visibility.title',
succeed: false,
failed: true
},
type: 'source[privacy]',
data: indexVisibilityMapping[buttonIndex]
}).then(() => dispatch(updateAccountPreferences()))
}
break
}
}
)
}
/>
<MenuRow
title={t('me.profile.root.sensitive.title')}
switchValue={data?.source.sensitive}
switchOnValueChange={onPressSensitive}
switchOnValueChange={() =>
mutateAsync({
messageRef,
message: {
text: 'me.profile.root.sensitive.title',
succeed: false,
failed: true
},
type: 'source[sensitive]',
data: data?.source.sensitive === undefined ? true : !data.source.sensitive
}).then(() => dispatch(updateAccountPreferences()))
}
loading={isLoading}
/>
</MenuContainer>
@ -180,14 +146,36 @@ const TabMeProfileRoot: React.FC<
title={t('me.profile.root.lock.title')}
description={t('me.profile.root.lock.description')}
switchValue={data?.locked}
switchOnValueChange={onPressLock}
switchOnValueChange={() =>
mutateAsync({
messageRef,
message: {
text: 'me.profile.root.lock.title',
succeed: false,
failed: true
},
type: 'locked',
data: data?.locked === undefined ? true : !data.locked
})
}
loading={isLoading}
/>
<MenuRow
title={t('me.profile.root.bot.title')}
description={t('me.profile.root.bot.description')}
switchValue={data?.bot}
switchOnValueChange={onPressBot}
switchOnValueChange={() =>
mutateAsync({
messageRef,
message: {
text: 'me.profile.root.bot.title',
succeed: false,
failed: true
},
type: 'bot',
data: data?.bot === undefined ? true : !data.bot
})
}
loading={isLoading}
/>
</MenuContainer>

View File

@ -3,7 +3,6 @@ import { MenuRow } from '@components/Menu'
import { displayMessage } from '@components/Message'
import { useActionSheet } from '@expo/react-native-action-sheet'
import { useProfileMutation, useProfileQuery } from '@utils/queryHooks/profile'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { RefObject } from 'react'
import { useTranslation } from 'react-i18next'
import FlashMessage from 'react-native-flash-message'
@ -14,12 +13,11 @@ export interface Props {
}
const ProfileAvatarHeader: React.FC<Props> = ({ type, messageRef }) => {
const { theme } = useTheme()
const { t } = useTranslation('screenTabs')
const { showActionSheetWithOptions } = useActionSheet()
const query = useProfileQuery({})
const query = useProfileQuery()
const mutation = useProfileMutation()
return (
@ -40,7 +38,6 @@ const ProfileAvatarHeader: React.FC<Props> = ({ type, messageRef }) => {
})
if (image[0].uri) {
mutation.mutate({
theme,
messageRef,
message: {
text: `me.profile.root.${type}.title`,
@ -54,8 +51,7 @@ const ProfileAvatarHeader: React.FC<Props> = ({ type, messageRef }) => {
displayMessage({
ref: messageRef,
message: t('screenTabs:me.profile.mediaSelectionFailed'),
theme: theme,
type: 'error'
type: 'danger'
})
}
}}

View File

@ -5,31 +5,42 @@ import CustomText from '@components/Text'
import browserPackage from '@helpers/browserPackage'
import { useAppDispatch } from '@root/store'
import { isDevelopment } from '@utils/checkEnvironment'
import { getExpoToken } from '@utils/slices/appSlice'
import { useAppsQuery } from '@utils/queryHooks/apps'
import { useProfileQuery } from '@utils/queryHooks/profile'
import { getExpoToken, retrieveExpoToken } from '@utils/slices/appSlice'
import {
checkPushAdminPermission,
PUSH_ADMIN,
PUSH_DEFAULT,
setChannels
} from '@utils/slices/instances/push/utils'
import { updateInstancePush } from '@utils/slices/instances/updatePush'
import { updateInstancePushAlert } from '@utils/slices/instances/updatePushAlert'
import { updateInstancePushDecode } from '@utils/slices/instances/updatePushDecode'
import {
clearPushLoading,
getInstanceAccount,
getInstancePush,
getInstanceUri
} from '@utils/slices/instancesSlice'
import { getInstance, getInstancePush } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import layoutAnimation from '@utils/styles/layoutAnimation'
import { useTheme } from '@utils/styles/ThemeManager'
import * as Notifications from 'expo-notifications'
import * as WebBrowser from 'expo-web-browser'
import React, { useState, useEffect, useMemo } from 'react'
import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { AppState, Linking, ScrollView, View } from 'react-native'
import { AppState, Linking, Platform, ScrollView, View } from 'react-native'
import { useSelector } from 'react-redux'
const TabMePush: React.FC = () => {
const { colors } = useTheme()
const { t } = useTranslation('screenTabs')
const instanceAccount = useSelector(getInstanceAccount, (prev, next) => prev?.acct === next?.acct)
const instanceUri = useSelector(getInstanceUri)
const instance = useSelector(getInstance)
const expoToken = useSelector(getExpoToken)
const [serverKeyAvailable, setServerKeyAvailable] = useState<boolean>()
useAppsQuery({
options: {
onSuccess: data => setServerKeyAvailable(!!data.vapid_key)
}
})
const dispatch = useAppDispatch()
const instancePush = useSelector(getInstancePush)
@ -37,61 +48,55 @@ const TabMePush: React.FC = () => {
const [pushAvailable, setPushAvailable] = useState<boolean>()
const [pushEnabled, setPushEnabled] = useState<boolean>()
const [pushCanAskAgain, setPushCanAskAgain] = useState<boolean>()
const checkPush = async () => {
const settings = await Notifications.getPermissionsAsync()
layoutAnimation()
setPushEnabled(settings.granted)
setPushCanAskAgain(settings.canAskAgain)
}
const expoToken = useSelector(getExpoToken)
useEffect(() => {
if (isDevelopment) {
setPushAvailable(true)
} else {
setPushAvailable(!!expoToken)
const checkPush = async () => {
switch (Platform.OS) {
case 'ios':
const settings = await Notifications.getPermissionsAsync()
layoutAnimation()
setPushEnabled(settings.granted)
setPushCanAskAgain(settings.canAskAgain)
break
case 'android':
await setChannels(instance)
layoutAnimation()
dispatch(retrieveExpoToken())
break
}
}
if (serverKeyAvailable) {
checkPush()
if (isDevelopment) {
setPushAvailable(true)
} else {
setPushAvailable(!!expoToken)
}
}
checkPush()
const subscription = AppState.addEventListener('change', checkPush)
return () => {
subscription.remove()
}
}, [])
}, [serverKeyAvailable])
useEffect(() => {
dispatch(clearPushLoading())
}, [])
const isLoading = instancePush?.global.loading || instancePush?.decode.loading
const alerts = useMemo(() => {
return instancePush?.alerts
? (
['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'poll', 'status'] as [
'follow',
'follow_request',
'favourite',
'reblog',
'mention',
'poll',
'status'
]
).map(alert => (
const alerts = () =>
instancePush?.alerts
? PUSH_DEFAULT.map(alert => (
<MenuRow
key={alert}
title={t(`me.push.${alert}.heading`)}
switchDisabled={!pushEnabled || !instancePush.global.value || isLoading}
switchValue={instancePush?.alerts[alert].value}
switchDisabled={!pushEnabled || !instancePush.global}
switchValue={instancePush?.alerts[alert]}
switchOnValueChange={() =>
dispatch(
updateInstancePushAlert({
changed: alert,
alerts: {
...instancePush?.alerts,
[alert]: {
...instancePush?.alerts[alert],
value: !instancePush?.alerts[alert].value
}
[alert]: instancePush?.alerts[alert]
}
})
)
@ -99,69 +104,120 @@ const TabMePush: React.FC = () => {
/>
))
: null
}, [pushEnabled, instancePush?.global, instancePush?.alerts, isLoading])
const profileQuery = useProfileQuery()
const adminAlerts = () =>
profileQuery.data?.role?.permissions
? PUSH_ADMIN.map(({ type, permission }) =>
checkPushAdminPermission(permission, profileQuery.data.role?.permissions) ? (
<MenuRow
key={type}
title={t(`me.push.${type}.heading`)}
switchDisabled={!pushEnabled || !instancePush.global}
switchValue={instancePush?.alerts[type]}
switchOnValueChange={() =>
dispatch(
updateInstancePushAlert({
changed: type,
alerts: {
...instancePush?.alerts,
[type]: instancePush?.alerts[type]
}
})
)
}
/>
) : null
)
: null
return (
<ScrollView>
{!!pushAvailable ? (
{!!serverKeyAvailable ? (
<>
{pushEnabled === false ? (
<MenuContainer>
<Button
type='text'
content={
pushCanAskAgain ? t('me.push.enable.direct') : t('me.push.enable.settings')
}
style={{
marginTop: StyleConstants.Spacing.Global.PagePadding,
marginHorizontal: StyleConstants.Spacing.Global.PagePadding * 2
}}
onPress={async () => {
if (pushCanAskAgain) {
const result = await Notifications.requestPermissionsAsync()
setPushEnabled(result.granted)
setPushCanAskAgain(result.canAskAgain)
} else {
Linking.openSettings()
{!!pushAvailable ? (
<>
{pushEnabled === false ? (
<MenuContainer>
<Button
type='text'
content={
pushCanAskAgain ? t('me.push.enable.direct') : t('me.push.enable.settings')
}
style={{
marginTop: StyleConstants.Spacing.Global.PagePadding,
marginHorizontal: StyleConstants.Spacing.Global.PagePadding * 2
}}
onPress={async () => {
if (pushCanAskAgain) {
const result = await Notifications.requestPermissionsAsync()
setPushEnabled(result.granted)
setPushCanAskAgain(result.canAskAgain)
} else {
Linking.openSettings()
}
}}
/>
</MenuContainer>
) : null}
<MenuContainer>
<MenuRow
title={t('me.push.global.heading', {
acct: `@${instance.account.acct}@${instance.uri}`
})}
description={t('me.push.global.description')}
switchDisabled={!pushEnabled}
switchValue={pushEnabled === false ? false : instancePush?.global}
switchOnValueChange={() => dispatch(updateInstancePush(!instancePush?.global))}
/>
</MenuContainer>
<MenuContainer>
<MenuRow
title={t('me.push.decode.heading')}
description={t('me.push.decode.description')}
loading={instancePush?.decode}
switchDisabled={!pushEnabled || !instancePush?.global}
switchValue={instancePush?.decode}
switchOnValueChange={() =>
dispatch(updateInstancePushDecode(!instancePush?.decode))
}
/>
<MenuRow
title={t('me.push.howitworks')}
iconBack='ExternalLink'
onPress={async () =>
WebBrowser.openBrowserAsync('https://tooot.app/how-push-works', {
browserPackage: await browserPackage()
})
}
/>
</MenuContainer>
<MenuContainer children={alerts()} />
<MenuContainer children={adminAlerts()} />
</>
) : (
<View
style={{
flex: 1,
minHeight: '100%',
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: StyleConstants.Spacing.Global.PagePadding
}}
>
<Icon name='Frown' size={StyleConstants.Font.Size.L} color={colors.primaryDefault} />
<CustomText
fontStyle='M'
style={{
color: colors.primaryDefault,
textAlign: 'center',
marginTop: StyleConstants.Spacing.S
}}
/>
</MenuContainer>
) : null}
<MenuContainer>
<MenuRow
title={t('me.push.global.heading', {
acct: `@${instanceAccount?.acct}@${instanceUri}`
})}
description={t('me.push.global.description')}
loading={instancePush?.global.loading}
switchDisabled={!pushEnabled || isLoading}
switchValue={pushEnabled === false ? false : instancePush?.global.value}
switchOnValueChange={() => dispatch(updateInstancePush(!instancePush?.global.value))}
/>
</MenuContainer>
<MenuContainer>
<MenuRow
title={t('me.push.decode.heading')}
description={t('me.push.decode.description')}
loading={instancePush?.decode.loading}
switchDisabled={!pushEnabled || !instancePush?.global.value || isLoading}
switchValue={instancePush?.decode.value}
switchOnValueChange={() =>
dispatch(updateInstancePushDecode(!instancePush?.decode.value))
}
/>
<MenuRow
title={t('me.push.howitworks')}
iconBack='ExternalLink'
onPress={async () =>
WebBrowser.openBrowserAsync('https://tooot.app/how-push-works', {
browserPackage: await browserPackage()
})
}
/>
</MenuContainer>
<MenuContainer>{alerts}</MenuContainer>
>
{t('me.push.notAvailable')}
</CustomText>
</View>
)}
</>
) : (
<View
@ -169,12 +225,29 @@ const TabMePush: React.FC = () => {
flex: 1,
minHeight: '100%',
justifyContent: 'center',
alignItems: 'center'
alignItems: 'center',
paddingHorizontal: StyleConstants.Spacing.Global.PagePadding
}}
>
<Icon name='Frown' size={StyleConstants.Font.Size.L} color={colors.primaryDefault} />
<CustomText fontStyle='M' style={{ color: colors.primaryDefault }}>
{t('me.push.notAvailable')}
<CustomText
fontStyle='M'
style={{
color: colors.primaryDefault,
textAlign: 'center',
marginTop: StyleConstants.Spacing.S
}}
>
{t('me.push.missingServerKey.message')}
</CustomText>
<CustomText
fontStyle='S'
style={{
color: colors.primaryDefault,
textAlign: 'center'
}}
>
{t('me.push.missingServerKey.description')}
</CustomText>
</View>
)}

View File

@ -50,10 +50,7 @@ const Collections: React.FC = () => {
}
}, [announcementsQuery.data])
const instancePush = useSelector(
getInstancePush,
(prev, next) => prev?.global.value === next?.global.value
)
const instancePush = useSelector(getInstancePush, (prev, next) => prev?.global === next?.global)
return (
<MenuContainer>
@ -102,11 +99,7 @@ const Collections: React.FC = () => {
iconFront={instancePush ? 'Bell' : 'BellOff'}
iconBack='ChevronRight'
title={t('me.stacks.push.name')}
content={
instancePush.global.value
? t('me.root.push.content.enabled')
: t('me.root.push.content.disabled')
}
content={t('me.root.push.content', { context: instancePush.global.toString() })}
onPress={() => navigation.navigate('Tab-Me-Push')}
/>
</MenuContainer>

View File

@ -4,13 +4,11 @@ import { useNavigation } from '@react-navigation/native'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import * as Linking from 'expo-linking'
import * as StoreReview from 'expo-store-review'
import * as WebBrowser from 'expo-web-browser'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
import { getInstanceActive, getInstanceVersion } from '@utils/slices/instancesSlice'
import { isDevelopment, isRelease } from '@utils/checkEnvironment'
import { Platform } from 'react-native'
import Constants from 'expo-constants'
import { getExpoToken } from '@utils/slices/appSlice'
@ -27,6 +25,12 @@ const SettingsTooot: React.FC = () => {
return (
<MenuContainer>
<MenuRow
title={t('me.settings.support.heading')}
content={<Icon name='Heart' size={StyleConstants.Font.Size.M} color={colors.red} />}
iconBack='ChevronRight'
onPress={() => Linking.openURL('https://www.buymeacoffee.com/xmflsct')}
/>
<MenuRow
title={t('me.settings.feedback.heading')}
content={
@ -35,20 +39,6 @@ const SettingsTooot: React.FC = () => {
iconBack='ChevronRight'
onPress={() => Linking.openURL('https://feedback.tooot.app/feature-requests')}
/>
<MenuRow
title={t('me.settings.support.heading')}
content={<Icon name='Heart' size={StyleConstants.Font.Size.M} color={colors.red} />}
iconBack='ChevronRight'
onPress={() => Linking.openURL('https://www.buymeacoffee.com/xmflsct')}
/>
{isDevelopment || isRelease ? (
<MenuRow
title={t('me.settings.review.heading')}
content={<Icon name='Star' size={StyleConstants.Font.Size.M} color='#FF9500' />}
iconBack='ChevronRight'
onPress={() => StoreReview?.isAvailableAsync().then(() => StoreReview?.requestReview())}
/>
) : null}
<MenuRow
title={t('me.settings.contact.heading')}
content={<Icon name='Mail' size={StyleConstants.Font.Size.M} color={colors.secondary} />}

View File

@ -2,19 +2,18 @@ import haptics from '@components/haptics'
import { MenuContainer, MenuRow } from '@components/Menu'
import { LOCALES } from '@root/i18n/locales'
import { TabMeStackScreenProps } from '@utils/navigation/navigators'
import androidDefaults from '@utils/slices/instances/push/androidDefaults'
import { setChannels } from '@utils/slices/instances/push/utils'
import { getInstances } from '@utils/slices/instancesSlice'
import { changeLanguage } from '@utils/slices/settingsSlice'
import * as Notifications from 'expo-notifications'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { FlatList, Platform } from 'react-native'
import { useDispatch, useSelector } from 'react-redux'
const TabMeSettingsLanguage: React.FC<
TabMeStackScreenProps<'Tab-Me-Settings-Language'>
> = ({ navigation }) => {
const { i18n, t } = useTranslation('screenTabs')
const TabMeSettingsLanguage: React.FC<TabMeStackScreenProps<'Tab-Me-Settings-Language'>> = ({
navigation
}) => {
const { i18n } = useTranslation('screenTabs')
const languages = Object.entries(LOCALES)
const instances = useSelector(getInstances)
const dispatch = useDispatch()
@ -27,45 +26,7 @@ const TabMeSettingsLanguage: React.FC<
// Update Android notification channel language
if (Platform.OS === 'android') {
instances.forEach(instance => {
const accountFull = `@${instance.account.acct}@${instance.uri}`
if (instance.push.decode.value === false) {
Notifications.setNotificationChannelAsync(`${accountFull}_default`, {
groupId: accountFull,
name: t('me.push.default.heading'),
...androidDefaults
})
} else {
Notifications.setNotificationChannelAsync(`${accountFull}_follow`, {
groupId: accountFull,
name: t('me.push.follow.heading'),
...androidDefaults
})
Notifications.setNotificationChannelAsync(
`${accountFull}_favourite`,
{
groupId: accountFull,
name: t('me.push.favourite.heading'),
...androidDefaults
}
)
Notifications.setNotificationChannelAsync(`${accountFull}_reblog`, {
groupId: accountFull,
name: t('me.push.reblog.heading'),
...androidDefaults
})
Notifications.setNotificationChannelAsync(`${accountFull}_mention`, {
groupId: accountFull,
name: t('me.push.mention.heading'),
...androidDefaults
})
Notifications.setNotificationChannelAsync(`${accountFull}_poll`, {
groupId: accountFull,
name: t('me.push.poll.heading'),
...androidDefaults
})
}
})
instances.forEach(setChannels)
}
navigation.pop(1)

View File

@ -23,7 +23,13 @@ const AccountInformationNote = React.memo(
return (
<View style={styles.note}>
<ParseHTML content={account.note!} size={'M'} emojis={account.emojis} selectable />
<ParseHTML
content={account.note!}
size={'M'}
emojis={account.emojis}
selectable
numberOfLines={999}
/>
</View>
)
},

View File

@ -72,16 +72,18 @@ const SearchEmpty: React.FC<Props> = ({ isLoading, inputRef, setSearchTerm }) =>
</CustomText>
</View>
<CustomText
style={{
color: colors.primaryDefault,
marginTop: StyleConstants.Spacing.M,
paddingHorizontal: StyleConstants.Spacing.Global.PagePadding
}}
fontWeight='Bold'
>
{t('shared.search.empty.trending.tags')}
</CustomText>
{trendsTags.data?.length ? (
<CustomText
style={{
color: colors.primaryDefault,
marginTop: StyleConstants.Spacing.M,
paddingHorizontal: StyleConstants.Spacing.Global.PagePadding
}}
fontWeight='Bold'
>
{t('shared.search.empty.trending.tags')}
</CustomText>
) : null}
<View>
{trendsTags.data?.map((tag, index) => {
const hashtag = tag as Mastodon.Tag

View File

@ -1,13 +1,8 @@
import apiInstance from '@api/instance'
import NetInfo from '@react-native-community/netinfo'
import { store } from '@root/store'
import initQuery from '@utils/initQuery'
import { getPreviousTab } from '@utils/slices/contextsSlice'
import removeInstance from '@utils/slices/instances/remove'
import {
getInstance,
updateInstanceAccount
} from '@utils/slices/instancesSlice'
import { getInstance, updateInstanceAccount } from '@utils/slices/instancesSlice'
import { onlineManager } from 'react-query'
import log from './log'
@ -22,9 +17,7 @@ const netInfo = async (): Promise<{
onlineManager.setEventListener(setOnline => {
return NetInfo.addEventListener(state => {
setOnline(
typeof state.isConnected === 'boolean' ? state.isConnected : undefined
)
setOnline(typeof state.isConnected === 'boolean' ? state.isConnected : undefined)
})
})
@ -60,23 +53,6 @@ const netInfo = async (): Promise<{
})
)
if (instance.timelinesLookback) {
const previousTab = getPreviousTab(store.getState())
let loadPage:
| Extract<App.Pages, 'Following' | 'Local' | 'LocalPublic'>
| undefined = undefined
if (previousTab === 'Tab-Local') {
loadPage = 'Following'
} else if (previousTab === 'Tab-Public') {
loadPage = 'LocalPublic'
}
// await initQuery({
// instance,
// prefetch: { enabled: true, page: loadPage }
// })
}
return Promise.resolve({ connected: true })
}
} else {

View File

@ -46,7 +46,7 @@ const instancesPersistConfig = {
key: 'instances',
prefix,
storage: Platform.OS === 'ios' ? secureStorage : AsyncStorage,
version: 10,
version: 11,
// @ts-ignore
migrate: createMigrate(instancesMigration)
}

View File

@ -1,33 +1,11 @@
import queryClient from '@helpers/queryClient'
import { store } from '@root/store'
import { InstanceLatest } from './migrations/instances/migration'
// import { prefetchTimelineQuery } from './queryHooks/timeline'
import { updateInstanceActive } from './slices/instancesSlice'
const initQuery = async ({
instance,
prefetch
}: {
instance: InstanceLatest
prefetch?: { enabled: boolean; page?: 'Following' | 'LocalPublic' }
}) => {
const initQuery = async ({ instance }: { instance: InstanceLatest }) => {
store.dispatch(updateInstanceActive(instance))
await queryClient.resetQueries()
// if (prefetch?.enabled && instance.timelinesLookback) {
// if (
// prefetch.page &&
// instance.timelinesLookback[prefetch.page]?.ids?.length > 0
// ) {
// await prefetchTimelineQuery(instance.timelinesLookback[prefetch.page])
// }
// for (const page of Object.keys(instance.timelinesLookback)) {
// if (page !== prefetch.page) {
// prefetchTimelineQuery(instance.timelinesLookback[page])
// }
// }
// }
}
export default initQuery

View File

@ -6,6 +6,7 @@ import { InstanceV7 } from './v7'
import { InstanceV8 } from './v8'
import { InstanceV9 } from './v9'
import { InstanceV10 } from './v10'
import { InstanceV11 } from './v11'
const instancesMigration = {
4: (state: InstanceV3): InstanceV4 => {
@ -128,9 +129,34 @@ const instancesMigration = {
}
})
}
},
11: (state: { instances: InstanceV10[] }): { instances: InstanceV11[] } => {
return {
instances: state.instances.map(instance => {
return {
...instance,
push: {
...instance.push,
global: instance.push.global.value,
decode: instance.push.decode.value,
alerts: {
follow: instance.push.alerts.follow.value,
follow_request: instance.push.alerts.follow_request.value,
favourite: instance.push.alerts.favourite.value,
reblog: instance.push.alerts.reblog.value,
mention: instance.push.alerts.mention.value,
poll: instance.push.alerts.poll.value,
status: instance.push.alerts.status.value,
'admin.sign_up': false,
'admin.report': false
}
}
}
})
}
}
}
export { InstanceV10 as InstanceLatest }
export { InstanceV11 as InstanceLatest }
export default instancesMigration

View File

@ -0,0 +1,60 @@
import { ComposeStateDraft } from '@screens/Compose/utils/types'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
export type InstanceV11 = {
active: boolean
appData: {
clientId: string
clientSecret: string
}
url: string
token: string
uri: Mastodon.Instance['uri']
urls: Mastodon.Instance['urls']
account: {
id: Mastodon.Account['id']
acct: Mastodon.Account['acct']
avatarStatic: Mastodon.Account['avatar_static']
preferences: Mastodon.Preferences
}
version: string
configuration?: Mastodon.Instance['configuration']
filters: Mastodon.Filter[]
notifications_filter: {
follow: boolean
follow_request: boolean
favourite: boolean
reblog: boolean
mention: boolean
poll: boolean
status: boolean
update: boolean
}
push: {
global: boolean
decode: boolean
alerts: Mastodon.PushSubscription['alerts']
keys: {
auth?: string
public?: string // legacy
private?: string // legacy
}
}
timelinesLookback?: {
[key: string]: {
queryKey: QueryKeyTimeline
ids: Mastodon.Status['id'][]
}
}
mePage: {
lists: { shown: boolean }
announcements: { shown: boolean; unread: number }
}
drafts: ComposeStateDraft[]
frequentEmojis: {
emoji: Pick<Mastodon.Emoji, 'shortcode' | 'url' | 'static_url'>
score: number
count: number
lastUsed: number
}[]
}

View File

@ -1,4 +1,5 @@
import apiGeneral from '@api/general'
import { handleError } from '@api/helpers'
import apiTooot from '@api/tooot'
import { displayMessage } from '@components/Message'
import navigationRef from '@helpers/navigationRef'
@ -6,7 +7,6 @@ import { useAppDispatch } from '@root/store'
import * as Sentry from '@sentry/react-native'
import { getExpoToken, retrieveExpoToken } from '@utils/slices/appSlice'
import { disableAllPushes, getInstances } from '@utils/slices/instancesSlice'
import { useTheme } from '@utils/styles/ThemeManager'
import * as Notifications from 'expo-notifications'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
@ -15,7 +15,6 @@ import { useSelector } from 'react-redux'
const pushUseConnect = () => {
const { t } = useTranslation('screens')
const { theme } = useTheme()
const dispatch = useAppDispatch()
useEffect(() => {
@ -24,7 +23,7 @@ const pushUseConnect = () => {
const expoToken = useSelector(getExpoToken)
const instances = useSelector(getInstances, (prev, next) => prev.length === next.length)
const pushEnabled = instances.filter(instance => instance.push.global.value)
const pushEnabled = instances.filter(instance => instance.push.global)
const connect = () => {
apiTooot({
@ -33,16 +32,12 @@ const pushUseConnect = () => {
})
.then(() => Notifications.setBadgeCountAsync(0))
.catch(error => {
Sentry.setContext('Error response', {
...(error?.response && { response: error.response?._response })
})
Sentry.setContext('Error object', { error })
Sentry.captureMessage('Push connect error')
handleError({ message: 'Push connect error', captureResponse: true })
Notifications.setBadgeCountAsync(0)
if (error?.status == 404) {
displayMessage({
theme,
type: 'error',
type: 'danger',
duration: 'long',
message: t('pushError.message'),
description: t('pushError.description'),
@ -65,7 +60,7 @@ const pushUseConnect = () => {
dispatch(disableAllPushes())
instances.forEach(instance => {
if (instance.push.global.value) {
if (instance.push.global) {
apiGeneral<{}>({
method: 'delete',
domain: instance.url,

View File

@ -31,10 +31,7 @@ const pushUseReceive = () => {
description: notification.request.content.body!,
onPress: () => {
if (notificationIndex !== -1) {
initQuery({
instance: instances[notificationIndex],
prefetch: { enabled: true }
})
initQuery({ instance: instances[notificationIndex] })
}
pushUseNavigate(payloadData.notification_id)
}

View File

@ -27,10 +27,7 @@ const pushUseRespond = () => {
instance.account.id === payloadData.accountId
)
if (notificationIndex !== -1) {
initQuery({
instance: instances[notificationIndex],
prefetch: { enabled: true }
})
initQuery({ instance: instances[notificationIndex] })
}
pushUseNavigate(payloadData.notification_id)
}

View File

@ -1,18 +1,41 @@
import apiGeneral from '@api/general'
import apiInstance from '@api/instance'
import { AxiosError } from 'axios'
import * as AuthSession from 'expo-auth-session'
import { QueryFunctionContext, useQuery, UseQueryOptions } from 'react-query'
import {
QueryFunctionContext,
useMutation,
UseMutationOptions,
useQuery,
UseQueryOptions
} from 'react-query'
export type QueryKeyApps = ['Apps', { domain?: string }]
export type QueryKeyApps = ['Apps']
const queryFunction = ({ queryKey }: QueryFunctionContext<QueryKeyApps>) => {
const redirectUri = AuthSession.makeRedirectUri({
native: 'tooot://instance-auth',
useProxy: false
const queryFunctionApps = async ({ queryKey }: QueryFunctionContext<QueryKeyApps>) => {
const res = await apiInstance<Mastodon.Apps>({
method: 'get',
url: 'apps/verify_credentials'
})
return res.body
}
const { domain } = queryKey[1]
const useAppsQuery = (
params: {
options?: UseQueryOptions<Mastodon.Apps, AxiosError>
} | void
) => {
const queryKey: QueryKeyApps = ['Apps']
return useQuery(queryKey, queryFunctionApps, params?.options)
}
type MutationVarsApps = { domain: string }
export const redirectUri = AuthSession.makeRedirectUri({
native: 'tooot://instance-auth',
useProxy: false
})
const mutationFunctionApps = async ({ domain }: MutationVarsApps) => {
const formData = new FormData()
formData.append('client_name', 'tooot')
formData.append('website', 'https://tooot.app')
@ -21,20 +44,16 @@ const queryFunction = ({ queryKey }: QueryFunctionContext<QueryKeyApps>) => {
return apiGeneral<Mastodon.Apps>({
method: 'post',
domain: domain || '',
domain: domain,
url: `api/v1/apps`,
body: formData
}).then(res => res.body)
}
const useAppsQuery = ({
options,
...queryKeyParams
}: QueryKeyApps[1] & {
options?: UseQueryOptions<Mastodon.Apps, AxiosError>
}) => {
const queryKey: QueryKeyApps = ['Apps', { ...queryKeyParams }]
return useQuery(queryKey, queryFunction, options)
const useAppsMutation = (
options: UseMutationOptions<Mastodon.Apps, AxiosError, MutationVarsApps>
) => {
return useMutation(mutationFunctionApps, options)
}
export { useAppsQuery }
export { useAppsQuery, useAppsMutation }

View File

@ -2,20 +2,18 @@ import apiInstance from '@api/instance'
import haptics from '@components/haptics'
import { displayMessage } from '@components/Message'
import queryClient from '@helpers/queryClient'
import { Theme } from '@utils/styles/themes'
import { AxiosError } from 'axios'
import i18next from 'i18next'
import { RefObject } from 'react'
import FlashMessage from 'react-native-flash-message'
import { useMutation, useQuery, UseQueryOptions } from 'react-query'
type AccountWithSource = Mastodon.Account &
Required<Pick<Mastodon.Account, 'source'>>
type AccountWithSource = Mastodon.Account & Required<Pick<Mastodon.Account, 'source'>>
type QueryKeyProfile = ['Profile']
export type QueryKeyProfile = ['Profile']
const queryKey: QueryKeyProfile = ['Profile']
const queryFunction = async () => {
const queryFunctionProfile = async () => {
const res = await apiInstance<AccountWithSource>({
method: 'get',
url: `accounts/verify_credentials`
@ -23,12 +21,12 @@ const queryFunction = async () => {
return res.body
}
const useProfileQuery = ({
options
}: {
options?: UseQueryOptions<AccountWithSource, AxiosError>
}) => {
return useQuery(queryKey, queryFunction, options)
const useProfileQuery = (
params: {
options: UseQueryOptions<AccountWithSource, AxiosError>
} | void
) => {
return useQuery(queryKey, queryFunctionProfile, params?.options)
}
type MutationVarsProfileBase =
@ -52,7 +50,6 @@ type MutationVarsProfileBase =
}
type MutationVarsProfile = MutationVarsProfileBase & {
theme: Theme
messageRef: RefObject<FlashMessage>
message: {
text: string
@ -92,73 +89,70 @@ const mutationFunction = async ({ type, data }: MutationVarsProfile) => {
}
const useProfileMutation = () => {
return useMutation<
{ body: AccountWithSource },
AxiosError,
MutationVarsProfile
>(mutationFunction, {
onMutate: async variables => {
await queryClient.cancelQueries(queryKey)
return useMutation<{ body: AccountWithSource }, AxiosError, MutationVarsProfile>(
mutationFunction,
{
onMutate: async variables => {
await queryClient.cancelQueries(queryKey)
const oldData = queryClient.getQueryData<AccountWithSource>(queryKey)
const oldData = queryClient.getQueryData<AccountWithSource>(queryKey)
queryClient.setQueryData<AccountWithSource | undefined>(queryKey, old => {
if (old) {
switch (variables.type) {
case 'source[privacy]':
return {
...old,
source: { ...old.source, privacy: variables.data }
}
case 'source[sensitive]':
return {
...old,
source: { ...old.source, sensitive: variables.data }
}
case 'locked':
return { ...old, locked: variables.data }
case 'bot':
return { ...old, bot: variables.data }
default:
return old
queryClient.setQueryData<AccountWithSource | undefined>(queryKey, old => {
if (old) {
switch (variables.type) {
case 'source[privacy]':
return {
...old,
source: { ...old.source, privacy: variables.data }
}
case 'source[sensitive]':
return {
...old,
source: { ...old.source, sensitive: variables.data }
}
case 'locked':
return { ...old, locked: variables.data }
case 'bot':
return { ...old, bot: variables.data }
default:
return old
}
}
}
})
})
return oldData
},
onError: (err, variables, context) => {
queryClient.setQueryData(queryKey, context)
haptics('Error')
if (variables.message.failed) {
displayMessage({
ref: variables.messageRef,
message: i18next.t('screenTabs:me.profile.feedback.failed', {
type: i18next.t(`screenTabs:${variables.message.text}`)
}),
...(err && { description: err.message }),
theme: variables.theme,
type: 'error'
})
return oldData
},
onError: (err, variables, context) => {
queryClient.setQueryData(queryKey, context)
haptics('Error')
if (variables.message.failed) {
displayMessage({
ref: variables.messageRef,
message: i18next.t('screenTabs:me.profile.feedback.failed', {
type: i18next.t(`screenTabs:${variables.message.text}`)
}),
...(err && { description: err.message }),
type: 'danger'
})
}
},
onSuccess: (_, variables) => {
if (variables.message.succeed) {
haptics('Success')
displayMessage({
ref: variables.messageRef,
message: i18next.t('screenTabs:me.profile.feedback.succeed', {
type: i18next.t(`screenTabs:${variables.message.text}`)
}),
type: 'success'
})
}
},
onSettled: () => {
queryClient.invalidateQueries(queryKey)
}
},
onSuccess: (_, variables) => {
if (variables.message.succeed) {
haptics('Success')
displayMessage({
ref: variables.messageRef,
message: i18next.t('screenTabs:me.profile.feedback.succeed', {
type: i18next.t(`screenTabs:${variables.message.text}`)
}),
theme: variables.theme,
type: 'success'
})
}
},
onSettled: () => {
queryClient.invalidateQueries(queryKey)
}
})
)
}
export { useProfileQuery, useProfileMutation }
export { queryFunctionProfile, useProfileQuery, useProfileMutation }

View File

@ -90,16 +90,18 @@ const addInstance = createAsyncThunk(
update: true
},
push: {
global: { loading: false, value: false },
decode: { loading: false, value: false },
global: false,
decode: false,
alerts: {
follow: { loading: false, value: true },
follow_request: { loading: false, value: true },
favourite: { loading: false, value: true },
reblog: { loading: false, value: true },
mention: { loading: false, value: true },
poll: { loading: false, value: true },
status: { loading: false, value: true }
follow: true,
follow_request: true,
favourite: true,
reblog: true,
mention: true,
poll: true,
status: true,
'admin.sign_up': false,
'admin.report': false
},
keys: { auth: undefined, public: undefined, private: undefined }
},

View File

@ -1,11 +0,0 @@
import * as Notifications from 'expo-notifications'
const androidDefaults = {
importance: Notifications.AndroidImportance.DEFAULT,
bypassDnd: false,
showBadge: true,
enableLights: true,
enableVibrate: true
}
export default androidDefaults

View File

@ -1,15 +1,15 @@
import apiInstance from '@api/instance'
import apiTooot, { TOOOT_API_DOMAIN } from '@api/tooot'
import i18n from '@root/i18n/i18n'
import { displayMessage } from '@components/Message'
import { RootState } from '@root/store'
import * as Sentry from '@sentry/react-native'
import { InstanceLatest } from '@utils/migrations/instances/migration'
import { getInstance } from '@utils/slices/instancesSlice'
import * as Notifications from 'expo-notifications'
import * as Random from 'expo-random'
import i18next from 'i18next'
import { Platform } from 'react-native'
import base64 from 'react-native-base64'
import androidDefaults from './androidDefaults'
import { setChannels } from './utils'
const subscribe = async ({
expoToken,
@ -74,6 +74,12 @@ const pushRegister = async (
})
if (!res.body.server_key?.length) {
displayMessage({
type: 'danger',
duration: 'long',
message: i18next.t('screenTabs:me.push.missingServerKey.message'),
description: i18next.t('screenTabs:me.push.missingServerKey.description')
})
Sentry.setContext('Push server key', {
instance: instanceUri,
resBody: res.body
@ -88,50 +94,11 @@ const pushRegister = async (
accountId,
accountFull,
serverKey: res.body.server_key,
auth: instancePush.decode.value === false ? null : auth
auth: instancePush.decode === false ? null : auth
})
if (Platform.OS === 'android') {
Notifications.setNotificationChannelGroupAsync(accountFull, {
name: accountFull,
...androidDefaults
}).then(group => {
if (group) {
if (instancePush.decode.value === false) {
Notifications.setNotificationChannelAsync(`${group.id}_default`, {
groupId: group.id,
name: i18n.t('meSettingsPush:content.default.heading'),
...androidDefaults
})
} else {
Notifications.setNotificationChannelAsync(`${group.id}_follow`, {
groupId: group.id,
name: i18n.t('meSettingsPush:content.follow.heading'),
...androidDefaults
})
Notifications.setNotificationChannelAsync(`${group.id}_favourite`, {
groupId: group.id,
name: i18n.t('meSettingsPush:content.favourite.heading'),
...androidDefaults
})
Notifications.setNotificationChannelAsync(`${group.id}_reblog`, {
groupId: group.id,
name: i18n.t('meSettingsPush:content.reblog.heading'),
...androidDefaults
})
Notifications.setNotificationChannelAsync(`${group.id}_mention`, {
groupId: group.id,
name: i18n.t('meSettingsPush:content.mention.heading'),
...androidDefaults
})
Notifications.setNotificationChannelAsync(`${group.id}_poll`, {
groupId: group.id,
name: i18n.t('meSettingsPush:content.poll.heading'),
...androidDefaults
})
}
}
})
setChannels(instance)
}
return Promise.resolve(auth)

View File

@ -0,0 +1,76 @@
import { PERMISSION_MANAGE_REPORTS, PERMISSION_MANAGE_USERS } from '@helpers/permissions'
import queryClient from '@helpers/queryClient'
import i18n from '@root/i18n/i18n'
import { InstanceLatest } from '@utils/migrations/instances/migration'
import { queryFunctionProfile, QueryKeyProfile } from '@utils/queryHooks/profile'
import * as Notifications from 'expo-notifications'
export const PUSH_DEFAULT: [
'follow',
'follow_request',
'favourite',
'reblog',
'mention',
'poll',
'status'
] = ['follow', 'follow_request', 'favourite', 'reblog', 'mention', 'poll', 'status']
export const PUSH_ADMIN: { type: 'admin.sign_up' | 'admin.report'; permission: number }[] = [
{ type: 'admin.sign_up', permission: PERMISSION_MANAGE_USERS },
{ type: 'admin.report', permission: PERMISSION_MANAGE_REPORTS }
]
export const checkPushAdminPermission = (
permission: number,
permissions?: string | number
): boolean =>
permissions
? !!(
(typeof permissions === 'string' ? parseInt(permissions || '0') : permissions) & permission
)
: false
export const setChannels = async (instance: InstanceLatest) => {
const account = `@${instance.account.acct}@${instance.uri}`
const deleteChannel = async (type: string) =>
Notifications.deleteNotificationChannelAsync(`${account}_${type}`)
const setChannel = async (type: string) =>
Notifications.setNotificationChannelAsync(`${account}_${type}`, {
groupId: account,
name: i18n.t(`screenTabs:me.push.${type}.heading`),
importance: Notifications.AndroidImportance.DEFAULT,
bypassDnd: false,
showBadge: true,
enableLights: true,
enableVibrate: true
})
const queryKey: QueryKeyProfile = ['Profile']
const profileQuery = await queryClient.fetchQuery(queryKey, queryFunctionProfile)
const channelGroup = await Notifications.getNotificationChannelGroupAsync(account)
if (!channelGroup) {
await Notifications.setNotificationChannelGroupAsync(account, { name: account })
}
if (!instance.push.decode) {
await setChannel('default')
for (const push of PUSH_DEFAULT) {
await deleteChannel(push)
}
for (const { type } of PUSH_ADMIN) {
await deleteChannel(type)
}
} else {
await deleteChannel('default')
for (const push of PUSH_DEFAULT) {
await setChannel(push)
}
for (const { type, permission } of PUSH_ADMIN) {
if (checkPushAdminPermission(permission, profileQuery.role?.permissions)) {
await setChannel(type)
}
}
}
}

View File

@ -6,7 +6,7 @@ import { updateInstancePush } from './updatePush'
const removeInstance = createAsyncThunk(
'instances/remove',
async (instance: InstanceLatest, { dispatch }): Promise<InstanceLatest> => {
if (instance.push.global.value) {
if (instance.push.global) {
dispatch(updateInstancePush(false))
}

View File

@ -1,19 +1,17 @@
import apiTooot from '@api/tooot'
import { createAsyncThunk } from '@reduxjs/toolkit'
import i18n from '@root/i18n/i18n'
import { RootState } from '@root/store'
import { InstanceLatest } from '@utils/migrations/instances/migration'
import * as Notifications from 'expo-notifications'
import { Platform } from 'react-native'
import { getInstance } from '../instancesSlice'
import androidDefaults from './push/androidDefaults'
import { setChannels } from './push/utils'
export const updateInstancePushDecode = createAsyncThunk(
'instances/updatePushDecode',
async (
disable: boolean,
{ getState }
): Promise<{ disable: InstanceLatest['push']['decode']['value'] }> => {
): Promise<{ disable: InstanceLatest['push']['decode'] }> => {
const state = getState() as RootState
const instance = getInstance(state)
if (!instance?.url || !instance.account.id || !instance.push.keys) {
@ -34,54 +32,7 @@ export const updateInstancePushDecode = createAsyncThunk(
})
if (Platform.OS === 'android') {
const accountFull = `@${instance.account.acct}@${instance.uri}`
switch (disable) {
case true:
Notifications.deleteNotificationChannelAsync(`${accountFull}_default`)
Notifications.setNotificationChannelAsync(`${accountFull}_follow`, {
groupId: accountFull,
name: i18n.t('meSettingsPush:content.follow.heading'),
...androidDefaults
})
Notifications.setNotificationChannelAsync(
`${accountFull}_favourite`,
{
groupId: accountFull,
name: i18n.t('meSettingsPush:content.favourite.heading'),
...androidDefaults
}
)
Notifications.setNotificationChannelAsync(`${accountFull}_reblog`, {
groupId: accountFull,
name: i18n.t('meSettingsPush:content.reblog.heading'),
...androidDefaults
})
Notifications.setNotificationChannelAsync(`${accountFull}_mention`, {
groupId: accountFull,
name: i18n.t('meSettingsPush:content.mention.heading'),
...androidDefaults
})
Notifications.setNotificationChannelAsync(`${accountFull}_poll`, {
groupId: accountFull,
name: i18n.t('meSettingsPush:content.poll.heading'),
...androidDefaults
})
break
case false:
Notifications.setNotificationChannelAsync(`${accountFull}_default`, {
groupId: accountFull,
name: i18n.t('meSettingsPush:content.default.heading'),
...androidDefaults
})
Notifications.deleteNotificationChannelAsync(`${accountFull}_follow`)
Notifications.deleteNotificationChannelAsync(
`${accountFull}_favourite`
)
Notifications.deleteNotificationChannelAsync(`${accountFull}_reblog`)
Notifications.deleteNotificationChannelAsync(`${accountFull}_mention`)
Notifications.deleteNotificationChannelAsync(`${accountFull}_poll`)
break
}
setChannels(instance)
}
return Promise.resolve({ disable })

View File

@ -73,18 +73,11 @@ const instancesSlice = createSlice({
},
clearPushLoading: ({ instances }) => {
const activeIndex = findInstanceActive(instances)
instances[activeIndex].push.global.loading = false
instances[activeIndex].push.decode.loading = false
instances[activeIndex].push.alerts.favourite.loading = false
instances[activeIndex].push.alerts.follow.loading = false
instances[activeIndex].push.alerts.mention.loading = false
instances[activeIndex].push.alerts.poll.loading = false
instances[activeIndex].push.alerts.reblog.loading = false
},
disableAllPushes: ({ instances }) => {
instances = instances.map(instance => {
let newInstance = instance
newInstance.push.global.value = false
newInstance.push.global = false
return newInstance
})
},
@ -238,48 +231,21 @@ const instancesSlice = createSlice({
// Update Instance Push Global
.addCase(updateInstancePush.fulfilled, (state, action) => {
const activeIndex = findInstanceActive(state.instances)
state.instances[activeIndex].push.global.loading = false
state.instances[activeIndex].push.global.value = action.meta.arg
state.instances[activeIndex].push.global = action.meta.arg
state.instances[activeIndex].push.keys = { auth: action.payload }
})
.addCase(updateInstancePush.rejected, state => {
const activeIndex = findInstanceActive(state.instances)
state.instances[activeIndex].push.global.loading = false
})
.addCase(updateInstancePush.pending, state => {
const activeIndex = findInstanceActive(state.instances)
state.instances[activeIndex].push.global.loading = true
})
// Update Instance Push Decode
.addCase(updateInstancePushDecode.fulfilled, (state, action) => {
const activeIndex = findInstanceActive(state.instances)
state.instances[activeIndex].push.decode.loading = false
state.instances[activeIndex].push.decode.value = action.payload.disable
})
.addCase(updateInstancePushDecode.rejected, state => {
const activeIndex = findInstanceActive(state.instances)
state.instances[activeIndex].push.decode.loading = false
})
.addCase(updateInstancePushDecode.pending, state => {
const activeIndex = findInstanceActive(state.instances)
state.instances[activeIndex].push.decode.loading = true
state.instances[activeIndex].push.decode = action.payload.disable
})
// Update Instance Push Individual Alert
.addCase(updateInstancePushAlert.fulfilled, (state, action) => {
const activeIndex = findInstanceActive(state.instances)
state.instances[activeIndex].push.alerts[action.meta.arg.changed].loading = false
state.instances[activeIndex].push.alerts = action.payload
})
.addCase(updateInstancePushAlert.rejected, (state, action) => {
const activeIndex = findInstanceActive(state.instances)
state.instances[activeIndex].push.alerts[action.meta.arg.changed].loading = false
})
.addCase(updateInstancePushAlert.pending, (state, action) => {
const activeIndex = findInstanceActive(state.instances)
state.instances[activeIndex].push.alerts[action.meta.arg.changed].loading = true
})
// Check if frequently used emojis still exist
.addCase(checkEmojis.fulfilled, (state, action) => {
@ -412,7 +378,6 @@ export const {
updateInstanceNotificationsFilter,
updateInstanceDraft,
removeInstanceDraft,
clearPushLoading,
disableAllPushes,
updateInstanceTimelineLookback,
updateInstanceMePage,

View File

@ -1,6 +1,10 @@
import { LayoutAnimation } from 'react-native'
import { AccessibilityInfo, LayoutAnimation } from 'react-native'
const layoutAnimation = async () => {
const disable = await AccessibilityInfo.isReduceMotionEnabled()
if (disable) return
const layoutAnimation = () =>
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
}
export default layoutAnimation

View File

@ -10853,10 +10853,10 @@ react-native-iphone-x-helper@^1.3.1:
resolved "https://registry.yarnpkg.com/react-native-iphone-x-helper/-/react-native-iphone-x-helper-1.3.1.tgz#20c603e9a0e765fd6f97396638bdeb0e5a60b010"
integrity sha512-HOf0jzRnq2/aFUcdCJ9w9JGzN3gdEg0zFE4FyYlp4jtidqU03D5X7ZegGKfT1EWteR0gPBGp9ye5T5FvSWi9Yg==
react-native-language-detection@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/react-native-language-detection/-/react-native-language-detection-0.1.0.tgz#06b5d20bffb60dbbd599c8e62b6acf500952afa8"
integrity sha512-26CLndVMmMbVp40Y9Herza73nfR08JFTcYkJ3MX5MIQbGRoqgNAG89z8pA1y7dPHHK1Nfa6AWKAYpNv7tMRCaw==
react-native-language-detection@^0.2.2:
version "0.2.2"
resolved "https://registry.yarnpkg.com/react-native-language-detection/-/react-native-language-detection-0.2.2.tgz#4cc94177aa1c4575c4656f6d42456fa6c72ed5db"
integrity sha512-6u1JBgr+UG/GX/xMmT4K8CaBlSep4XfM91jwUzRA/Y3bMCHDx7bNVxGQvqvzkmOchby9h66XD8F5Eo+kV01CAA==
react-native-live-text-image-view@^0.4.0:
version "0.4.0"