1
0
mirror of https://github.com/tooot-app/app synced 2025-06-05 22:19:13 +02:00

Second commit

This commit is contained in:
Zhiyuan Zheng
2020-10-23 09:22:17 +02:00
parent 9b8df9316f
commit 83f6039ade
26 changed files with 639 additions and 259 deletions

35
src/Main.jsx Normal file
View File

@@ -0,0 +1,35 @@
import 'react-native-gesture-handler'
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'
import { NavigationContainer } from '@react-navigation/native'
import { enableScreens } from 'react-native-screens'
enableScreens()
import React from 'react'
import store from './app/store'
import { Provider } from 'react-redux'
import { StatusBar } from 'expo-status-bar'
import MainTimeline from 'src/stacks/MainTimeline'
import PublicTimeline from 'src/stacks/PublicTimeline'
import Notifications from 'src/stacks/Notifications'
import Me from 'src/stacks/Me'
const Tab = createBottomTabNavigator()
export default function Main () {
return (
<>
<Provider store={store}>
<StatusBar style='auto' />
<NavigationContainer>
<Tab.Navigator>
<Tab.Screen name='MainTimeline' component={MainTimeline} />
<Tab.Screen name='PublicTimeline' component={PublicTimeline} />
{/* <Tab.Screen name='Notifications' component={Notifications} /> */}
<Tab.Screen name='Me' component={Me} />
</Tab.Navigator>
</NavigationContainer>
</Provider>
</>
)
}

53
src/api/client.js Normal file
View File

@@ -0,0 +1,53 @@
export async function client (
instance,
endpoint,
query,
{ body, ...customConfig } = {}
) {
if (!instance || !endpoint) {
console.error('Missing instance or endpoint.')
return Promise.reject('Missing instance or endpoint.')
}
const headers = { 'Content-Type': 'application/json' }
const config = {
method: body ? 'POST' : 'GET',
...customConfig,
headers: {
...headers,
...customConfig.headers
}
}
if (body) {
config.body = JSON.stringify(body)
}
let data
try {
const response = await fetch(
`https://${instance}/api/v1/${endpoint}${
query
? `?${Object.keys(query)
.map(key => `${key}=${query[key]}`)
.join('&')}`
: ''
}`,
config
)
data = await response.json()
if (response.ok) {
return data
}
throw new Error(response.statusText)
} catch (err) {
return Promise.reject(err.message ? err.message : data)
}
}
client.get = function (instance, endpoint, query, customConfig = {}) {
return client(instance, endpoint, query, { ...customConfig, method: 'GET' })
}
client.post = function (instance, endpoint, query, body, customConfig = {}) {
return client(instance, endpoint, query, { ...customConfig, body })
}

16
src/app/store.js Normal file
View File

@@ -0,0 +1,16 @@
import { configureStore } from '@reduxjs/toolkit'
import genericTimelineSlice from 'src/stacks/common/timelineSlice'
export default configureStore({
reducer: {
'social.xmflsct.com': genericTimelineSlice('social.xmflsct.com').slice
.reducer,
'm.cmx.im': genericTimelineSlice('m.cmx.im').slice.reducer
},
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
immutableCheck: false,
serializableCheck: false
})
})

View File

@@ -0,0 +1,75 @@
import PropTypes from 'prop-types'
import React from 'react'
import { Image, StyleSheet, Text, View } from 'react-native'
import HTML from 'react-native-render-html'
import relativeTime from 'src/utils/relativeTime'
export default function TootTimeline ({ item }) {
return (
<View style={styles.tootTimeline}>
<View style={styles.header}>
<Image
source={{
uri: item.reblog ? item.reblog.account.avatar : item.account.avatar
}}
style={styles.avatar}
/>
<View>
<View style={styles.name}>
<Text>
{item.reblog
? item.reblog.account.display_name
: item.account.display_name}
</Text>
<Text>
{item.reblog ? item.reblog.account.acct : item.account.acct}
</Text>
</View>
<View>
<Text>{relativeTime(item.created_at)}</Text>
{item.application && item.application.name !== 'Web' && (
<Text onPress={() => Linking.openURL(item.application.website)}>
{item.application.name}
</Text>
)}
</View>
</View>
</View>
{item.content ? <HTML html={item.content} /> : <></>}
</View>
)
}
TootTimeline.propTypes = {
item: PropTypes.shape({
account: PropTypes.shape({
avatar: PropTypes.string.isRequired,
display_name: PropTypes.string.isRequired,
acct: PropTypes.string.isRequired
}).isRequired,
created_at: PropTypes.string.isRequired,
application: PropTypes.exact({
name: PropTypes.string.isRequired,
website: PropTypes.string
}),
content: PropTypes.string
}).isRequired
}
const styles = StyleSheet.create({
tootTimeline: {
flex: 1,
padding: 15
},
header: {
flexDirection: 'row'
},
avatar: {
width: 40,
height: 40
},
name: {
flexDirection: 'row'
}
})

