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'
},
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: {

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
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 <T = unknown>({
headers: {
'Content-Type': 'application/json',
...headers,
...(instance === 'local' && {
Authorization: `Bearer ${state.local!.instances[theLocalIndex!].token}`
...(token && {
Authorization: `Bearer ${token}`
})
},
...(body && { data: body }),

View File

@ -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<Props> = ({
const instanceQuery = useInstanceQuery({
instanceDomain,
checkPublic: type === 'remote',
options: { enabled: false, retry: false }
})
const appsQuery = useAppsQuery({
@ -170,7 +171,12 @@ const ComponentInstance: React.FC<Props> = ({
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<Props> = ({
type='text'
content={buttonContent}
onPress={processUpdate}
disabled={!instanceQuery.data?.uri}
disabled={
!instanceQuery.data?.uri ||
(type === 'remote' && !instanceQuery.data.publicAllow)
}
loading={instanceQuery.isFetching || appsQuery.isFetching}
/>
</View>
{type === 'remote' &&
instanceQuery.data &&
!instanceQuery.data.publicAllow ? (
<Text style={[styles.privateInstance, { color: theme.red }]}>
{t('server.privateInstance')}
</Text>
) : null}
<View>
<InstanceInfo
visible={instanceQuery.data?.title !== undefined}
@ -278,6 +294,12 @@ const styles = StyleSheet.create({
...StyleConstants.FontStyle.M,
marginRight: StyleConstants.Spacing.M
},
privateInstance: {
...StyleConstants.FontStyle.S,
fontWeight: StyleConstants.Font.Weight.Bold,
marginLeft: StyleConstants.Spacing.Global.PagePadding,
marginTop: StyleConstants.Spacing.XS
},
instanceStats: {
flex: 1,
flexDirection: 'row'

View File

@ -1,7 +1,7 @@
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react'
import { StyleSheet, Text } from 'react-native'
import React, { useMemo } from 'react'
import { StyleSheet, Text, View } from 'react-native'
import { Image } from 'react-native-expo-image-cache'
const regexEmoji = new RegExp(/(:[A-Za-z0-9_]+:)/)
@ -19,18 +19,26 @@ const ParseEmojis: React.FC<Props> = ({
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 (
<Text style={styles.text}>
@ -50,7 +58,13 @@ const ParseEmojis: React.FC<Props> = ({
<Text key={i}>
{/* When emoji starts a paragraph, lineHeight will break */}
{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>
)
} else {

View File

@ -154,12 +154,16 @@ const ParseHTML: React.FC<Props> = ({
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<Props> = ({
</View>
)
},
[theme]
[theme, i18n.language]
)
return (

View File

@ -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<Props> = ({ name, content }) => {
const Timelines: React.FC<Props> = ({ 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<Props> = ({ 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 content={publicDomain} />
headerCenter: () => <HeaderCenter content={remoteUrl} />
})
}
}
} else {
return {
headerCenter: () => (
<View style={styles.segmentsContainer}>
<SegmentedControl
appearance={mode}
values={[
content[0].title,
content[1].remote ? remoteUrl : content[1].title
]}
selectedIndex={segment}
onChange={({ nativeEvent }) =>
setSegment(nativeEvent.selectedSegmentIndex)
}
/>
</View>
<SegmentedControl
appearance={mode}
values={mapNameToContent[name].map(p => p.title)}
selectedIndex={segment}
onChange={({ nativeEvent }) =>
setSegment(nativeEvent.selectedSegmentIndex)
}
style={styles.segmentsContainer}
/>
),
headerRight: () => (
<HeaderRight content='Search' onPress={onPressSearch} />
)
}
}
}, [localActiveIndex, mode, segment])
}, [localActiveIndex, mode, segment, i18n.language])
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 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<Props> = ({
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<Props> = ({
// 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<FlatList<any>>(null)
useEffect(() => {
@ -166,43 +159,29 @@ const Timeline: React.FC<Props> = ({
[hasNextPage]
)
const queryClient = useQueryClient()
const isSwipeDown = useRef(false)
const refreshControl = useMemo(
() => (
<RefreshControl
{...(Platform.OS === 'android' && { enabled: true })}
refreshing={
isFetchingPreviousPage ||
(isFetching && !isFetchingNextPage && !isLoading)
isSwipeDown.current && isFetching && !isFetchingNextPage && !isLoading
}
onRefresh={() => {
// if (hasPreviousPage) {
// fetchPreviousPage()
// } else {
// queryClient.setQueryData<InfiniteData<any> | 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 })

View File

@ -79,43 +79,44 @@ const TimelineConversation: React.FC<Props> = ({
</View>
{conversation.last_status ? (
<View
style={{
paddingTop: highlighted ? StyleConstants.Spacing.S : 0,
paddingLeft: highlighted
? 0
: StyleConstants.Avatar.M + StyleConstants.Spacing.S
}}
>
<TimelineContent
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}
<>
<View
style={{
paddingTop: highlighted ? StyleConstants.Spacing.S : 0,
paddingLeft: highlighted
? 0
: StyleConstants.Avatar.M + StyleConstants.Spacing.S
}}
>
<TimelineContent
status={conversation.last_status}
highlighted={highlighted}
/>
)}
</View>
{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}
/>
)}
</View>
<View
style={{
paddingLeft: highlighted
? 0
: StyleConstants.Avatar.M + StyleConstants.Spacing.S
}}
>
<TimelineActions
queryKey={queryKey}
status={conversation.last_status}
reblog={false}
/>
</View>
</>
) : null}
<View
style={{
paddingLeft: highlighted
? 0
: StyleConstants.Avatar.M + StyleConstants.Spacing.S
}}
>
<TimelineActions
queryKey={queryKey}
status={conversation.last_status!}
reblog={false}
/>
</View>
</Pressable>
)
}

View File

@ -111,12 +111,10 @@ const AttachmentAudio: React.FC<Props> = ({
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

View File

@ -59,7 +59,9 @@ const AttachmentUnsupported: React.FC<Props> = ({
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}
</>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (
<Timelines
name='Local'
content={[
{ title: t('local:heading.segments.left'), page: 'Following' },
{ title: t('local:heading.segments.right'), page: 'Local' }
]}
/>
)
}
const ScreenLocal = React.memo(
() => {
return <Timelines name='Local' />
},
() => true
)
export default ScreenLocal

View File

@ -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 (
<MenuContainer>

View File

@ -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])

View File

@ -50,7 +50,7 @@ const AccountButton: React.FC<Props> = ({
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
}
})

View File

@ -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 (
<Timelines
name='Public'
content={[
{ title: t('public:heading.segments.left'), page: 'LocalPublic' },
{
title: t('public:heading.segments.right'),
page: 'RemotePublic',
remote: true
}
]}
/>
)
}
const ScreenPublic = React.memo(
() => {
return <Timelines name='Public' />
},
() => true
)
export default ScreenPublic

View File

@ -47,7 +47,7 @@ const AccountInformationCreated = forwardRef<ShimmerPlaceholder, Props>(
}}
>
{t('content.created_at', {
date: new Date(account?.created_at!).toLocaleDateString(
date: new Date(account?.created_at || '').toLocaleDateString(
i18n.language,
{
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 { 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<SharedComposeProp> = ({
}}
/>
),
[totalTextCount]
[totalTextCount, composeState]
)
const headerCenter = useCallback(
() => (
@ -190,7 +191,8 @@ const Compose: React.FC<SharedComposeProp> = ({
}
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<SharedComposeProp> = ({
})
}}
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]

View File

@ -54,10 +54,11 @@ const ComposeEditAttachment: React.FC<Props> = ({
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
}
}

View File

@ -28,20 +28,21 @@ const ComposeEditAttachmentImage: React.FC<Props> = ({ 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<Props> = ({ index, focus }) => {
height: imageDimensionis.height
}}
source={{
uri: theAttachmentLocal.uri || theAttachmentRemote.preview_url
uri: theAttachmentLocal?.uri || theAttachmentRemote?.preview_url
}}
/>
<PanGestureHandler onGestureEvent={onGestureEvent}>

View File

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

View File

@ -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={
<View
style={[styles.base, { backgroundColor: theme.backgroundOverlay }]}
children={
<Chase
size={StyleConstants.Font.Size.L * 2}
color={theme.primaryOverlay}
/>
}
/>
<View style={{ flex: 1, backgroundColor: theme.backgroundOverlay }} />
}
/>
)
@ -32,12 +22,4 @@ const ComposePosting = React.memo(
() => true
)
const styles = StyleSheet.create({
base: {
flex: 1,
justifyContent: 'center',
alignItems: 'center'
}
})
export default ComposePosting

View File

@ -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) {

View File

@ -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
}

View File

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

View File

@ -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(<TagText key={index} text={main} />)
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(<TagText key={index} text={main} />)
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

View File

@ -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())
}

View File

@ -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
)
}
}

View File

@ -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')

View File

@ -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<Mastodon.Instance>({
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<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,
...queryKeyParams
}: QueryKey[1] & {
options?: UseQueryOptions<Mastodon.Instance, AxiosError, TData>
options?: UseQueryOptions<
Mastodon.Instance & { publicAllow?: boolean },
AxiosError,
TData
>
}) => {
const queryKey: QueryKey = ['Instance', { ...queryKeyParams }]
return useQuery(queryKey, queryFunction, options)

View File

@ -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())
}

View File

@ -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>) => {

View File

@ -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