diff --git a/README.md b/README.md new file mode 100644 index 00000000..9e10710f --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +@tooot@xmflsct.com \ No newline at end of file diff --git a/app.config.ts b/app.config.ts index cef5160e..00bdddb0 100644 --- a/app.config.ts +++ b/app.config.ts @@ -17,26 +17,10 @@ export default (): ExpoConfig => ({ image: './assets/splash.png' }, 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/*'], + extra: { + sentryDSN: process.env.SENTRY_DSN + }, hooks: { postPublish: [ { @@ -51,8 +35,24 @@ export default (): ExpoConfig => ({ } ] }, - extra: { - sentryDSN: process.env.SENTRY_DSN + ios: { + 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: { config: { diff --git a/demo/statuses.json b/demo/statuses.json new file mode 100644 index 00000000..2c654588 --- /dev/null +++ b/demo/statuses.json @@ -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": "

Would you like to try out this simple and open-source mobile app for Mastodon? 😊

", + "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": "

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.

", + "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": "

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.

", + "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": [] + } + ] + ] +} \ No newline at end of file diff --git a/src/api/client.ts b/src/api/client.ts index 986b3ede..52cdac4f 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -35,11 +35,13 @@ const client = async ({ localIndex !== undefined ? localIndex : state.local.activeIndex let domain = null + let token = null if (instance === 'remote') { domain = instanceDomain || state.remote.url } else { if (theLocalIndex !== null && state.local.instances[theLocalIndex]) { domain = state.local.instances[theLocalIndex].url + token = state.local.instances[theLocalIndex].token } else { console.error( ctx.bgRed.white.bold(' API ') + ' ' + 'No instance domain is provided' @@ -69,8 +71,8 @@ const client = async ({ headers: { 'Content-Type': 'application/json', ...headers, - ...(instance === 'local' && { - Authorization: `Bearer ${state.local!.instances[theLocalIndex!].token}` + ...(token && { + Authorization: `Bearer ${token}` }) }, ...(body && { data: body }), diff --git a/src/components/Instance.tsx b/src/components/Instance.tsx index a1f3f659..8b1b7b48 100644 --- a/src/components/Instance.tsx +++ b/src/components/Instance.tsx @@ -10,7 +10,7 @@ import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' import * as Linking from 'expo-linking' 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 { Alert, Image, StyleSheet, Text, TextInput, View } from 'react-native' import { useQueryClient } from 'react-query' @@ -42,6 +42,7 @@ const ComponentInstance: React.FC = ({ const instanceQuery = useInstanceQuery({ instanceDomain, + checkPublic: type === 'remote', options: { enabled: false, retry: false } }) const appsQuery = useAppsQuery({ @@ -170,7 +171,12 @@ const ComponentInstance: React.FC = ({ styles.textInput, { color: theme.primary, - borderBottomColor: theme.border + borderBottomColor: + type === 'remote' && + instanceQuery.data && + !instanceQuery.data.publicAllow + ? theme.red + : theme.border } ]} onChangeText={onChangeText} @@ -188,10 +194,20 @@ const ComponentInstance: React.FC = ({ type='text' content={buttonContent} onPress={processUpdate} - disabled={!instanceQuery.data?.uri} + disabled={ + !instanceQuery.data?.uri || + (type === 'remote' && !instanceQuery.data.publicAllow) + } loading={instanceQuery.isFetching || appsQuery.isFetching} /> + {type === 'remote' && + instanceQuery.data && + !instanceQuery.data.publicAllow ? ( + + {t('server.privateInstance')} + + ) : null} = ({ size = 'M', fontBold = false }) => { - const { theme } = useTheme() - const styles = StyleSheet.create({ - text: { - color: theme.primary, - ...StyleConstants.FontStyle[size], - ...(fontBold && { fontWeight: StyleConstants.Font.Weight.Bold }) - }, - image: { - width: StyleConstants.Font.Size[size], - height: StyleConstants.Font.Size[size] - } - }) + const { mode, theme } = useTheme() + const styles = useMemo(() => { + return StyleSheet.create({ + text: { + color: theme.primary, + ...StyleConstants.FontStyle[size], + ...(fontBold && { fontWeight: StyleConstants.Font.Weight.Bold }) + }, + imageContainer: { + paddingVertical: + (StyleConstants.Font.LineHeight[size] - + StyleConstants.Font.Size[size]) / + 3 + }, + image: { + width: StyleConstants.Font.Size[size], + height: StyleConstants.Font.Size[size] + } + }) + }, [mode]) return ( @@ -50,7 +58,13 @@ const ParseEmojis: React.FC = ({ {/* When emoji starts a paragraph, lineHeight will break */} {i === 0 ? : null} - + + + ) } else { diff --git a/src/components/Parse/HTML.tsx b/src/components/Parse/HTML.tsx index bae5b4b6..c80ad435 100644 --- a/src/components/Parse/HTML.tsx +++ b/src/components/Parse/HTML.tsx @@ -154,12 +154,16 @@ const ParseHTML: React.FC = ({ tags, showFullLink = false, numberOfLines = 10, - expandHint = '全文', + expandHint, disableDetails = false }) => { const navigation = useNavigation() const route = useRoute() const { theme } = useTheme() + const { t, i18n } = useTranslation('componentParse') + if (!expandHint) { + expandHint = t('HTML.defaultHint') + } const renderNodeCallback = useCallback( (node, index) => @@ -261,7 +265,7 @@ const ParseHTML: React.FC = ({ ) }, - [theme] + [theme, i18n.language] ) return ( diff --git a/src/components/Timelines.tsx b/src/components/Timelines.tsx index c3605f1b..6cb45a1c 100644 --- a/src/components/Timelines.tsx +++ b/src/components/Timelines.tsx @@ -6,7 +6,8 @@ import sharedScreens from '@screens/Shared/sharedScreens' import { getLocalActiveIndex, getRemoteUrl } from '@utils/slices/instancesSlice' import { useTheme } from '@utils/styles/ThemeManager' 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 { TabView } from 'react-native-tab-view' import ViewPagerAdapter from 'react-native-tab-view-viewpager-adapter' @@ -18,22 +19,32 @@ const Stack = createNativeStackNavigator< export interface Props { name: 'Local' | 'Public' - content: { title: string; page: App.Pages; remote?: boolean }[] } -const Timelines: React.FC = ({ name, content }) => { +const Timelines: React.FC = ({ name }) => { + const { t, i18n } = useTranslation() 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 { mode } = useTheme() const localActiveIndex = useSelector(getLocalActiveIndex) - const publicDomain = useSelector(getRemoteUrl) - const [segment, setSegment] = useState(0) const onPressSearch = useCallback(() => { navigation.navigate(`Screen-${name}`, { screen: 'Screen-Shared-Search' }) }, []) - const routes = content + const routes = mapNameToContent[name] .filter(p => (localActiveIndex !== null ? true : p.page === 'RemotePublic')) .map(p => ({ key: p.page })) @@ -54,39 +65,37 @@ const Timelines: React.FC = ({ name, content }) => { [localActiveIndex] ) + const { mode } = useTheme() + const [segment, setSegment] = useState(0) const screenOptions = useMemo(() => { if (localActiveIndex === null) { if (name === 'Public') { return { - headerTitle: publicDomain, + headerTitle: remoteUrl, ...(Platform.OS === 'android' && { - headerCenter: () => + headerCenter: () => }) } } } else { return { headerCenter: () => ( - - - setSegment(nativeEvent.selectedSegmentIndex) - } - /> - + p.title)} + selectedIndex={segment} + onChange={({ nativeEvent }) => + setSegment(nativeEvent.selectedSegmentIndex) + } + style={styles.segmentsContainer} + /> ), headerRight: () => ( ) } } - }, [localActiveIndex, mode, segment]) + }, [localActiveIndex, mode, segment, i18n.language]) const renderPager = useCallback(props => , []) diff --git a/src/components/Timelines/Timeline.tsx b/src/components/Timelines/Timeline.tsx index 36783101..f866eb3a 100644 --- a/src/components/Timelines/Timeline.tsx +++ b/src/components/Timelines/Timeline.tsx @@ -5,7 +5,7 @@ import TimelineEmpty from '@components/Timelines/Timeline/Empty' import TimelineEnd from '@root/components/Timelines/Timeline/End' import TimelineHeader from '@components/Timelines/Timeline/Header' 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 { StyleConstants } from '@utils/styles/constants' 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 { QueryKeyTimeline, useTimelineQuery } from '@utils/queryHooks/timeline' import { findIndex } from 'lodash' -import { InfiniteData, useQueryClient } from 'react-query' import { getPublicRemoteNotice } from '@utils/slices/contextsSlice' export interface Props { @@ -58,25 +57,12 @@ const Timeline: React.FC = ({ isSuccess, isFetching, isLoading, - hasPreviousPage, - fetchPreviousPage, - isFetchingPreviousPage, hasNextPage, fetchNextPage, isFetchingNextPage } = useTimelineQuery({ ...queryKeyParams, 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 => { return Array.isArray(lastPage) && lastPage.length ? { @@ -94,16 +80,23 @@ const Timeline: React.FC = ({ // Clear unread notification badge const dispatch = useDispatch() + const navigation = useNavigation() useEffect(() => { - if (page === 'Notifications' && flattenData.length) { - dispatch( - localUpdateNotification({ - unread: false, - latestTime: (flattenData[0] as Mastodon.Notification).created_at - }) - ) - } - }, [flattenData]) + const unsubscribe = navigation.addListener('focus', props => { + if (props.target && props.target.includes('Screen-Notifications-Root')) { + if (flattenData.length) { + dispatch( + localUpdateNotification({ + unread: false, + latestTime: (flattenData[0] as Mastodon.Notification).created_at + }) + ) + } + } + }) + + return unsubscribe + }, [navigation, flattenData]) const flRef = useRef>(null) useEffect(() => { @@ -166,43 +159,29 @@ const Timeline: React.FC = ({ [hasNextPage] ) - const queryClient = useQueryClient() + const isSwipeDown = useRef(false) const refreshControl = useMemo( () => ( { - // if (hasPreviousPage) { - // fetchPreviousPage() - // } else { - // queryClient.setQueryData | undefined>( - // queryKey, - // data => { - // if (data) { - // return { - // pages: data.pages.slice(1), - // pageParams: data.pageParams.slice(1) - // } - // } - // } - // ) + isSwipeDown.current = true refetch() - // } }} /> ), - [ - hasPreviousPage, - isFetchingPreviousPage, - isFetching, - isFetchingNextPage, - isLoading - ] + [isSwipeDown.current, isFetching, isFetchingNextPage, isLoading] ) + + useEffect(() => { + if (!isFetching) { + isSwipeDown.current = false + } + }, [isFetching]) + const onScrollToIndexFailed = useCallback(error => { const offset = error.averageItemLength * error.index flRef.current?.scrollToOffset({ offset }) diff --git a/src/components/Timelines/Timeline/Conversation.tsx b/src/components/Timelines/Timeline/Conversation.tsx index 3f35f19f..351c35d0 100644 --- a/src/components/Timelines/Timeline/Conversation.tsx +++ b/src/components/Timelines/Timeline/Conversation.tsx @@ -79,43 +79,44 @@ const TimelineConversation: React.FC = ({ {conversation.last_status ? ( - - - {conversation.last_status.poll && ( - + + - )} - + {conversation.last_status.poll && ( + + )} + + + + + ) : null} - - - - ) } diff --git a/src/components/Timelines/Timeline/Shared/Attachment/Audio.tsx b/src/components/Timelines/Timeline/Shared/Attachment/Audio.tsx index cbaa0bde..90eec222 100644 --- a/src/components/Timelines/Timeline/Shared/Attachment/Audio.tsx +++ b/src/components/Timelines/Timeline/Shared/Attachment/Audio.tsx @@ -111,12 +111,10 @@ const AttachmentAudio: React.FC = ({ minimumTrackTintColor={theme.secondary} maximumTrackTintColor={theme.disabled} // onSlidingStart={() => { - // console.log('yes!!!') // audioPlayer?.pauseAsync() // setAudioPlaying(false) // }} // onSlidingComplete={value => { - // console.log('no!!!') // setAudioPosition(value) // }} enabled={false} // Bug in above sliding actions diff --git a/src/components/Timelines/Timeline/Shared/Attachment/Unsupported.tsx b/src/components/Timelines/Timeline/Shared/Attachment/Unsupported.tsx index f1681a75..e525e83c 100644 --- a/src/components/Timelines/Timeline/Shared/Attachment/Unsupported.tsx +++ b/src/components/Timelines/Timeline/Shared/Attachment/Unsupported.tsx @@ -59,7 +59,9 @@ const AttachmentUnsupported: React.FC = ({ content={t('shared.attachment.unsupported.button')} size='S' overlay - onPress={async () => await openLink(attachment.remote_url!)} + onPress={async () => + attachment.remote_url && (await openLink(attachment.remote_url)) + } /> ) : null} diff --git a/src/i18n/en/components/instance.ts b/src/i18n/en/components/instance.ts index a64ec33d..e5ea0d78 100644 --- a/src/i18n/en/components/instance.ts +++ b/src/i18n/en/components/instance.ts @@ -1,6 +1,7 @@ export default { server: { textInput: { placeholder: "Instance' domain" }, + privateInstance: 'Private instance, peeping not allowed', button: { local: 'Login', remote: 'Peep' diff --git a/src/i18n/en/components/parse.ts b/src/i18n/en/components/parse.ts index 5a56b039..fd05c099 100644 --- a/src/i18n/en/components/parse.ts +++ b/src/i18n/en/components/parse.ts @@ -3,6 +3,7 @@ export default { expanded: { true: 'Fold {{hint}}', false: 'Expand {{hint}}' - } + }, + defaultHint: 'article' } } diff --git a/src/i18n/en/screens/meRoot.ts b/src/i18n/en/screens/meRoot.ts index a2dadbe2..4f4aee22 100644 --- a/src/i18n/en/screens/meRoot.ts +++ b/src/i18n/en/screens/meRoot.ts @@ -9,7 +9,7 @@ export default { heading: '$t(sharedAnnouncements:heading)', content: { unread: '{{amount}} unread', - read: 'all read' + read: 'All read' } } }, diff --git a/src/i18n/zh-Hans/components/instance.ts b/src/i18n/zh-Hans/components/instance.ts index 6ad3c05a..4da6b041 100644 --- a/src/i18n/zh-Hans/components/instance.ts +++ b/src/i18n/zh-Hans/components/instance.ts @@ -1,6 +1,7 @@ export default { server: { textInput: { placeholder: '输入社区服务器地址' }, + privateInstance: '非公开社区, 不能围观', button: { local: '登录', remote: '围观' diff --git a/src/i18n/zh-Hans/components/parse.ts b/src/i18n/zh-Hans/components/parse.ts index 67b48fe2..f1575725 100644 --- a/src/i18n/zh-Hans/components/parse.ts +++ b/src/i18n/zh-Hans/components/parse.ts @@ -3,6 +3,7 @@ export default { expanded: { true: '折叠{{hint}}', false: '展开{{hint}}' - } + }, + defaultHint: '全文' } } diff --git a/src/screens/Local.tsx b/src/screens/Local.tsx index 6f673f81..1300c49b 100644 --- a/src/screens/Local.tsx +++ b/src/screens/Local.tsx @@ -1,19 +1,11 @@ import Timelines from '@components/Timelines' import React from 'react' -import { useTranslation } from 'react-i18next' -const ScreenLocal: React.FC = () => { - const { t } = useTranslation() - - return ( - - ) -} +const ScreenLocal = React.memo( + () => { + return + }, + () => true +) export default ScreenLocal diff --git a/src/screens/Me/Root/Collections.tsx b/src/screens/Me/Root/Collections.tsx index 4af18dcf..af55da15 100644 --- a/src/screens/Me/Root/Collections.tsx +++ b/src/screens/Me/Root/Collections.tsx @@ -5,7 +5,7 @@ import React, { useMemo } from 'react' import { useTranslation } from 'react-i18next' const Collections: React.FC = () => { - const { t } = useTranslation('meRoot') + const { t, i18n } = useTranslation('meRoot') const navigation = useNavigation() const { data, isFetching } = useAnnouncementQuery({ showAll: true }) @@ -19,7 +19,7 @@ const Collections: React.FC = () => { return t('content.collections.announcements.content.read') } } - }, [data]) + }, [data, i18n.language]) return ( diff --git a/src/screens/Me/Settings.tsx b/src/screens/Me/Settings.tsx index 85353d02..148e8b39 100644 --- a/src/screens/Me/Settings.tsx +++ b/src/screens/Me/Settings.tsx @@ -125,10 +125,10 @@ const ScreenMeSettings: React.FC = () => { { title: t('content.language.heading'), options, - cancelButtonIndex: i18n.languages.length + cancelButtonIndex: options.length - 1 }, buttonIndex => { - if (buttonIndex < i18n.languages.length) { + if (buttonIndex < options.length) { haptics('Success') dispatch(changeLanguage(availableLanguages[buttonIndex])) i18n.changeLanguage(availableLanguages[buttonIndex]) diff --git a/src/screens/Me/Switch/Root.tsx b/src/screens/Me/Switch/Root.tsx index 2a5d3c87..a8634102 100644 --- a/src/screens/Me/Switch/Root.tsx +++ b/src/screens/Me/Switch/Root.tsx @@ -50,7 +50,7 @@ const AccountButton: React.FC = ({ disabled={disabled} loading={isLoading} style={styles.button} - content={`@${data?.acct || '...'}@${instance.url}`} + content={`@${data?.acct || '...'}@${instance.uri}${disabled ? ' ✓' : ''}`} onPress={() => { dispatch(localUpdateActiveIndex(index)) queryClient.clear() @@ -125,7 +125,8 @@ const styles = StyleSheet.create({ marginTop: StyleConstants.Spacing.M }, button: { - marginBottom: StyleConstants.Spacing.M + marginBottom: StyleConstants.Spacing.M, + marginRight: StyleConstants.Spacing.M } }) diff --git a/src/screens/Public.tsx b/src/screens/Public.tsx index bf1b5516..82b8c634 100644 --- a/src/screens/Public.tsx +++ b/src/screens/Public.tsx @@ -1,23 +1,11 @@ import Timelines from '@components/Timelines' import React from 'react' -import { useTranslation } from 'react-i18next' -const ScreenPublic: React.FC = () => { - const { t } = useTranslation() - - return ( - - ) -} +const ScreenPublic = React.memo( + () => { + return + }, + () => true +) export default ScreenPublic diff --git a/src/screens/Shared/Account/Information/Created.tsx b/src/screens/Shared/Account/Information/Created.tsx index 7228bbc2..572d2f0a 100644 --- a/src/screens/Shared/Account/Information/Created.tsx +++ b/src/screens/Shared/Account/Information/Created.tsx @@ -47,7 +47,7 @@ const AccountInformationCreated = forwardRef( }} > {t('content.created_at', { - date: new Date(account?.created_at!).toLocaleDateString( + date: new Date(account?.created_at || '').toLocaleDateString( i18n.language, { year: 'numeric', diff --git a/src/screens/Shared/Compose.tsx b/src/screens/Shared/Compose.tsx index 863bf139..310e2d79 100644 --- a/src/screens/Shared/Compose.tsx +++ b/src/screens/Shared/Compose.tsx @@ -22,6 +22,7 @@ import { SafeAreaView } from 'react-native-safe-area-context' import { createNativeStackNavigator } from 'react-native-screens/native-stack' import { useQueryClient } from 'react-query' import { useDispatch } from 'react-redux' +import * as Sentry from 'sentry-expo' import ComposeEditAttachment from './Compose/EditAttachment' import ComposeContext from './Compose/utils/createContext' import composeInitialState from './Compose/utils/initialState' @@ -145,7 +146,7 @@ const Compose: React.FC = ({ }} /> ), - [totalTextCount] + [totalTextCount, composeState] ) const headerCenter = useCallback( () => ( @@ -190,7 +191,8 @@ const Compose: React.FC = ({ } navigation.goBack() }) - .catch(() => { + .catch(error => { + Sentry.Native.captureException(error) haptics('Error') composeDispatch({ type: 'posting', payload: false }) Alert.alert(t('heading.right.alert.title'), undefined, [ @@ -201,7 +203,13 @@ const Compose: React.FC = ({ }) }} 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] diff --git a/src/screens/Shared/Compose/EditAttachment.tsx b/src/screens/Shared/Compose/EditAttachment.tsx index e0daf91a..165cd653 100644 --- a/src/screens/Shared/Compose/EditAttachment.tsx +++ b/src/screens/Shared/Compose/EditAttachment.tsx @@ -54,10 +54,11 @@ const ComposeEditAttachment: React.FC = ({ if (theAttachment.type === 'image') { if (focus.current.x !== 0 || focus.current.y !== 0) { - theAttachment.meta!.focus = { - x: focus.current.x > 1 ? 1 : focus.current.x, - y: focus.current.y > 1 ? 1 : focus.current.y - } + theAttachment.meta && + (theAttachment.meta.focus = { + x: focus.current.x > 1 ? 1 : focus.current.x, + y: focus.current.y > 1 ? 1 : focus.current.y + }) needUpdate = true } } diff --git a/src/screens/Shared/Compose/EditAttachment/Image.tsx b/src/screens/Shared/Compose/EditAttachment/Image.tsx index c7f1016a..10397965 100644 --- a/src/screens/Shared/Compose/EditAttachment/Image.tsx +++ b/src/screens/Shared/Compose/EditAttachment/Image.tsx @@ -28,20 +28,21 @@ const ComposeEditAttachmentImage: React.FC = ({ index, focus }) => { const { theme } = useTheme() const { composeState } = useContext(ComposeContext) - const theAttachmentRemote = composeState.attachments.uploads[index].remote! - const theAttachmentLocal = composeState.attachments.uploads[index].local! + const theAttachmentRemote = composeState.attachments.uploads[index].remote + const theAttachmentLocal = composeState.attachments.uploads[index].local const imageWidthBase = - theAttachmentRemote.meta?.original?.aspect < 1 + theAttachmentRemote?.meta?.original?.aspect < 1 ? Dimensions.get('screen').width * - theAttachmentRemote.meta?.original?.aspect + theAttachmentRemote?.meta?.original?.aspect : Dimensions.get('screen').width const padding = (Dimensions.get('screen').width - imageWidthBase) / 2 const imageDimensionis = { width: imageWidthBase, height: imageWidthBase / - (theAttachmentRemote as Mastodon.AttachmentImage).meta?.original?.aspect! + ((theAttachmentRemote as Mastodon.AttachmentImage).meta?.original + ?.aspect || 1) } const panX = useSharedValue( @@ -115,7 +116,7 @@ const ComposeEditAttachmentImage: React.FC = ({ index, focus }) => { height: imageDimensionis.height }} source={{ - uri: theAttachmentLocal.uri || theAttachmentRemote.preview_url + uri: theAttachmentLocal?.uri || theAttachmentRemote?.preview_url }} /> diff --git a/src/screens/Shared/Compose/EditAttachment/Root.tsx b/src/screens/Shared/Compose/EditAttachment/Root.tsx index 39486c6e..6f6da03d 100644 --- a/src/screens/Shared/Compose/EditAttachment/Root.tsx +++ b/src/screens/Shared/Compose/EditAttachment/Root.tsx @@ -32,31 +32,33 @@ const ComposeEditAttachmentRoot: React.FC = ({ const { t } = useTranslation('sharedCompose') const { theme } = useTheme() const { composeState } = useContext(ComposeContext) - const theAttachment = composeState.attachments.uploads[index].remote! + const theAttachment = composeState.attachments.uploads[index].remote const mediaDisplay = useMemo(() => { - switch (theAttachment.type) { - case 'image': - return - case 'video': - case 'gifv': - const video = composeState.attachments.uploads[index] - return ( - - ) + if (theAttachment) { + switch (theAttachment.type) { + case 'image': + return + case 'video': + case 'gifv': + const video = composeState.attachments.uploads[index] + return ( + + ) + } } return null }, []) diff --git a/src/screens/Shared/Compose/Posting.tsx b/src/screens/Shared/Compose/Posting.tsx index 5164e851..c3225d7f 100644 --- a/src/screens/Shared/Compose/Posting.tsx +++ b/src/screens/Shared/Compose/Posting.tsx @@ -1,9 +1,7 @@ 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 ComposeContext from './utils/createContext' -import { Chase } from 'react-native-animated-spinkit' -import { StyleConstants } from '@utils/styles/constants' const ComposePosting = React.memo( () => { @@ -16,15 +14,7 @@ const ComposePosting = React.memo( animationType='fade' visible={composeState.posting} children={ - - } - /> + } /> ) @@ -32,12 +22,4 @@ const ComposePosting = React.memo( () => true ) -const styles = StyleSheet.create({ - base: { - flex: 1, - justifyContent: 'center', - alignItems: 'center' - } -}) - export default ComposePosting diff --git a/src/screens/Shared/Compose/Root.tsx b/src/screens/Shared/Compose/Root.tsx index 0ac7a24f..c49c762d 100644 --- a/src/screens/Shared/Compose/Root.tsx +++ b/src/screens/Shared/Compose/Root.tsx @@ -5,7 +5,7 @@ import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' import { forEach, groupBy, sortBy } from 'lodash' 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 ComposeActions from './Root/Actions' import ComposePosting from './Posting' @@ -53,6 +53,14 @@ const ComposeRoot: React.FC = () => { }) } }, [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(() => { if (isFetching) { diff --git a/src/screens/Shared/Compose/Root/Footer/Attachments.tsx b/src/screens/Shared/Compose/Root/Footer/Attachments.tsx index 8504cd59..6a9818eb 100644 --- a/src/screens/Shared/Compose/Root/Footer/Attachments.tsx +++ b/src/screens/Shared/Compose/Root/Footer/Attachments.tsx @@ -250,7 +250,7 @@ const ComposeAttachments: React.FC = () => { keyboardShouldPersistTaps='handled' showsHorizontalScrollIndicator={false} data={composeState.attachments.uploads} - keyExtractor={item => item.local!.uri || item.remote!.url} + keyExtractor={item => item.local?.uri || item.remote?.url} ListFooterComponent={ composeState.attachments.uploads.length < 4 ? listFooter : null } diff --git a/src/screens/Shared/Compose/Root/Footer/Emojis.tsx b/src/screens/Shared/Compose/Root/Footer/Emojis.tsx index 36e239a9..c73c8c46 100644 --- a/src/screens/Shared/Compose/Root/Footer/Emojis.tsx +++ b/src/screens/Shared/Compose/Root/Footer/Emojis.tsx @@ -70,7 +70,7 @@ const ComposeEmojis: React.FC = () => { item.shortcode} renderSectionHeader={listHeader} renderItem={listItem} diff --git a/src/screens/Shared/Compose/formatText.tsx b/src/screens/Shared/Compose/formatText.tsx index 99752b08..7ee4feb0 100644 --- a/src/screens/Shared/Compose/formatText.tsx +++ b/src/screens/Shared/Compose/formatText.tsx @@ -69,8 +69,8 @@ const formatText = ({ }) const changedTag = differenceWith(tags, prevTags, isEqual) - if (changedTag.length && !disableDebounce) { - if (changedTag[0]!.type !== 'url') { + if (changedTag.length > 0 && !disableDebounce) { + if (changedTag[0]?.type !== 'url') { debouncedSuggestions(composeDispatch, changedTag[0]) } } else { @@ -83,30 +83,33 @@ const formatText = ({ let contentLength: number = 0 const children = [] tags.forEach((tag, index) => { - const prev = _content.substr(0, tag!.offset - pointer) - const main = _content.substr(tag!.offset - pointer, tag!.length) - const next = _content.substr(tag!.offset - pointer + tag!.length) - children.push(prev) - contentLength = contentLength + prev.length - children.push() - switch (tag!.type) { - case 'url': - contentLength = contentLength + 23 - break - case 'accounts': - if (main.match(/@/g)!.length > 1) { - contentLength = - contentLength + main.split(new RegExp('(@.*?)@'))[1].length - } else { + if (tag) { + const prev = _content.substr(0, tag.offset - pointer) + const main = _content.substr(tag.offset - pointer, tag.length) + const next = _content.substr(tag.offset - pointer + tag.length) + children.push(prev) + contentLength = contentLength + prev.length + children.push() + switch (tag.type) { + case 'url': + contentLength = contentLength + 23 + break + case 'accounts': + const theMatch = main.match(/@/g) + if (theMatch && theMatch.length > 1) { + contentLength = + contentLength + main.split(new RegExp('(@.*?)@'))[1].length + } else { + contentLength = contentLength + main.length + } + break + case 'hashtags': contentLength = contentLength + main.length - } - break - case 'hashtags': - contentLength = contentLength + main.length - break + break + } + _content = next + pointer = pointer + prev.length + tag.length } - _content = next - pointer = pointer + prev.length + tag!.length }) children.push(_content) contentLength = contentLength + _content.length diff --git a/src/screens/Shared/Compose/utils/post.ts b/src/screens/Shared/Compose/utils/post.ts index c62d0457..2464c870 100644 --- a/src/screens/Shared/Compose/utils/post.ts +++ b/src/screens/Shared/Compose/utils/post.ts @@ -9,7 +9,7 @@ const composePost = async ( ) => { const formData = new FormData() - if (params?.type === 'conversation' || params?.type === 'reply') { + if (params?.type === 'reply') { formData.append('in_reply_to_id', composeState.replyToStatus!.id) } @@ -20,9 +20,9 @@ const composePost = async ( formData.append('status', composeState.text.raw) if (composeState.poll.active) { - Object.values(composeState.poll.options) - .filter(e => e?.length) - .forEach(e => formData.append('poll[options][]', e!)) + Object.values(composeState.poll.options).forEach( + e => e && e.length && formData.append('poll[options][]', e) + ) formData.append('poll[expires_in]', composeState.poll.expire) formData.append('poll[multiple]', composeState.poll.multiple.toString()) } diff --git a/src/screens/Shared/Compose/utils/reducer.ts b/src/screens/Shared/Compose/utils/reducer.ts index 1f124e82..33932383 100644 --- a/src/screens/Shared/Compose/utils/reducer.ts +++ b/src/screens/Shared/Compose/utils/reducer.ts @@ -58,7 +58,7 @@ const composeReducer = ( attachments: { ...state.attachments, uploads: state.attachments.uploads.filter( - upload => upload.remote!.id !== action.payload + upload => upload.remote?.id !== action.payload ) } } diff --git a/src/utils/queryHooks/apps.ts b/src/utils/queryHooks/apps.ts index cb445e49..3ea507a3 100644 --- a/src/utils/queryHooks/apps.ts +++ b/src/utils/queryHooks/apps.ts @@ -21,7 +21,7 @@ const queryFunction = ({ queryKey }: { queryKey: QueryKey }) => { const { instanceDomain } = queryKey[1] const formData = new FormData() - formData.append('client_name', 'tooot📱') + formData.append('client_name', 'tooot') formData.append('website', 'https://tooot.app') formData.append('redirect_uris', redirectUri) formData.append('scopes', 'read write follow push') diff --git a/src/utils/queryHooks/instance.ts b/src/utils/queryHooks/instance.ts index dcb287e7..f44c9280 100644 --- a/src/utils/queryHooks/instance.ts +++ b/src/utils/queryHooks/instance.ts @@ -2,24 +2,56 @@ import client from '@api/client' import { AxiosError } from 'axios' 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 { instanceDomain } = queryKey[1] +const queryFunction = async ({ queryKey }: { queryKey: QueryKey }) => { + const { instanceDomain, checkPublic } = queryKey[1] - return client({ + let res: Mastodon.Instance & { publicAllow?: boolean } = await client< + Mastodon.Instance + >({ method: 'get', instance: 'remote', instanceDomain, url: `instance` }) + + if (checkPublic) { + let check + try { + check = await client({ + 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 = ({ +const useInstanceQuery = < + TData = Mastodon.Instance & { publicAllow?: boolean } +>({ options, ...queryKeyParams }: QueryKey[1] & { - options?: UseQueryOptions + options?: UseQueryOptions< + Mastodon.Instance & { publicAllow?: boolean }, + AxiosError, + TData + > }) => { const queryKey: QueryKey = ['Instance', { ...queryKeyParams }] return useQuery(queryKey, queryFunction, options) diff --git a/src/utils/queryHooks/timeline.ts b/src/utils/queryHooks/timeline.ts index 4ca557bc..2bed0099 100644 --- a/src/utils/queryHooks/timeline.ts +++ b/src/utils/queryHooks/timeline.ts @@ -292,7 +292,7 @@ const mutationFunction = async (params: MutationVarsTimeline) => { case 'poll': const formData = new FormData() params.payload.type === 'vote' && - params.payload.options!.forEach((option, index) => { + params.payload.options?.forEach((option, index) => { if (option) { formData.append('choices[]', index.toString()) } diff --git a/src/utils/slices/contextsSlice.ts b/src/utils/slices/contextsSlice.ts index 28cd1327..2d5069a0 100644 --- a/src/utils/slices/contextsSlice.ts +++ b/src/utils/slices/contextsSlice.ts @@ -1,5 +1,6 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit' import { RootState } from '@root/store' +import Constants from 'expo-constants' import * as StoreReview from 'expo-store-review' export const supportedLngs = ['zh-Hans', 'en'] @@ -37,9 +38,11 @@ const contextsSlice = createSlice({ initialState: contextsInitialState as ContextsState, reducers: { updateStoreReview: (state, action: PayloadAction<1>) => { - state.storeReview.current = state.storeReview.current + action.payload - if (state.storeReview.current === state.storeReview.context) { - StoreReview.isAvailableAsync().then(() => StoreReview.requestReview()) + if (Constants.manifest.releaseChannel === 'production') { + state.storeReview.current = state.storeReview.current + action.payload + if (state.storeReview.current === state.storeReview.context) { + StoreReview.isAvailableAsync().then(() => StoreReview.requestReview()) + } } }, updatePublicRemoteNotice: (state, action: PayloadAction<1>) => { diff --git a/src/utils/slices/instancesSlice.ts b/src/utils/slices/instancesSlice.ts index 11d9ffe8..31718612 100644 --- a/src/utils/slices/instancesSlice.ts +++ b/src/utils/slices/instancesSlice.ts @@ -259,7 +259,7 @@ export const getLocalUrl = ({ instances: { local } }: RootState) => : undefined export const getLocalUri = ({ instances: { local } }: RootState) => local.activeIndex !== null - ? local.instances[local.activeIndex].url + ? local.instances[local.activeIndex].uri : undefined export const getLocalAccount = ({ instances: { local } }: RootState) => local.activeIndex !== null