View File

@@ -0,0 +1,18 @@
import React from 'react'
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
import Timeline from 'src/stacks/common/Timeline'
const MainTimelineStack = createNativeStackNavigator()
function Base () {
return <Timeline instance='social.xmflsct.com' endpoint='home' />
}
export default function MainTimeline () {
return (
<MainTimelineStack.Navigator>
<MainTimelineStack.Screen name='Base' component={Base} />
</MainTimelineStack.Navigator>
)
}

22
src/stacks/Me.jsx Normal file
View File

@@ -0,0 +1,22 @@
import React from 'react'
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
import Base from './Me/Base'
import Authentication from 'src/stacks/Me/Authentication'
const Stack = createNativeStackNavigator()
export default function Me () {
return (
<Stack.Navigator>
<Stack.Screen name='Me-Base' component={Base} />
<Stack.Screen
name='Me-Authentication'
component={Authentication}
options={{
stackPresentation: 'modal'
}}
/>
</Stack.Navigator>
)
}

View File

@@ -0,0 +1,16 @@
import React from 'react'
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
import Instance from './Authentication/Instance'
import Webview from './Authentication/Webview'
const Stack = createNativeStackNavigator()
export default function Base () {
return (
<Stack.Navigator>
<Stack.Screen name='Me-Authentication-Instance' component={Instance} />
<Stack.Screen name='Me-Authentication-Webview' component={Webview} />
</Stack.Navigator>
)
}

View File

@@ -0,0 +1,31 @@
import React, { useState } from 'react'
import { Button, TextInput, View } from 'react-native'
export default function Instance ({ navigation }) {
const [instance, onChangeInstance] = useState()
return (
<View>
<TextInput
style={{ height: 40, borderColor: 'gray', borderWidth: 1 }}
onChangeText={text => onChangeInstance(text)}
value={instance}
autoCapitalize='none'
autoCorrect={false}
clearButtonMode='unless-editing'
keyboardType='url'
textContentType='URL'
placeholder='输入服务器'
placeholderTextColor='#888888'
/>
<Button
title='登录'
onPress={() =>
navigation.navigate('Me-Authentication-Webview', {
instance: instance
})
}
/>
</View>
)
}

View File

@@ -0,0 +1,9 @@
import React from 'react'
import { Button, View } from 'react-native'
import * as AppAuth from 'expo-app-auth'
export default function Webview ({ navigation, route }) {
const { instance } = route.params
return <View></View>
}

10
src/stacks/Me/Base.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import { Button, View } from 'react-native'
export default function Base ({ navigation: { navigate } }) {
return (
<View>
<Button title='登录' onPress={() => navigate('Me-Authentication')} />
</View>
)
}

View File

@@ -0,0 +1,9 @@
import React, { useEffect } from 'react'
import { ActivityIndicator, FlatList, View } from 'react-native'
import { useSelector, useDispatch } from 'react-redux'
import TootTimeline from 'src/components/TootTimeline'
export default function Notifications () {
return <View></View>
}

View File

@@ -0,0 +1,20 @@
import React from 'react'
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
import Timeline from 'src/stacks/common/Timeline'
const PublicTimelineStack = createNativeStackNavigator()
function Base () {
return <Timeline instance='social.xmflsct.com' endpoint='public' local />
}
export default function PublicTimeline () {
return (
<PublicTimelineStack.Navigator>
<PublicTimelineStack.Screen name='Base' component={Base} />
</PublicTimelineStack.Navigator>
)
}
// store by page maybe

View File

