This commit is contained in:
Zhiyuan Zheng 2021-01-22 01:34:20 +01:00
parent 31b2f67feb
commit 7c6aba77ba
No known key found for this signature in database
GPG Key ID: 078A93AB607D85E0
39 changed files with 449 additions and 295 deletions

1
README.md Normal file
View File

@ -0,0 +1 @@
<a rel="me" href="https://social.xmflsct.com/@tooot">@tooot@xmflsct.com</a>

View File

@ -17,26 +17,10 @@ export default (): ExpoConfig => ({
image: './assets/splash.png' image: './assets/splash.png'
}, },
scheme: 'tooot', scheme: 'tooot',
ios: {
buildNumber: '1.0',
config: { usesNonExemptEncryption: false },
bundleIdentifier: 'com.xmflsct.app.tooot',
googleServicesFile: './configs/GoogleService-Info.plist',
infoPlist: {
CFBundleAllowMixedLocalizations: true
}
},
android: {
versionCode: 1,
package: 'com.xmflsct.app.tooot',
googleServicesFile: './configs/google-services.json',
permissions: ['CAMERA', 'VIBRATE']
},
locales: {
en: './src/i18n/en/system.json',
zh: './src/i18n/zh-Hans/system.json'
},
assetBundlePatterns: ['assets/*'], assetBundlePatterns: ['assets/*'],
extra: {
sentryDSN: process.env.SENTRY_DSN
},
hooks: { hooks: {
postPublish: [ postPublish: [
{ {
@ -51,8 +35,24 @@ export default (): ExpoConfig => ({
} }
] ]
}, },
extra: { ios: {
sentryDSN: process.env.SENTRY_DSN buildNumber: '2',
config: { usesNonExemptEncryption: false },
bundleIdentifier: 'com.xmflsct.app.tooot',
googleServicesFile: './configs/GoogleService-Info.plist',
infoPlist: {
CFBundleAllowMixedLocalizations: true
}
},
locales: {
en: './src/i18n/en/system.json',
zh: './src/i18n/zh-Hans/system.json'
},
android: {
versionCode: 2,
package: 'com.xmflsct.app.tooot',
googleServicesFile: './configs/google-services.json',
permissions: ['CAMERA', 'VIBRATE']
}, },
web: { web: {
config: { config: {

97
demo/statuses.json Normal file
View File

@ -0,0 +1,97 @@
{
"pageParams": [],
"pages": [
[
{
"id": "1",
"created_at": "2021-01-22T03:48:33.901Z",
"sensitive": false,
"visibility": "public",
"replies_count": 9,
"reblogs_count": 15,
"favourites_count": 8,
"favourited": false,
"reblogged": true,
"muted": false,
"bookmarked": false,
"content": "<p>Would you like to try out this simple and open-source mobile app for Mastodon? 😊</p>",
"reblog": null,
"application": {
"name": "tooot",
"website": "https://tooot.app"
},
"account": {
"id": "1",
"username": "tooot📱",
"acct": "tooot@xmflsct.com",
"display_name": "tooot📱",
"avatar_static": "https://avatars.githubusercontent.com/u/77554750?s=200&v=4"
},
"media_attachments": [],
"poll": null
},
{
"id": "2",
"created_at": "2021-01-22T03:48:33.901Z",
"sensitive": false,
"spoiler_text": "",
"visibility": "public",
"replies_count": 5,
"reblogs_count": 6,
"favourites_count": 11,
"favourited": true,
"reblogged": false,
"muted": false,
"bookmarked": false,
"content": "<p>Mastodon is a free and open-source self-hosted social networking service. It allows anyone to host their own server node in the network, and its various separately operated user bases are federated across many different servers. These nodes are referred to as \"instances\" by Mastodon users.</p>",
"reblog": null,
"application": {
"name": "Web",
"website": null
},
"account": {
"id": "2",
"username": "Mastodon",
"acct": "mastodon",
"display_name": "Mastodon",
"avatar_static": "https://cdn.dnaindia.com/sites/default/files/styles/full/public/2017/04/06/563120-123.jpg"
},
"media_attachments": [],
"card": {
"url": "https://joinmastodon.org/",
"title": "Giving social networking back to you - Mastodon",
"description": "Mastodon is an open source decentralized social network - by the people for the people. Join the federation and take back control of your social media!",
"type": "link",
"image": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/48/Mastodon_Logotype_%28Simple%29.svg/1200px-Mastodon_Logotype_%28Simple%29.svg.png"
}
},
{
"id": "3",
"created_at": "2021-01-22T03:48:33.901Z",
"spoiler_text": "",
"visibility": "public",
"replies_count": 2,
"reblogs_count": null,
"favourites_count": 3,
"favourited": false,
"reblogged": false,
"muted": false,
"bookmarked": true,
"content": "<p>These servers are connected as a federated social network, allowing users from different servers to interact with each other seamlessly. Once a Mastodon server knows another Mastodon server, it \"federates\" with the other Mastodon server. Mastodon is a part of the wider Fediverse, allowing its users to also interact with users on different open platforms that support the same protocol, such as PeerTube and Friendica.</p>",
"reblog": null,
"application": {
"name": "Web",
"website": null
},
"account": {
"id": "3",
"username": "Fediverse",
"acct": "fediverse",
"display_name": "Fediverse",
"avatar_static": "https://e7.pngegg.com/pngimages/667/514/png-clipart-mastodon-fediverse-social-media-free-software-logo-social-media-blue-text.png"
},
"media_attachments": []
}
]
]
}

View File

@ -35,11 +35,13 @@ const client = async <T = unknown>({
localIndex !== undefined ? localIndex : state.local.activeIndex localIndex !== undefined ? localIndex : state.local.activeIndex
let domain = null let domain = null
let token = null
if (instance === 'remote') { if (instance === 'remote') {
domain = instanceDomain || state.remote.url domain = instanceDomain || state.remote.url
} else { } else {
if (theLocalIndex !== null && state.local.instances[theLocalIndex]) { if (theLocalIndex !== null && state.local.instances[theLocalIndex]) {
domain = state.local.instances[theLocalIndex].url domain = state.local.instances[theLocalIndex].url
token = state.local.instances[theLocalIndex].token
} else { } else {
console.error( console.error(
ctx.bgRed.white.bold(' API ') + ' ' + 'No instance domain is provided' ctx.bgRed.white.bold(' API ') + ' ' + 'No instance domain is provided'
@ -69,8 +71,8 @@ const client = async <T = unknown>({
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...headers, ...headers,
...(instance === 'local' && { ...(token && {
Authorization: `Bearer ${state.local!.instances[theLocalIndex!].token}` Authorization: `Bearer ${token}`
}) })
}, },
...(body && { data: body }), ...(body && { data: body }),

View File

@ -10,7 +10,7 @@ import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import * as Linking from 'expo-linking' import * as Linking from 'expo-linking'
import { debounce } from 'lodash' import { debounce } from 'lodash'
import React, { useCallback, useMemo, useRef, useState } from 'react' import React, { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Alert, Image, StyleSheet, Text, TextInput, View } from 'react-native' import { Alert, Image, StyleSheet, Text, TextInput, View } from 'react-native'
import { useQueryClient } from 'react-query' import { useQueryClient } from 'react-query'
@ -42,6 +42,7 @@ const ComponentInstance: React.FC<Props> = ({
const instanceQuery = useInstanceQuery({ const instanceQuery = useInstanceQuery({
instanceDomain, instanceDomain,
checkPublic: type === 'remote',
options: { enabled: false, retry: false } options: { enabled: false, retry: false }
}) })
const appsQuery = useAppsQuery({ const appsQuery = useAppsQuery({
@ -170,7 +171,12 @@ const ComponentInstance: React.FC<Props> = ({
styles.textInput, styles.textInput,
{ {
color: theme.primary, color: theme.primary,
borderBottomColor: theme.border borderBottomColor:
type === 'remote' &&
instanceQuery.data &&
!instanceQuery.data.publicAllow
? theme.red
: theme.border
} }
]} ]}
onChangeText={onChangeText} onChangeText={onChangeText}
@ -188,10 +194,20 @@ const ComponentInstance: React.FC<Props> = ({
type='text' type='text'
content={buttonContent} content={buttonContent}
onPress={processUpdate} onPress={processUpdate}
disabled={!instanceQuery.data?.uri} disabled={
!instanceQuery.data?.uri ||
(type === 'remote' && !instanceQuery.data.publicAllow)
}
loading={instanceQuery.isFetching || appsQuery.isFetching} loading={instanceQuery.isFetching || appsQuery.isFetching}
/> />
</View> </View>
{type === 'remote' &&
instanceQuery.data &&
!instanceQuery.data.publicAllow ? (
<Text style={[styles.privateInstance, { color: theme.red }]}>
{t('server.privateInstance')}
</Text>
) : null}
<View> <View>
<InstanceInfo <InstanceInfo
visible={instanceQuery.data?.title !== undefined} visible={instanceQuery.data?.title !== undefined}
@ -278,6 +294,12 @@ const styles = StyleSheet.create({
...StyleConstants.FontStyle.M, ...StyleConstants.FontStyle.M,
marginRight: StyleConstants.Spacing.M marginRight: StyleConstants.Spacing.M
}, },
privateInstance: {
...StyleConstants.FontStyle.S,
fontWeight: StyleConstants.Font.Weight.Bold,
marginLeft: StyleConstants.Spacing.Global.PagePadding,
marginTop: StyleConstants.Spacing.XS
},
instanceStats: { instanceStats: {
flex: 1, flex: 1,
flexDirection: 'row' flexDirection: 'row'

View File

@ -1,7 +1,7 @@
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react' import React, { useMemo } from 'react'
import { StyleSheet, Text } from 'react-native' import { StyleSheet, Text, View } from 'react-native'
import { Image } from 'react-native-expo-image-cache' import { Image } from 'react-native-expo-image-cache'
const regexEmoji = new RegExp(/(:[A-Za-z0-9_]+:)/) const regexEmoji = new RegExp(/(:[A-Za-z0-9_]+:)/)
@ -19,18 +19,26 @@ const ParseEmojis: React.FC<Props> = ({
size = 'M', size = 'M',
fontBold = false fontBold = false
}) => { }) => {
const { theme } = useTheme() const { mode, theme } = useTheme()
const styles = StyleSheet.create({ const styles = useMemo(() => {
text: { return StyleSheet.create({
color: theme.primary, text: {
...StyleConstants.FontStyle[size], color: theme.primary,
...(fontBold && { fontWeight: StyleConstants.Font.Weight.Bold }) ...StyleConstants.FontStyle[size],
}, ...(fontBold && { fontWeight: StyleConstants.Font.Weight.Bold })
image: { },
width: StyleConstants.Font.Size[size], imageContainer: {
height: StyleConstants.Font.Size[size] paddingVertical:
} (StyleConstants.Font.LineHeight[size] -
}) StyleConstants.Font.Size[size]) /
3
},
image: {
width: StyleConstants.Font.Size[size],
height: StyleConstants.Font.Size[size]
}
})
}, [mode])
return ( return (
<Text style={styles.text}> <Text style={styles.text}>
@ -50,7 +58,13 @@ const ParseEmojis: React.FC<Props> = ({
<Text key={i}> <Text key={i}>
{/* When emoji starts a paragraph, lineHeight will break */} {/* When emoji starts a paragraph, lineHeight will break */}
{i === 0 ? <Text> </Text> : null} {i === 0 ? <Text> </Text> : null}
<Image uri={emojis[emojiIndex].url} style={[styles.image]} /> <View style={styles.imageContainer}>
<Image
transitionDuration={0}
uri={emojis[emojiIndex].url}
style={[styles.image]}
/>
</View>
</Text> </Text>
) )
} else { } else {

View File

@ -154,12 +154,16 @@ const ParseHTML: React.FC<Props> = ({
tags, tags,
showFullLink = false, showFullLink = false,
numberOfLines = 10, numberOfLines = 10,
expandHint = '全文', expandHint,
disableDetails = false disableDetails = false
}) => { }) => {
const navigation = useNavigation() const navigation = useNavigation()
const route = useRoute() const route = useRoute()
const { theme } = useTheme() const { theme } = useTheme()
const { t, i18n } = useTranslation('componentParse')
if (!expandHint) {
expandHint = t('HTML.defaultHint')
}
const renderNodeCallback = useCallback( const renderNodeCallback = useCallback(
(node, index) => (node, index) =>
@ -261,7 +265,7 @@ const ParseHTML: React.FC<Props> = ({
</View> </View>
) )
}, },
[theme] [theme, i18n.language]
) )
return ( return (

View File

@ -6,7 +6,8 @@ import sharedScreens from '@screens/Shared/sharedScreens'
import { getLocalActiveIndex, getRemoteUrl } from '@utils/slices/instancesSlice' import { getLocalActiveIndex, getRemoteUrl } from '@utils/slices/instancesSlice'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useMemo, useState } from 'react' import React, { useCallback, useMemo, useState } from 'react'
import { Dimensions, Platform, StyleSheet, View } from 'react-native' import { useTranslation } from 'react-i18next'
import { Dimensions, Platform, StyleSheet } from 'react-native'
import { createNativeStackNavigator } from 'react-native-screens/native-stack' import { createNativeStackNavigator } from 'react-native-screens/native-stack'
import { TabView } from 'react-native-tab-view' import { TabView } from 'react-native-tab-view'
import ViewPagerAdapter from 'react-native-tab-view-viewpager-adapter' import ViewPagerAdapter from 'react-native-tab-view-viewpager-adapter'
@ -18,22 +19,32 @@ const Stack = createNativeStackNavigator<
export interface Props { export interface Props {
name: 'Local' | 'Public' name: 'Local' | 'Public'
content: { title: string; page: App.Pages; remote?: boolean }[]
} }
const Timelines: React.FC<Props> = ({ name, content }) => { const Timelines: React.FC<Props> = ({ name }) => {
const { t, i18n } = useTranslation()
const remoteUrl = useSelector(getRemoteUrl) const remoteUrl = useSelector(getRemoteUrl)
const mapNameToContent: {
[key: string]: { title: string; page: App.Pages }[]
} = {
Local: [
{ title: t('local:heading.segments.left'), page: 'Following' },
{ title: t('local:heading.segments.right'), page: 'Local' }
],
Public: [
{ title: t('public:heading.segments.left'), page: 'LocalPublic' },
{ title: remoteUrl, page: 'RemotePublic' }
]
}
const navigation = useNavigation() const navigation = useNavigation()
const { mode } = useTheme()
const localActiveIndex = useSelector(getLocalActiveIndex) const localActiveIndex = useSelector(getLocalActiveIndex)
const publicDomain = useSelector(getRemoteUrl)
const [segment, setSegment] = useState(0)
const onPressSearch = useCallback(() => { const onPressSearch = useCallback(() => {
navigation.navigate(`Screen-${name}`, { screen: 'Screen-Shared-Search' }) navigation.navigate(`Screen-${name}`, { screen: 'Screen-Shared-Search' })
}, []) }, [])
const routes = content const routes = mapNameToContent[name]
.filter(p => (localActiveIndex !== null ? true : p.page === 'RemotePublic')) .filter(p => (localActiveIndex !== null ? true : p.page === 'RemotePublic'))
.map(p => ({ key: p.page })) .map(p => ({ key: p.page }))
@ -54,39 +65,37 @@ const Timelines: React.FC<Props> = ({ name, content }) => {
[localActiveIndex] [localActiveIndex]
) )
const { mode } = useTheme()
const [segment, setSegment] = useState(0)
const screenOptions = useMemo(() => { const screenOptions = useMemo(() => {
if (localActiveIndex === null) { if (localActiveIndex === null) {
if (name === 'Public') { if (name === 'Public') {
return { return {
headerTitle: publicDomain, headerTitle: remoteUrl,
...(Platform.OS === 'android' && { ...(Platform.OS === 'android' && {
headerCenter: () => <HeaderCenter content={publicDomain} /> headerCenter: () => <HeaderCenter content={remoteUrl} />
}) })
} }
} }
} else { } else {
return { return {
headerCenter: () => ( headerCenter: () => (
<View style={styles.segmentsContainer}> <SegmentedControl
<SegmentedControl appearance={mode}
appearance={mode} values={mapNameToContent[name].map(p => p.title)}
values={[ selectedIndex={segment}
content[0].title, onChange={({ nativeEvent }) =>
content[1].remote ? remoteUrl : content[1].title setSegment(nativeEvent.selectedSegmentIndex)
]} }
selectedIndex={segment} style={styles.segmentsContainer}
onChange={({ nativeEvent }) => />
setSegment(nativeEvent.selectedSegmentIndex)
}
/>
</View>
), ),
headerRight: () => ( headerRight: () => (
<HeaderRight content='Search' onPress={onPressSearch} /> <HeaderRight content='Search' onPress={onPressSearch} />
) )
} }
} }
}, [localActiveIndex, mode, segment]) }, [localActiveIndex, mode, segment, i18n.language])
const renderPager = useCallback(props => <ViewPagerAdapter {...props} />, []) const renderPager = useCallback(props => <ViewPagerAdapter {...props} />, [])

View File

@ -5,7 +5,7 @@ import TimelineEmpty from '@components/Timelines/Timeline/Empty'
import TimelineEnd from '@root/components/Timelines/Timeline/End' import TimelineEnd from '@root/components/Timelines/Timeline/End'
import TimelineHeader from '@components/Timelines/Timeline/Header' import TimelineHeader from '@components/Timelines/Timeline/Header'
import TimelineNotifications from '@components/Timelines/Timeline/Notifications' import TimelineNotifications from '@components/Timelines/Timeline/Notifications'
import { useScrollToTop } from '@react-navigation/native' import { useNavigation, useScrollToTop } from '@react-navigation/native'
import { localUpdateNotification } from '@utils/slices/instancesSlice' import { localUpdateNotification } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import React, { useCallback, useEffect, useMemo, useRef } from 'react' import React, { useCallback, useEffect, useMemo, useRef } from 'react'
@ -19,7 +19,6 @@ import { FlatList } from 'react-native-gesture-handler'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import { QueryKeyTimeline, useTimelineQuery } from '@utils/queryHooks/timeline' import { QueryKeyTimeline, useTimelineQuery } from '@utils/queryHooks/timeline'
import { findIndex } from 'lodash' import { findIndex } from 'lodash'
import { InfiniteData, useQueryClient } from 'react-query'
import { getPublicRemoteNotice } from '@utils/slices/contextsSlice' import { getPublicRemoteNotice } from '@utils/slices/contextsSlice'
export interface Props { export interface Props {
@ -58,25 +57,12 @@ const Timeline: React.FC<Props> = ({
isSuccess, isSuccess,
isFetching, isFetching,
isLoading, isLoading,
hasPreviousPage,
fetchPreviousPage,
isFetchingPreviousPage,
hasNextPage, hasNextPage,
fetchNextPage, fetchNextPage,
isFetchingNextPage isFetchingNextPage
} = useTimelineQuery({ } = useTimelineQuery({
...queryKeyParams, ...queryKeyParams,
options: { options: {
getPreviousPageParam: firstPage => {
return Array.isArray(firstPage) && firstPage.length
? {
direction: 'prev',
id: firstPage[0].last_status
? firstPage[0].last_status.id
: firstPage[0].id
}
: undefined
},
getNextPageParam: lastPage => { getNextPageParam: lastPage => {
return Array.isArray(lastPage) && lastPage.length return Array.isArray(lastPage) && lastPage.length
? { ? {
@ -94,16 +80,23 @@ const Timeline: React.FC<Props> = ({
// Clear unread notification badge // Clear unread notification badge
const dispatch = useDispatch() const dispatch = useDispatch()
const navigation = useNavigation()
useEffect(() => { useEffect(() => {
if (page === 'Notifications' && flattenData.length) { const unsubscribe = navigation.addListener('focus', props => {
dispatch( if (props.target && props.target.includes('Screen-Notifications-Root')) {
localUpdateNotification({ if (flattenData.length) {
unread: false, dispatch(
latestTime: (flattenData[0] as Mastodon.Notification).created_at localUpdateNotification({
}) unread: false,
) latestTime: (flattenData[0] as Mastodon.Notification).created_at
} })
}, [flattenData]) )
}
}
})
return unsubscribe
}, [navigation, flattenData])
const flRef = useRef<FlatList<any>>(null) const flRef = useRef<FlatList<any>>(null)
useEffect(() => { useEffect(() => {
@ -166,43 +159,29 @@ const Timeline: React.FC<Props> = ({
[hasNextPage] [hasNextPage]
) )
const queryClient = useQueryClient() const isSwipeDown = useRef(false)
const refreshControl = useMemo( const refreshControl = useMemo(
() => ( () => (
<RefreshControl <RefreshControl
{...(Platform.OS === 'android' && { enabled: true })} {...(Platform.OS === 'android' && { enabled: true })}
refreshing={ refreshing={
isFetchingPreviousPage || isSwipeDown.current && isFetching && !isFetchingNextPage && !isLoading
(isFetching && !isFetchingNextPage && !isLoading)
} }
onRefresh={() => { onRefresh={() => {
// if (hasPreviousPage) { isSwipeDown.current = true
// fetchPreviousPage()
// } else {
// queryClient.setQueryData<InfiniteData<any> | undefined>(
// queryKey,
// data => {
// if (data) {
// return {
// pages: data.pages.slice(1),
// pageParams: data.pageParams.slice(1)
// }
// }
// }
// )
refetch() refetch()
// }
}} }}
/> />
), ),
[ [isSwipeDown.current, isFetching, isFetchingNextPage, isLoading]
hasPreviousPage,
isFetchingPreviousPage,
isFetching,
isFetchingNextPage,
isLoading
]
) )
useEffect(() => {
if (!isFetching) {
isSwipeDown.current = false
}
}, [isFetching])
const onScrollToIndexFailed = useCallback(error => { const onScrollToIndexFailed = useCallback(error => {
const offset = error.averageItemLength * error.index const offset = error.averageItemLength * error.index
flRef.current?.scrollToOffset({ offset }) flRef.current?.scrollToOffset({ offset })

View File

@ -79,43 +79,44 @@ const TimelineConversation: React.FC<Props> = ({
</View> </View>
{conversation.last_status ? ( {conversation.last_status ? (
<View <>
style={{ <View
paddingTop: highlighted ? StyleConstants.Spacing.S : 0, style={{
paddingLeft: highlighted paddingTop: highlighted ? StyleConstants.Spacing.S : 0,
? 0 paddingLeft: highlighted
: StyleConstants.Avatar.M + StyleConstants.Spacing.S ? 0
}} : StyleConstants.Avatar.M + StyleConstants.Spacing.S
> }}
<TimelineContent >
status={conversation.last_status} <TimelineContent
highlighted={highlighted} status={conversation.last_status}
/> highlighted={highlighted}
{conversation.last_status.poll && (
<TimelinePoll
queryKey={queryKey}
statusId={conversation.last_status.id}
poll={conversation.last_status.poll}
reblog={false}
sameAccount={conversation.last_status.id === localAccount?.id}
/> />
)} {conversation.last_status.poll && (
</View> <TimelinePoll
queryKey={queryKey}
statusId={conversation.last_status.id}
poll={conversation.last_status.poll}
reblog={false}
sameAccount={conversation.last_status.id === localAccount?.id}
/>
)}
</View>
<View
style={{
paddingLeft: highlighted
? 0
: StyleConstants.Avatar.M + StyleConstants.Spacing.S
}}
>
<TimelineActions
queryKey={queryKey}
status={conversation.last_status}
reblog={false}
/>
</View>
</>
) : null} ) : null}
<View
style={{
paddingLeft: highlighted
? 0
: StyleConstants.Avatar.M + StyleConstants.Spacing.S
}}
>
<TimelineActions
queryKey={queryKey}
status={conversation.last_status!}
reblog={false}
/>
</View>
</Pressable> </Pressable>
) )
} }

View File

@ -111,12 +111,10 @@ const AttachmentAudio: React.FC<Props> = ({
minimumTrackTintColor={theme.secondary} minimumTrackTintColor={theme.secondary}
maximumTrackTintColor={theme.disabled} maximumTrackTintColor={theme.disabled}
// onSlidingStart={() => { // onSlidingStart={() => {
// console.log('yes!!!')
// audioPlayer?.pauseAsync() // audioPlayer?.pauseAsync()
// setAudioPlaying(false) // setAudioPlaying(false)
// }} // }}
// onSlidingComplete={value => { // onSlidingComplete={value => {
// console.log('no!!!')
// setAudioPosition(value) // setAudioPosition(value)
// }} // }}
enabled={false} // Bug in above sliding actions enabled={false} // Bug in above sliding actions

View File

@ -59,7 +59,9 @@ const AttachmentUnsupported: React.FC<Props> = ({
content={t('shared.attachment.unsupported.button')} content={t('shared.attachment.unsupported.button')}
size='S' size='S'
overlay overlay
onPress={async () => await openLink(attachment.remote_url!)} onPress={async () =>
attachment.remote_url && (await openLink(attachment.remote_url))
}
/> />
) : null} ) : null}
</> </>

View File

@ -1,6 +1,7 @@
export default { export default {
server: { server: {
textInput: { placeholder: "Instance' domain" }, textInput: { placeholder: "Instance' domain" },
privateInstance: 'Private instance, peeping not allowed',
button: { button: {
local: 'Login', local: 'Login',
remote: 'Peep' remote: 'Peep'

View File

@ -3,6 +3,7 @@ export default {
expanded: { expanded: {
true: 'Fold {{hint}}', true: 'Fold {{hint}}',
false: 'Expand {{hint}}' false: 'Expand {{hint}}'
} },
defaultHint: 'article'
} }
} }

View File

@ -9,7 +9,7 @@ export default {
heading: '$t(sharedAnnouncements:heading)', heading: '$t(sharedAnnouncements:heading)',
content: { content: {
unread: '{{amount}} unread', unread: '{{amount}} unread',
read: 'all read' read: 'All read'
} }
} }
}, },

View File

@ -1,6 +1,7 @@
export default { export default {
server: { server: {
textInput: { placeholder: '输入社区服务器地址' }, textInput: { placeholder: '输入社区服务器地址' },
privateInstance: '非公开社区, 不能围观',
button: { button: {
local: '登录', local: '登录',
remote: '围观' remote: '围观'

View File

@ -3,6 +3,7 @@ export default {
expanded: { expanded: {
true: '折叠{{hint}}', true: '折叠{{hint}}',
false: '展开{{hint}}' false: '展开{{hint}}'
} },
defaultHint: '全文'
} }
} }

View File

@ -1,19 +1,11 @@
import Timelines from '@components/Timelines' import Timelines from '@components/Timelines'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next'
const ScreenLocal: React.FC = () => { const ScreenLocal = React.memo(
const { t } = useTranslation() () => {
return <Timelines name='Local' />
return ( },
<Timelines () => true
name='Local' )
content={[
{ title: t('local:heading.segments.left'), page: 'Following' },
{ title: t('local:heading.segments.right'), page: 'Local' }
]}
/>
)
}
export default ScreenLocal export default ScreenLocal

View File

@ -5,7 +5,7 @@ import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const Collections: React.FC = () => { const Collections: React.FC = () => {
const { t } = useTranslation('meRoot') const { t, i18n } = useTranslation('meRoot')
const navigation = useNavigation() const navigation = useNavigation()
const { data, isFetching } = useAnnouncementQuery({ showAll: true }) const { data, isFetching } = useAnnouncementQuery({ showAll: true })
@ -19,7 +19,7 @@ const Collections: React.FC = () => {
return t('content.collections.announcements.content.read') return t('content.collections.announcements.content.read')
} }
} }
}, [data]) }, [data, i18n.language])
return ( return (
<MenuContainer> <MenuContainer>

View File

@ -125,10 +125,10 @@ const ScreenMeSettings: React.FC = () => {
{ {
title: t('content.language.heading'), title: t('content.language.heading'),
options, options,
cancelButtonIndex: i18n.languages.length cancelButtonIndex: options.length - 1
}, },
buttonIndex => { buttonIndex => {
if (buttonIndex < i18n.languages.length) { if (buttonIndex < options.length) {
haptics('Success') haptics('Success')
dispatch(changeLanguage(availableLanguages[buttonIndex])) dispatch(changeLanguage(availableLanguages[buttonIndex]))
i18n.changeLanguage(availableLanguages[buttonIndex]) i18n.changeLanguage(availableLanguages[buttonIndex])

View File

@ -50,7 +50,7 @@ const AccountButton: React.FC<Props> = ({
disabled={disabled} disabled={disabled}
loading={isLoading} loading={isLoading}
style={styles.button} style={styles.button}
content={`@${data?.acct || '...'}@${instance.url}`} content={`@${data?.acct || '...'}@${instance.uri}${disabled ? ' ✓' : ''}`}
onPress={() => { onPress={() => {
dispatch(localUpdateActiveIndex(index)) dispatch(localUpdateActiveIndex(index))
queryClient.clear() queryClient.clear()
@ -125,7 +125,8 @@ const styles = StyleSheet.create({
marginTop: StyleConstants.Spacing.M marginTop: StyleConstants.Spacing.M
}, },
button: { button: {
marginBottom: StyleConstants.Spacing.M marginBottom: StyleConstants.Spacing.M,
marginRight: StyleConstants.Spacing.M
} }
}) })

View File

@ -1,23 +1,11 @@
import Timelines from '@components/Timelines' import Timelines from '@components/Timelines'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next'
const ScreenPublic: React.FC = () => { const ScreenPublic = React.memo(
const { t } = useTranslation() () => {
return <Timelines name='Public' />
return ( },
<Timelines () => true
name='Public' )
content={[
{ title: t('public:heading.segments.left'), page: 'LocalPublic' },
{
title: t('public:heading.segments.right'),
page: 'RemotePublic',
remote: true
}
]}
/>
)
}
export default ScreenPublic export default ScreenPublic

View File

@ -47,7 +47,7 @@ const AccountInformationCreated = forwardRef<ShimmerPlaceholder, Props>(
}} }}
> >
{t('content.created_at', { {t('content.created_at', {
date: new Date(account?.created_at!).toLocaleDateString( date: new Date(account?.created_at || '').toLocaleDateString(
i18n.language, i18n.language,
{ {
year: 'numeric', year: 'numeric',

View File

@ -22,6 +22,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'
import { createNativeStackNavigator } from 'react-native-screens/native-stack' import { createNativeStackNavigator } from 'react-native-screens/native-stack'
import { useQueryClient } from 'react-query' import { useQueryClient } from 'react-query'
import { useDispatch } from 'react-redux' import { useDispatch } from 'react-redux'
import * as Sentry from 'sentry-expo'
import ComposeEditAttachment from './Compose/EditAttachment' import ComposeEditAttachment from './Compose/EditAttachment'
import ComposeContext from './Compose/utils/createContext' import ComposeContext from './Compose/utils/createContext'
import composeInitialState from './Compose/utils/initialState' import composeInitialState from './Compose/utils/initialState'
@ -145,7 +146,7 @@ const Compose: React.FC<SharedComposeProp> = ({
}} }}
/> />
), ),
[totalTextCount] [totalTextCount, composeState]
) )
const headerCenter = useCallback( const headerCenter = useCallback(
() => ( () => (
@ -190,7 +191,8 @@ const Compose: React.FC<SharedComposeProp> = ({
} }
navigation.goBack() navigation.goBack()
}) })
.catch(() => { .catch(error => {
Sentry.Native.captureException(error)
haptics('Error') haptics('Error')
composeDispatch({ type: 'posting', payload: false }) composeDispatch({ type: 'posting', payload: false })
Alert.alert(t('heading.right.alert.title'), undefined, [ Alert.alert(t('heading.right.alert.title'), undefined, [
@ -201,7 +203,13 @@ const Compose: React.FC<SharedComposeProp> = ({
}) })
}} }}
loading={composeState.posting} loading={composeState.posting}
disabled={composeState.text.raw.length < 1 || totalTextCount > 500} disabled={
composeState.text.raw.length < 1 ||
totalTextCount > 500 ||
(composeState.attachments.uploads.length > 0 &&
composeState.attachments.uploads.filter(upload => upload.uploading)
.length > 0)
}
/> />
), ),
[totalTextCount, composeState] [totalTextCount, composeState]

View File

@ -54,10 +54,11 @@ const ComposeEditAttachment: React.FC<Props> = ({
if (theAttachment.type === 'image') { if (theAttachment.type === 'image') {
if (focus.current.x !== 0 || focus.current.y !== 0) { if (focus.current.x !== 0 || focus.current.y !== 0) {
theAttachment.meta!.focus = { theAttachment.meta &&
x: focus.current.x > 1 ? 1 : focus.current.x, (theAttachment.meta.focus = {
y: focus.current.y > 1 ? 1 : focus.current.y x: focus.current.x > 1 ? 1 : focus.current.x,
} y: focus.current.y > 1 ? 1 : focus.current.y
})
needUpdate = true needUpdate = true
} }
} }

View File

@ -28,20 +28,21 @@ const ComposeEditAttachmentImage: React.FC<Props> = ({ index, focus }) => {
const { theme } = useTheme() const { theme } = useTheme()
const { composeState } = useContext(ComposeContext) const { composeState } = useContext(ComposeContext)
const theAttachmentRemote = composeState.attachments.uploads[index].remote! const theAttachmentRemote = composeState.attachments.uploads[index].remote
const theAttachmentLocal = composeState.attachments.uploads[index].local! const theAttachmentLocal = composeState.attachments.uploads[index].local
const imageWidthBase = const imageWidthBase =
theAttachmentRemote.meta?.original?.aspect < 1 theAttachmentRemote?.meta?.original?.aspect < 1
? Dimensions.get('screen').width * ? Dimensions.get('screen').width *
theAttachmentRemote.meta?.original?.aspect theAttachmentRemote?.meta?.original?.aspect
: Dimensions.get('screen').width : Dimensions.get('screen').width
const padding = (Dimensions.get('screen').width - imageWidthBase) / 2 const padding = (Dimensions.get('screen').width - imageWidthBase) / 2
const imageDimensionis = { const imageDimensionis = {
width: imageWidthBase, width: imageWidthBase,
height: height:
imageWidthBase / imageWidthBase /
(theAttachmentRemote as Mastodon.AttachmentImage).meta?.original?.aspect! ((theAttachmentRemote as Mastodon.AttachmentImage).meta?.original
?.aspect || 1)
} }
const panX = useSharedValue( const panX = useSharedValue(
@ -115,7 +116,7 @@ const ComposeEditAttachmentImage: React.FC<Props> = ({ index, focus }) => {
height: imageDimensionis.height height: imageDimensionis.height
}} }}
source={{ source={{
uri: theAttachmentLocal.uri || theAttachmentRemote.preview_url uri: theAttachmentLocal?.uri || theAttachmentRemote?.preview_url
}} }}
/> />
<PanGestureHandler onGestureEvent={onGestureEvent}> <PanGestureHandler onGestureEvent={onGestureEvent}>

View File

@ -32,31 +32,33 @@ const ComposeEditAttachmentRoot: React.FC<Props> = ({
const { t } = useTranslation('sharedCompose') const { t } = useTranslation('sharedCompose')
const { theme } = useTheme() const { theme } = useTheme()
const { composeState } = useContext(ComposeContext) const { composeState } = useContext(ComposeContext)
const theAttachment = composeState.attachments.uploads[index].remote! const theAttachment = composeState.attachments.uploads[index].remote
const mediaDisplay = useMemo(() => { const mediaDisplay = useMemo(() => {
switch (theAttachment.type) { if (theAttachment) {
case 'image': switch (theAttachment.type) {
return <ComposeEditAttachmentImage index={index} focus={focus} /> case 'image':
case 'video': return <ComposeEditAttachmentImage index={index} focus={focus} />
case 'gifv': case 'video':
const video = composeState.attachments.uploads[index] case 'gifv':
return ( const video = composeState.attachments.uploads[index]
<AttachmentVideo return (
total={1} <AttachmentVideo
index={0} total={1}
sensitiveShown={false} index={0}
video={ sensitiveShown={false}
video.local video={
? ({ video.local
url: video.local.uri, ? ({
preview_url: video.local.local_thumbnail, url: video.local.uri,
blurhash: video.remote?.blurhash preview_url: video.local.local_thumbnail,
} as Mastodon.AttachmentVideo) blurhash: video.remote?.blurhash
: (video.remote! as Mastodon.AttachmentVideo) } as Mastodon.AttachmentVideo)
} : (video.remote as Mastodon.AttachmentVideo)
/> }
) />
)
}
} }
return null return null
}, []) }, [])

View File

@ -1,9 +1,7 @@
import React, { useContext } from 'react' import React, { useContext } from 'react'
import { Modal, StyleSheet, View } from 'react-native' import { Modal, View } from 'react-native'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import ComposeContext from './utils/createContext' import ComposeContext from './utils/createContext'
import { Chase } from 'react-native-animated-spinkit'
import { StyleConstants } from '@utils/styles/constants'
const ComposePosting = React.memo( const ComposePosting = React.memo(
() => { () => {
@ -16,15 +14,7 @@ const ComposePosting = React.memo(
animationType='fade' animationType='fade'
visible={composeState.posting} visible={composeState.posting}
children={ children={
<View <View style={{ flex: 1, backgroundColor: theme.backgroundOverlay }} />
style={[styles.base, { backgroundColor: theme.backgroundOverlay }]}
children={
<Chase
size={StyleConstants.Font.Size.L * 2}
color={theme.primaryOverlay}
/>
}
/>
} }
/> />
) )
@ -32,12 +22,4 @@ const ComposePosting = React.memo(
() => true () => true
) )
const styles = StyleSheet.create({
base: {
flex: 1,
justifyContent: 'center',
alignItems: 'center'
}
})
export default ComposePosting export default ComposePosting

View File

@ -5,7 +5,7 @@ import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import { forEach, groupBy, sortBy } from 'lodash' import { forEach, groupBy, sortBy } from 'lodash'
import React, { useCallback, useContext, useEffect, useMemo } from 'react' import React, { useCallback, useContext, useEffect, useMemo } from 'react'
import { View, FlatList, StyleSheet } from 'react-native' import { FlatList, Image, StyleSheet, View } from 'react-native'
import { Chase } from 'react-native-animated-spinkit' import { Chase } from 'react-native-animated-spinkit'
import ComposeActions from './Root/Actions' import ComposeActions from './Root/Actions'
import ComposePosting from './Posting' import ComposePosting from './Posting'
@ -53,6 +53,14 @@ const ComposeRoot: React.FC = () => {
}) })
} }
}, [emojisData]) }, [emojisData])
useEffect(() => {
if (emojisData && emojisData.length) {
// Prefetch first batch of emojis for faster loading experience
emojisData.slice(0, 40).forEach(emoji => {
Image.prefetch(emoji.url)
})
}
}, [emojisData])
const listEmpty = useMemo(() => { const listEmpty = useMemo(() => {
if (isFetching) { if (isFetching) {

View File

@ -250,7 +250,7 @@ const ComposeAttachments: React.FC = () => {
keyboardShouldPersistTaps='handled' keyboardShouldPersistTaps='handled'
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
data={composeState.attachments.uploads} data={composeState.attachments.uploads}
keyExtractor={item => item.local!.uri || item.remote!.url} keyExtractor={item => item.local?.uri || item.remote?.url}
ListFooterComponent={ ListFooterComponent={
composeState.attachments.uploads.length < 4 ? listFooter : null composeState.attachments.uploads.length < 4 ? listFooter : null
} }

View File

@ -70,7 +70,7 @@ const ComposeEmojis: React.FC = () => {
<SectionList <SectionList
horizontal horizontal
keyboardShouldPersistTaps='handled' keyboardShouldPersistTaps='handled'
sections={composeState.emoji.emojis!} sections={composeState.emoji.emojis || []}
keyExtractor={item => item.shortcode} keyExtractor={item => item.shortcode}
renderSectionHeader={listHeader} renderSectionHeader={listHeader}
renderItem={listItem} renderItem={listItem}

View File

@ -69,8 +69,8 @@ const formatText = ({
}) })
const changedTag = differenceWith(tags, prevTags, isEqual) const changedTag = differenceWith(tags, prevTags, isEqual)
if (changedTag.length && !disableDebounce) { if (changedTag.length > 0 && !disableDebounce) {
if (changedTag[0]!.type !== 'url') { if (changedTag[0]?.type !== 'url') {
debouncedSuggestions(composeDispatch, changedTag[0]) debouncedSuggestions(composeDispatch, changedTag[0])
} }
} else { } else {
@ -83,30 +83,33 @@ const formatText = ({
let contentLength: number = 0 let contentLength: number = 0
const children = [] const children = []
tags.forEach((tag, index) => { tags.forEach((tag, index) => {
const prev = _content.substr(0, tag!.offset - pointer) if (tag) {
const main = _content.substr(tag!.offset - pointer, tag!.length) const prev = _content.substr(0, tag.offset - pointer)
const next = _content.substr(tag!.offset - pointer + tag!.length) const main = _content.substr(tag.offset - pointer, tag.length)
children.push(prev) const next = _content.substr(tag.offset - pointer + tag.length)
contentLength = contentLength + prev.length children.push(prev)
children.push(<TagText key={index} text={main} />) contentLength = contentLength + prev.length
switch (tag!.type) { children.push(<TagText key={index} text={main} />)
case 'url': switch (tag.type) {
contentLength = contentLength + 23 case 'url':
break contentLength = contentLength + 23
case 'accounts': break
if (main.match(/@/g)!.length > 1) { case 'accounts':
contentLength = const theMatch = main.match(/@/g)
contentLength + main.split(new RegExp('(@.*?)@'))[1].length if (theMatch && theMatch.length > 1) {
} else { contentLength =
contentLength + main.split(new RegExp('(@.*?)@'))[1].length
} else {
contentLength = contentLength + main.length
}
break
case 'hashtags':
contentLength = contentLength + main.length contentLength = contentLength + main.length
} break
break }
case 'hashtags': _content = next
contentLength = contentLength + main.length pointer = pointer + prev.length + tag.length
break
} }
_content = next
pointer = pointer + prev.length + tag!.length
}) })
children.push(_content) children.push(_content)
contentLength = contentLength + _content.length contentLength = contentLength + _content.length

View File

@ -9,7 +9,7 @@ const composePost = async (
) => { ) => {
const formData = new FormData() const formData = new FormData()
if (params?.type === 'conversation' || params?.type === 'reply') { if (params?.type === 'reply') {
formData.append('in_reply_to_id', composeState.replyToStatus!.id) formData.append('in_reply_to_id', composeState.replyToStatus!.id)
} }
@ -20,9 +20,9 @@ const composePost = async (
formData.append('status', composeState.text.raw) formData.append('status', composeState.text.raw)
if (composeState.poll.active) { if (composeState.poll.active) {
Object.values(composeState.poll.options) Object.values(composeState.poll.options).forEach(
.filter(e => e?.length) e => e && e.length && formData.append('poll[options][]', e)
.forEach(e => formData.append('poll[options][]', e!)) )
formData.append('poll[expires_in]', composeState.poll.expire) formData.append('poll[expires_in]', composeState.poll.expire)
formData.append('poll[multiple]', composeState.poll.multiple.toString()) formData.append('poll[multiple]', composeState.poll.multiple.toString())
} }

View File

@ -58,7 +58,7 @@ const composeReducer = (
attachments: { attachments: {
...state.attachments, ...state.attachments,
uploads: state.attachments.uploads.filter( uploads: state.attachments.uploads.filter(
upload => upload.remote!.id !== action.payload upload => upload.remote?.id !== action.payload
) )
} }
} }

View File

@ -21,7 +21,7 @@ const queryFunction = ({ queryKey }: { queryKey: QueryKey }) => {
const { instanceDomain } = queryKey[1] const { instanceDomain } = queryKey[1]
const formData = new FormData() const formData = new FormData()
formData.append('client_name', 'tooot📱') formData.append('client_name', 'tooot')
formData.append('website', 'https://tooot.app') formData.append('website', 'https://tooot.app')
formData.append('redirect_uris', redirectUri) formData.append('redirect_uris', redirectUri)
formData.append('scopes', 'read write follow push') formData.append('scopes', 'read write follow push')

View File

@ -2,24 +2,56 @@ import client from '@api/client'
import { AxiosError } from 'axios' import { AxiosError } from 'axios'
import { useQuery, UseQueryOptions } from 'react-query' import { useQuery, UseQueryOptions } from 'react-query'
export type QueryKey = ['Instance', { instanceDomain?: string }] export type QueryKey = [
'Instance',
{ instanceDomain?: string; checkPublic: boolean }
]
const queryFunction = ({ queryKey }: { queryKey: QueryKey }) => { const queryFunction = async ({ queryKey }: { queryKey: QueryKey }) => {
const { instanceDomain } = queryKey[1] const { instanceDomain, checkPublic } = queryKey[1]
return client<Mastodon.Instance>({ let res: Mastodon.Instance & { publicAllow?: boolean } = await client<
Mastodon.Instance
>({
method: 'get', method: 'get',
instance: 'remote', instance: 'remote',
instanceDomain, instanceDomain,
url: `instance` url: `instance`
}) })
if (checkPublic) {
let check
try {
check = await client<Mastodon.Status[]>({
method: 'get',
instance: 'remote',
instanceDomain,
url: `timelines/public`
})
} catch {}
if (check) {
res.publicAllow = true
return res
} else {
res.publicAllow = false
return res
}
}
return res
} }
const useInstanceQuery = <TData = Mastodon.Instance>({ const useInstanceQuery = <
TData = Mastodon.Instance & { publicAllow?: boolean }
>({
options, options,
...queryKeyParams ...queryKeyParams
}: QueryKey[1] & { }: QueryKey[1] & {
options?: UseQueryOptions<Mastodon.Instance, AxiosError, TData> options?: UseQueryOptions<
Mastodon.Instance & { publicAllow?: boolean },
AxiosError,
TData
>
}) => { }) => {
const queryKey: QueryKey = ['Instance', { ...queryKeyParams }] const queryKey: QueryKey = ['Instance', { ...queryKeyParams }]
return useQuery(queryKey, queryFunction, options) return useQuery(queryKey, queryFunction, options)

View File

@ -292,7 +292,7 @@ const mutationFunction = async (params: MutationVarsTimeline) => {
case 'poll': case 'poll':
const formData = new FormData() const formData = new FormData()
params.payload.type === 'vote' && params.payload.type === 'vote' &&
params.payload.options!.forEach((option, index) => { params.payload.options?.forEach((option, index) => {
if (option) { if (option) {
formData.append('choices[]', index.toString()) formData.append('choices[]', index.toString())
} }

View File

@ -1,5 +1,6 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { RootState } from '@root/store' import { RootState } from '@root/store'
import Constants from 'expo-constants'
import * as StoreReview from 'expo-store-review' import * as StoreReview from 'expo-store-review'
export const supportedLngs = ['zh-Hans', 'en'] export const supportedLngs = ['zh-Hans', 'en']
@ -37,9 +38,11 @@ const contextsSlice = createSlice({
initialState: contextsInitialState as ContextsState, initialState: contextsInitialState as ContextsState,
reducers: { reducers: {
updateStoreReview: (state, action: PayloadAction<1>) => { updateStoreReview: (state, action: PayloadAction<1>) => {
state.storeReview.current = state.storeReview.current + action.payload if (Constants.manifest.releaseChannel === 'production') {
if (state.storeReview.current === state.storeReview.context) { state.storeReview.current = state.storeReview.current + action.payload
StoreReview.isAvailableAsync().then(() => StoreReview.requestReview()) if (state.storeReview.current === state.storeReview.context) {
StoreReview.isAvailableAsync().then(() => StoreReview.requestReview())
}
} }
}, },
updatePublicRemoteNotice: (state, action: PayloadAction<1>) => { updatePublicRemoteNotice: (state, action: PayloadAction<1>) => {

View File

@ -259,7 +259,7 @@ export const getLocalUrl = ({ instances: { local } }: RootState) =>
: undefined : undefined
export const getLocalUri = ({ instances: { local } }: RootState) => export const getLocalUri = ({ instances: { local } }: RootState) =>
local.activeIndex !== null local.activeIndex !== null
? local.instances[local.activeIndex].url ? local.instances[local.activeIndex].uri
: undefined : undefined
export const getLocalAccount = ({ instances: { local } }: RootState) => export const getLocalAccount = ({ instances: { local } }: RootState) =>
local.activeIndex !== null local.activeIndex !== null