mirror of
https://github.com/tooot-app/app
synced 2025-06-05 22:19:13 +02:00
Second commit
This commit is contained in:
35
src/Main.jsx
Normal file
35
src/Main.jsx
Normal 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
53
src/api/client.js
Normal 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
16
src/app/store.js
Normal 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
|
||||
})
|
||||
})
|
75
src/components/TootTimeline.jsx
Normal file
75
src/components/TootTimeline.jsx
Normal 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'
|
||||
}
|
||||
})
|
18
src/stacks/MainTimeline.jsx
Normal file
18
src/stacks/MainTimeline.jsx
Normal 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
22
src/stacks/Me.jsx
Normal 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>
|
||||
)
|
||||
}
|
16
src/stacks/Me/Authentication.jsx
Normal file
16
src/stacks/Me/Authentication.jsx
Normal 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>
|
||||
)
|
||||
}
|
31
src/stacks/Me/Authentication/Instance.jsx
Normal file
31
src/stacks/Me/Authentication/Instance.jsx
Normal 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>
|
||||
)
|
||||
}
|
9
src/stacks/Me/Authentication/Webview.jsx
Normal file
9
src/stacks/Me/Authentication/Webview.jsx
Normal 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
10
src/stacks/Me/Base.jsx
Normal 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>
|
||||
)
|
||||
}
|
9
src/stacks/Notifications.jsx
Normal file
9
src/stacks/Notifications.jsx
Normal 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>
|
||||
}
|
20
src/stacks/PublicTimeline.jsx
Normal file
20
src/stacks/PublicTimeline.jsx
Normal 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
|
66
src/stacks/common/Timeline.jsx
Normal file
66
src/stacks/common/Timeline.jsx
Normal 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
|
||||
}
|
77
src/stacks/common/timelineSlice.js
Normal file
77
src/stacks/common/timelineSlice.js
Normal 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
24
src/utils/localStorage.js
Normal 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
25
src/utils/relativeTime.js
Normal 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
|
||||
}
|
Reference in New Issue
Block a user