@@ -0,0 +1,66 @@
import PropTypes from 'prop-types'
import React, { useEffect } from 'react'
import { ActivityIndicator, FlatList, View } from 'react-native'
import { useSelector, useDispatch } from 'react-redux'
import TootTimeline from 'src/components/TootTimeline'
import genericTimelineSlice from './timelineSlice'
export default function Timeline ({ instance, endpoint, local }) {
const dispatch = useDispatch()
const toots = useSelector(genericTimelineSlice(instance).getToots)
const status = useSelector(genericTimelineSlice(instance).getStatus)
useEffect(() => {
if (status === 'idle') {
dispatch(genericTimelineSlice(instance).fetch({ endpoint, local }))
}
}, [status, dispatch])
let content
if (status === 'error') {
content = <Text>Error message</Text>
} else {
content = (
<>
<FlatList
data={toots}
keyExtractor={({ id }) => id}
renderItem={TootTimeline}
onRefresh={() =>
dispatch(
genericTimelineSlice(instance).fetch({
endpoint,
local,
id: toots[0].id,
newer: true
})
)
}
refreshing={status === 'loading'}
onEndReached={() =>
dispatch(
genericTimelineSlice(instance).fetch({
endpoint,
local,
id: toots[toots.length - 1].id
})
)
}
onEndReachedThreshold={0.5}
style={{ height: '100%', width: '100%' }}
/>
{status === 'loading' && <ActivityIndicator />}
</>
)
}
return <View>{content}</View>
}
Timeline.propTypes = {
instance: PropTypes.string.isRequired,
public: PropTypes.bool
}

View File

@@ -0,0 +1,77 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { client } from 'src/api/client'
import * as localStorage from 'src/utils/localStorage'
export default function genericTimelineSlice (instance) {
const fetch = createAsyncThunk(
'timeline/fetch',
async ({ endpoint, local, id, newer }) => {
if (!instance || !endpoint) console.error('Missing instance or endpoint.')
let query = {}
if (local) query.local = 'true'
if (newer) {
query.since_id = id
} else {
if (id) {
query.max_id = id
}
}
let header
const instanceToken = await localStorage.getItem()
if (instanceToken) {
header = { headers: { Authorization: `Bearer ${instanceToken}` } }
}
return {
data: await client.get(
instance,
`timelines/${endpoint}`,
query,
header
),
newer: newer
}
}
)
const slice = createSlice({
name: instance,
initialState: {
toots: [],
status: 'idle',
error: null
},
extraReducers: {
[fetch.pending]: state => {
state.status = 'loading'
},
[fetch.fulfilled]: (state, action) => {
state.status = 'succeeded'
action.payload.newer
? state.toots.unshift(...action.payload.data)
: state.toots.push(...action.payload.data)
},
[fetch.rejected]: (state, action) => {
state.status = 'failed'
state.error = action.payload
}
}
})
getToots = state => state[instance].toots
getStatus = state => state[instance].status
return {
fetch,
slice,
getToots,
getStatus
}
}
// export const timelineSlice = genericTimelineSlice(data)
// export default timelineSlice.reducer

24
src/utils/localStorage.js Normal file
View File

@@ -0,0 +1,24 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
export async function getItem () {
try {
const value = await AsyncStorage.getItem('@social.xmflsct.com')
if (!value) {
await AsyncStorage.setItem(
'@social.xmflsct.com',
'qjzJ0IjvZ1apsn0_wBkGcdjKgX7Dao9KEPhGwggPwAo'
)
}
return value
} catch (e) {
console.error('Get token error')
}
}
export async function getAllKeys () {
try {
return await AsyncStorage.getAllKeys()
} catch (e) {
console.error('Get all keys error')
}
}

25
src/utils/relativeTime.js Normal file
View File

@@ -0,0 +1,25 @@
import PropTypes from 'prop-types'
export default function relativeTime (date) {
var units = {
year: 24 * 60 * 60 * 1000 * 365,
month: (24 * 60 * 60 * 1000 * 365) / 12,
day: 24 * 60 * 60 * 1000,
hour: 60 * 60 * 1000,
minute: 60 * 1000,
second: 1000
}
var rtf = new Intl.RelativeTimeFormat('zh', { numeric: 'auto' })
var elapsed = new Date(date) - new Date()
// "Math.abs" accounts for both "past" & "future" scenarios
for (var u in units)
if (Math.abs(elapsed) > units[u] || u == 'second')
return rtf.format(Math.round(elapsed / units[u]), u)
}
relativeTime.propTypes = {
date: PropTypes.string.isRequired
}