diff --git a/App.tsx b/App.tsx index 31149e8a..b4d19691 100644 --- a/App.tsx +++ b/App.tsx @@ -1,88 +1,38 @@ -import NetInfo from '@react-native-community/netinfo' -import client from '@root/api/client' import Index from '@root/Index' import { persistor, store } from '@root/store' -import { resetLocal } from '@root/utils/slices/instancesSlice' import ThemeManager from '@utils/styles/ThemeManager' -import chalk from 'chalk' -import * as Analytics from 'expo-firebase-analytics' -import { Audio } from 'expo-av' import * as SplashScreen from 'expo-splash-screen' import React, { useCallback, useEffect, useState } from 'react' import { enableScreens } from 'react-native-screens' -import { onlineManager, QueryClient, QueryClientProvider } from 'react-query' +import { QueryClient, QueryClientProvider } from 'react-query' import { Provider } from 'react-redux' import { PersistGate } from 'redux-persist/integration/react' -import * as Sentry from 'sentry-expo' +import checkSecureStorageVersion from '@root/startup/checkSecureStorageVersion' +import dev from '@root/startup/dev' +import sentry from '@root/startup/sentry' +import log from '@root/startup/log' +import audio from '@root/startup/audio' +import onlineStatus from '@root/startup/onlineStatus' +import netInfo from '@root/startup/netInfo' -const ctx = new chalk.Instance({ level: 3 }) -const startingLog = (type: 'log' | 'warn' | 'error', message: string) => { - switch (type) { - case 'log': - console.log(ctx.bgBlue.bold(' Start up ') + ' ' + message) - break - case 'warn': - console.log(ctx.bgBlue.bold(' Start up ') + ' ' + message) - break - case 'error': - console.log(ctx.bgBlue.bold(' Start up ') + ' ' + message) - break - } -} +dev() +sentry() +audio() +onlineStatus() -if (__DEV__) { - Analytics.setDebugModeEnabled(true) - - startingLog('log', 'initializing wdyr') - const whyDidYouRender = require('@welldone-software/why-did-you-render') - whyDidYouRender(React, { - trackHooks: true, - hotReloadBufferMs: 1000 - }) -} - -startingLog('log', 'initializing Sentry') -Sentry.init({ - dsn: - 'https://c9e29aa05f774aca8f36def98244ce04@o389581.ingest.sentry.io/5571975', - enableInExpoDevelopment: false, - debug: __DEV__ -}) - -startingLog('log', 'initializing react-query') +log('log', 'react-query', 'initializing') const queryClient = new QueryClient() -startingLog('log', 'setting audio playback default options') -Audio.setAudioModeAsync({ - playsInSilentModeIOS: true, - interruptionModeIOS: 1 -}) - -startingLog('log', 'initializing native screen') +log('log', 'react-native-screens', 'initializing') enableScreens() const App: React.FC = () => { - startingLog('log', 'rendering App') - const [appLoaded, setAppLoaded] = useState(false) + log('log', 'App', 'rendering App') const [localCorrupt, setLocalCorrupt] = useState() - useEffect(() => { - const onlineState = onlineManager.setEventListener(setOnline => { - startingLog('log', 'added onlineManager listener') - return NetInfo.addEventListener(state => { - startingLog('warn', `setting online state ${state.isConnected}`) - // @ts-ignore - setOnline(state.isConnected) - }) - }) - return () => { - onlineState - } - }, []) - useEffect(() => { const delaySplash = async () => { - startingLog('log', 'delay splash') + log('log', 'App', 'delay splash') try { await SplashScreen.preventAutoHideAsync() } catch (e) { @@ -91,72 +41,29 @@ const App: React.FC = () => { } delaySplash() }, []) - useEffect(() => { - const hideSplash = async () => { - startingLog('log', 'hide splash') - try { - await SplashScreen.hideAsync() - } catch (e) { - console.warn(e) - } - } - if (appLoaded) { - hideSplash() - } - }, [appLoaded]) - const onBeforeLift = useCallback(() => { - NetInfo.fetch().then(netInfo => { - startingLog('log', 'on before lift') - const localUrl = store.getState().instances.local.url - const localToken = store.getState().instances.local.token - if (netInfo.isConnected) { - startingLog('log', 'network connected') - if (localUrl && localToken) { - startingLog('log', 'checking locally stored credentials') - client({ - method: 'get', - instance: 'remote', - instanceDomain: localUrl, - url: `accounts/verify_credentials`, - headers: { Authorization: `Bearer ${localToken}` } - }) - .then(res => { - startingLog('log', 'local credential check passed') - if (res.body.id !== store.getState().instances.local.account.id) { - store.dispatch(resetLocal()) - setLocalCorrupt('') - } - setAppLoaded(true) - }) - .catch(error => { - startingLog('error', 'local credential check failed') - if ( - error.status && - typeof error.status === 'number' && - error.status === 401 - ) { - store.dispatch(resetLocal()) - } - setLocalCorrupt(error.data.error) - setAppLoaded(true) - }) - } else { - startingLog('log', 'no local credential found') - setAppLoaded(true) - } - } else { - startingLog('warn', 'network not connected') - setAppLoaded(true) - } - }) + const onBeforeLift = useCallback(async () => { + const netInfoRes = await netInfo() + + if (netInfoRes.corrupted && netInfoRes.corrupted.length) { + setLocalCorrupt(netInfoRes.corrupted) + } + + log('log', 'App', 'hide splash') + try { + await SplashScreen.hideAsync() + return Promise.resolve() + } catch (e) { + console.warn(e) + return Promise.reject() + } }, []) const main = useCallback( bootstrapped => { - startingLog('log', 'bootstrapped') - if (bootstrapped && appLoaded) { - startingLog('log', 'loading actual app :)') + log('log', 'App', 'bootstrapped') + if (bootstrapped) { + log('log', 'App', 'loading actual app :)') require('@root/i18n/i18n') return ( @@ -167,7 +74,7 @@ const App: React.FC = () => { return null } }, - [appLoaded] + [localCorrupt] ) return ( diff --git a/configs/Microsoft_AutoUpdate_4.30.20121301_Updater.pkg b/configs/Microsoft_AutoUpdate_4.30.20121301_Updater.pkg new file mode 100644 index 00000000..f54c3035 Binary files /dev/null and b/configs/Microsoft_AutoUpdate_4.30.20121301_Updater.pkg differ diff --git a/package.json b/package.json index 7b2c8cbb..da67d600 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "test": "jest" }, "dependencies": { + "@react-native-async-storage/async-storage": "^1.13.2", "@react-native-community/masked-view": "0.1.10", "@react-native-community/netinfo": "^5.9.7", "@react-native-community/segmented-control": "2.2.1", @@ -116,4 +117,4 @@ ] }, "private": true -} \ No newline at end of file +} diff --git a/publish.sh b/publish.sh new file mode 100755 index 00000000..01fa85c6 --- /dev/null +++ b/publish.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +SENTRY_ORG=xmflsct SENTRY_PROJECT=mastodon-app SENTRY_AUTH_TOKEN=771a5746bad54f5987aa5d5d8b1c10abc751b3bf28554c908d452e0054eb6003 expo publish --release-channel testing \ No newline at end of file diff --git a/src/@types/app.d.ts b/src/@types/app.d.ts index 38e5113f..7ca8b57a 100644 --- a/src/@types/app.d.ts +++ b/src/@types/app.d.ts @@ -15,40 +15,3 @@ declare namespace App { | 'Bookmarks' | 'Favourites' } - -declare namespace QueryKey { - type Account = ['Account', { id: Mastodon.Account['id'] }] - - type Announcements = ['Announcements', { showAll?: boolean }] - - type Application = ['Application', { instanceDomain: string }] - - type Instance = ['Instance', { instanceDomain: string }] - - type Relationship = ['Relationship', { id: Mastodon.Account['id'] }] - - type Relationships = [ - 'Relationships', - 'following' | 'followers', - { id: Mastodon.Account['id'] } - ] - - type Search = [ - 'Search', - { - type?: 'accounts' | 'hashtags' | 'statuses' - term: string - limit?: number - } - ] - - type Timeline = [ - Pages, - { - hashtag?: Mastodon.Tag['name'] - list?: Mastodon.List['id'] - toot?: Mastodon.Status['id'] - account?: Mastodon.Account['id'] - } - ] -} diff --git a/src/@types/mastodon.d.ts b/src/@types/mastodon.d.ts index afc9ab90..f683717e 100644 --- a/src/@types/mastodon.d.ts +++ b/src/@types/mastodon.d.ts @@ -67,7 +67,7 @@ declare namespace Mastodon { vapid_key?: string } - type AppOauth = { + type Apps = { id: string name: string website?: string diff --git a/src/Index.tsx b/src/Index.tsx index 2fd96c70..3a84b27a 100644 --- a/src/Index.tsx +++ b/src/Index.tsx @@ -11,39 +11,38 @@ import ScreenLocal from '@screens/Local' import ScreenMe from '@screens/Me' import ScreenNotifications from '@screens/Notifications' import ScreenPublic from '@screens/Public' -import { timelineFetch } from '@utils/fetches/timelineFetch' +import hookTimeline from '@utils/queryHooks/timeline' import { + getLocalActiveIndex, getLocalNotification, - getLocalUrl, - updateLocalAccountPreferences, - updateNotification + localUpdateAccountPreferences, + localUpdateNotification } from '@utils/slices/instancesSlice' import { useTheme } from '@utils/styles/ThemeManager' import { themes } from '@utils/styles/themes' import * as Analytics from 'expo-firebase-analytics' -import React, { useCallback, useEffect, useMemo, useRef } from 'react' +import React, { + createRef, + useCallback, + useEffect, + useMemo, + useRef +} from 'react' import { StatusBar } from 'react-native' import Toast from 'react-native-toast-message' -import { useInfiniteQuery } from 'react-query' import { useDispatch, useSelector } from 'react-redux' -const Tab = createBottomTabNavigator() - -export type RootStackParamList = { - 'Screen-Local': undefined - 'Screen-Public': { publicTab: boolean } - 'Screen-Post': undefined - 'Screen-Notifications': undefined - 'Screen-Me': undefined -} +const Tab = createBottomTabNavigator() export interface Props { localCorrupt?: string } +export const navigationRef = createRef() + const Index: React.FC = ({ localCorrupt }) => { const dispatch = useDispatch() - const localInstance = useSelector(getLocalUrl) + const localActiveIndex = useSelector(getLocalActiveIndex) const { mode, theme } = useTheme() enum barStyle { light = 'dark-content', @@ -51,7 +50,7 @@ const Index: React.FC = ({ localCorrupt }) => { } const routeNameRef = useRef() - const navigationRef = useRef(null) + // const navigationRef = useRef(null) // const isConnected = useNetInfo().isConnected // const [firstRender, setFirstRender] = useState(false) @@ -82,14 +81,14 @@ const Index: React.FC = ({ localCorrupt }) => { // On launch check if there is any unread announcements useEffect(() => { console.log('Checking announcements') - localInstance && - client({ + localActiveIndex !== null && + client({ method: 'get', instance: 'local', url: `announcements` }) - .then(({ body }: { body?: Mastodon.Announcement[] }) => { - if (body?.filter(announcement => !announcement.read).length) { + .then(res => { + if (res?.filter(announcement => !announcement.read).length) { navigationRef.current?.navigate('Screen-Shared-Announcements', { showAll: false }) @@ -99,15 +98,15 @@ const Index: React.FC = ({ localCorrupt }) => { }, []) // On launch check if there is any unread noficiations - const queryNotification = useInfiniteQuery( - ['Notifications', {}], - timelineFetch, - { - enabled: localInstance ? true : false, + const queryNotification = hookTimeline({ + page: 'Notifications', + options: { + enabled: localActiveIndex !== null ? true : false, refetchInterval: 1000 * 60, refetchIntervalInBackground: true } - ) + }) + const prevNotification = useSelector(getLocalNotification) useEffect(() => { if (queryNotification.data?.pages) { @@ -120,7 +119,7 @@ const Index: React.FC = ({ localCorrupt }) => { if (!prevNotification || !prevNotification.latestTime) { dispatch( - updateNotification({ + localUpdateNotification({ unread: true }) ) @@ -129,7 +128,7 @@ const Index: React.FC = ({ localCorrupt }) => { new Date(prevNotification.latestTime) < new Date(latestNotificationTime) ) { dispatch( - updateNotification({ + localUpdateNotification({ unread: true, latestTime: latestNotificationTime }) @@ -140,8 +139,8 @@ const Index: React.FC = ({ localCorrupt }) => { // Lazily update users's preferences, for e.g. composing default visibility useEffect(() => { - if (localInstance) { - dispatch(updateLocalAccountPreferences()) + if (localActiveIndex !== null) { + dispatch(localUpdateAccountPreferences()) } }, []) @@ -204,51 +203,48 @@ const Index: React.FC = ({ localCorrupt }) => { const tabNavigatorTabBarOptions = useMemo( () => ({ activeTintColor: theme.primary, - inactiveTintColor: localInstance ? theme.secondary : theme.disabled, + inactiveTintColor: + localActiveIndex !== null ? theme.secondary : theme.disabled, showLabel: false }), - [theme, localInstance] + [theme, localActiveIndex] ) const tabScreenLocalListeners = useCallback( () => ({ tabPress: (e: any) => { - if (!localInstance) { + if (!(localActiveIndex !== null)) { e.preventDefault() } } }), - [localInstance] + [localActiveIndex] ) const tabScreenComposeListeners = useMemo( () => ({ tabPress: (e: any) => { e.preventDefault() - if (localInstance) { + if (localActiveIndex !== null) { haptics('Medium') navigationRef.current?.navigate('Screen-Shared-Compose') } } }), - [localInstance] + [localActiveIndex] ) const tabScreenComposeComponent = useCallback(() => null, []) const tabScreenNotificationsListeners = useCallback( () => ({ tabPress: (e: any) => { - if (!localInstance) { + if (!(localActiveIndex !== null)) { e.preventDefault() } } }), - [localInstance] + [localActiveIndex] ) const tabScreenNotificationsOptions = useMemo( () => ({ - tabBarBadge: prevNotification && prevNotification.unread ? '' : undefined, - tabBarBadgeStyle: { - transform: [{ scale: 0.5 }], - backgroundColor: theme.red - } + tabBarBadge: prevNotification && prevNotification.unread ? '' : undefined }), [theme, prevNotification] ) @@ -263,7 +259,9 @@ const Index: React.FC = ({ localCorrupt }) => { onStateChange={navigationContainerOnStateChange} > @@ -282,7 +280,13 @@ const Index: React.FC = ({ localCorrupt }) => { name='Screen-Notifications' component={ScreenNotifications} listeners={tabScreenNotificationsListeners} - options={tabScreenNotificationsOptions} + options={{ + tabBarBadgeStyle: { + transform: [{ scale: 0.5 }], + backgroundColor: theme.red + }, + ...tabScreenNotificationsOptions + }} /> diff --git a/src/api/client.ts b/src/api/client.ts index 3f46730f..6782bfe7 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,11 +1,13 @@ +import { RootState } from '@root/store' import axios from 'axios' import chalk from 'chalk' const ctx = new chalk.Instance({ level: 3 }) -const client = async ({ +const client = async ({ method, instance, + localIndex, instanceDomain, version = 'v1', url, @@ -16,6 +18,7 @@ const client = async ({ }: { method: 'get' | 'post' | 'put' | 'delete' instance: 'local' | 'remote' + localIndex?: number instanceDomain?: string version?: 'v1' | 'v2' url: string @@ -25,9 +28,30 @@ const client = async ({ headers?: { [key: string]: string } body?: FormData onUploadProgress?: (progressEvent: any) => void -}): Promise => { +}): Promise => { + const { store } = require('@root/store') + const state = (store.getState() as RootState).instances + const theLocalIndex = + localIndex !== undefined ? localIndex : state.local.activeIndex + + let domain = null + if (instance === 'remote') { + domain = instanceDomain || state.remote.url + } else { + if (theLocalIndex !== null && state.local.instances[theLocalIndex]) { + domain = state.local.instances[theLocalIndex].url + } else { + console.error( + ctx.bgRed.white.bold(' API ') + ' ' + 'No instance domain is provided' + ) + return Promise.reject() + } + } + console.log( ctx.bgGreen.bold(' API ') + + ' ' + + domain + ' ' + method + ctx.green(' -> ') + @@ -35,10 +59,6 @@ const client = async ({ (params ? ctx.green(' -> ') : ''), params ? params : '' ) - const { store } = require('@root/store') - const state = store.getState().instances - const domain = - instance === 'remote' ? instanceDomain || state.remote.url : state.local.url return axios({ timeout: method === 'post' ? 1000 * 60 : 1000 * 15, @@ -50,18 +70,13 @@ const client = async ({ 'Content-Type': 'application/json', ...headers, ...(instance === 'local' && { - Authorization: `Bearer ${state.local.token}` + Authorization: `Bearer ${state.local!.instances[theLocalIndex!].token}` }) }, ...(body && { data: body }), ...(onUploadProgress && { onUploadProgress: onUploadProgress }) }) - .then(response => - Promise.resolve({ - headers: response.headers, - body: response.data - }) - ) + .then(response => Promise.resolve(response.data)) .catch(error => { if (error.response) { // The request was made and the server responded with a status code @@ -70,7 +85,8 @@ const client = async ({ ctx.bold(' API '), ctx.bold('response'), error.response.status, - error.response.data.error + error.response.data.error, + error.request ) return Promise.reject(error.response) } else if (error.request) { diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 48bc02d5..9340b2ec 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -23,6 +23,7 @@ export interface Props { loading?: boolean destructive?: boolean disabled?: boolean + active?: boolean strokeWidth?: number size?: 'S' | 'M' | 'L' @@ -40,6 +41,7 @@ const Button: React.FC = ({ loading = false, destructive = false, disabled = false, + active = false, strokeWidth, size = 'M', spacing = 'S', @@ -68,10 +70,29 @@ const Button: React.FC = ({ ) const colorContent = useMemo(() => { - if (overlay) { - return theme.primaryOverlay + if (active) { + return theme.blue } else { - if (disabled) { + if (overlay) { + return theme.primaryOverlay + } else { + if (disabled) { + return theme.secondary + } else { + if (destructive) { + return theme.red + } else { + return theme.primary + } + } + } + } + }, [theme, disabled]) + const colorBorder = useMemo(() => { + if (active) { + return theme.blue + } else { + if (disabled || loading) { return theme.secondary } else { if (destructive) { @@ -81,7 +102,14 @@ const Button: React.FC = ({ } } } - }, [theme, disabled]) + }, [theme, loading, disabled]) + const colorBackground = useMemo(() => { + if (overlay) { + return theme.backgroundOverlay + } else { + return theme.background + } + }, [theme]) const children = useMemo(() => { switch (type) { @@ -118,26 +146,7 @@ const Button: React.FC = ({ ) } - }, [theme, content, loading, disabled]) - - const colorBorder = useMemo(() => { - if (disabled || loading) { - return theme.secondary - } else { - if (destructive) { - return theme.red - } else { - return theme.primary - } - } - }, [theme, loading, disabled]) - const colorBackground = useMemo(() => { - if (overlay) { - return theme.backgroundOverlay - } else { - return theme.background - } - }, [theme]) + }, [theme, content, loading, disabled, active]) enum spacingMapping { XS = 'S', @@ -164,7 +173,7 @@ const Button: React.FC = ({ testID='base' onPress={onPress} children={children} - disabled={disabled || loading} + disabled={disabled || active || loading} /> ) diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 5e4e52b2..c584a471 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -1,4 +1,3 @@ -import { StyleConstants } from '@root/utils/styles/constants' import React, { createElement } from 'react' import { StyleProp, View, ViewStyle } from 'react-native' import * as FeatherIcon from 'react-native-feather' diff --git a/src/components/Instance.tsx b/src/components/Instance.tsx new file mode 100644 index 00000000..9e45ec4a --- /dev/null +++ b/src/components/Instance.tsx @@ -0,0 +1,258 @@ +import Button from '@components/Button' +import haptics from '@components/haptics' +import Icon from '@components/Icon' +import { useNavigation } from '@react-navigation/native' +import hookApps from '@utils/queryHooks/apps' +import hookInstance from '@utils/queryHooks/instance' +import { QueryKeyTimeline } from '@utils/queryHooks/timeline' +import { InstanceLocal, remoteUpdate } from '@utils/slices/instancesSlice' +import { StyleConstants } from '@utils/styles/constants' +import { useTheme } from '@utils/styles/ThemeManager' +import { debounce } from 'lodash' +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Image, StyleSheet, Text, TextInput, View } from 'react-native' +import { useQueryClient } from 'react-query' +import { useDispatch } from 'react-redux' +import InstanceAuth from './Instance/Auth' +import InstanceInfo from './Instance/Info' +import { toast } from './toast' + +export interface Props { + type: 'local' | 'remote' + disableHeaderImage?: boolean + goBack?: boolean +} + +const ComponentInstance: React.FC = ({ + type, + disableHeaderImage, + goBack = false +}) => { + const navigation = useNavigation() + const dispatch = useDispatch() + const queryClient = useQueryClient() + const { t } = useTranslation('meRoot') + const { theme } = useTheme() + const [instanceDomain, setInstanceDomain] = useState() + const [appData, setApplicationData] = useState() + + const instanceQuery = hookInstance({ + instanceDomain, + options: { enabled: false, retry: false } + }) + const applicationQuery = hookApps({ + instanceDomain, + options: { enabled: false, retry: false } + }) + + useEffect(() => { + if ( + applicationQuery.data?.client_id.length && + applicationQuery.data?.client_secret.length + ) { + setApplicationData({ + clientId: applicationQuery.data.client_id, + clientSecret: applicationQuery.data.client_secret + }) + } + }, [applicationQuery.data?.client_id]) + + const onChangeText = useCallback( + debounce( + text => { + setInstanceDomain(text.replace(/^http(s)?\:\/\//i, '')) + setApplicationData(undefined) + }, + 1000, + { + trailing: true + } + ), + [] + ) + useEffect(() => { + if (instanceDomain) { + instanceQuery.refetch() + } + }, [instanceDomain]) + + const processUpdate = useCallback(() => { + if (instanceDomain) { + haptics('Success') + switch (type) { + case 'local': + applicationQuery.refetch() + return + case 'remote': + const queryKey: QueryKeyTimeline = [ + 'Timeline', + { page: 'RemotePublic' } + ] + dispatch(remoteUpdate(instanceDomain)) + queryClient.resetQueries(queryKey) + toast({ type: 'success', message: '重置成功' }) + navigation.navigate('Screen-Remote-Root') + return + } + } + }, [instanceDomain]) + + const onSubmitEditing = useCallback( + ({ nativeEvent: { text } }) => { + if ( + text === instanceDomain && + instanceQuery.isSuccess && + instanceQuery.data && + instanceQuery.data.uri + ) { + processUpdate() + } else { + setInstanceDomain(text) + setApplicationData(undefined) + } + }, + [instanceDomain, instanceQuery.isSuccess, instanceQuery.data] + ) + + const buttonContent = useMemo(() => { + switch (type) { + case 'local': + return t('content.login.button') + case 'remote': + return '登记' + } + }, []) + + return ( + <> + {!disableHeaderImage ? ( + + + + ) : null} + + + +