1
0
mirror of https://github.com/tooot-app/app synced 2025-06-05 22:19:13 +02:00
This commit is contained in:
Zhiyuan Zheng
2020-10-29 14:52:28 +01:00
parent a6e33d8b0a
commit 97e9ceca5a
23 changed files with 865 additions and 351 deletions

View File

@@ -4,7 +4,7 @@ import { StyleSheet, Text } from 'react-native'
import HTMLView from 'react-native-htmlview'
import { useNavigation } from '@react-navigation/native'
import Emojis from 'src/components/TootTimeline/Emojis'
import Emojis from 'src/components/Toot/Emojis'
function renderNode ({ node, index, navigation, mentions, showFullLink }) {
if (node.name == 'a') {
@@ -72,7 +72,8 @@ export default function ParseContent ({
emojis,
emojiSize = 14,
mentions,
showFullLink = false
showFullLink = false,
linesTruncated = 10
}) {
const navigation = useNavigation()
@@ -80,13 +81,16 @@ export default function ParseContent ({
<HTMLView
value={content}
stylesheet={HTMLstyles}
addLineBreaks={null}
paragraphBreak={null}
renderNode={(node, index) =>
renderNode({ node, index, navigation, mentions, showFullLink })
}
TextComponent={({ children }) => (
<Emojis content={children} emojis={emojis} dimension={emojiSize} />
)}
RootComponent={({ children }) => {
return <Text numberOfLines={linesTruncated}>{children}</Text>
}}
/>
)
}
@@ -115,5 +119,6 @@ ParseContent.propTypes = {
acct: PropTypes.string.isRequired
})
),
showFullLink: PropTypes.bool
showFullLink: PropTypes.bool,
linesTruncated: PropTypes.number
}

129
src/components/Toot.jsx Normal file
View File

@@ -0,0 +1,129 @@
import React, { useMemo } from 'react'
import PropTypes from 'prop-types'
import { Dimensions, Pressable, StyleSheet, View } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import Reblog from './Toot/Reblog'
import Avatar from './Toot/Avatar'
import Header from './Toot/Header'
import Content from './Toot/Content'
import Poll from './Toot/Poll'
import Media from './Toot/Media'
import Card from './Toot/Card'
import Actions from './Toot/Actions'
// Maybe break away notification types? https://docs.joinmastodon.org/entities/notification/
export default function Toot ({ item, notification }) {
const navigation = useNavigation()
let actualContent
if (notification && item.status) {
actualContent = item.status
} else if (item.reblog) {
actualContent = item.reblog
} else {
actualContent = item
}
const toot = useMemo(() => {
return (
<View style={styles.tootTimeline}>
{item.reblog && (
<Reblog
name={item.account.display_name || item.account.username}
emojis={item.account.emojis}
/>
)}
<View style={styles.toot}>
<Avatar
uri={actualContent.account.avatar}
id={actualContent.account.id}
/>
<View style={styles.details}>
<Header
name={
actualContent.account.display_name ||
actualContent.account.username
}
emojis={actualContent.account.emojis}
account={actualContent.account.acct}
created_at={item.created_at}
application={item.application}
/>
{/* Can pass toot info to next page to speed up performance */}
<Pressable
onPress={() =>
navigation.navigate('Toot', { toot: actualContent.id })
}
>
{actualContent.content ? (
<Content
content={actualContent.content}
emojis={actualContent.emojis}
mentions={actualContent.mentions}
spoiler_text={actualContent.spoiler_text}
tags={actualContent.tags}
style={{ flex: 1 }}
/>
) : (
<></>
)}
{actualContent.poll && <Poll poll={actualContent.poll} />}
{actualContent.media_attachments && (
<Media
media_attachments={actualContent.media_attachments}
sensitive={actualContent.sensitive}
width={Dimensions.get('window').width - 24 - 50 - 8}
/>
)}
{actualContent.card && <Card card={actualContent.card} />}
</Pressable>
<Actions
replies_count={actualContent.replies_count}
reblogs_count={actualContent.reblogs_count}
reblogged={actualContent.reblogged}
favourites_count={actualContent.favourites_count}
favourited={actualContent.favourited}
/>
</View>
</View>
</View>
)
})
return toot
}
const styles = StyleSheet.create({
tootTimeline: {
flex: 1,
flexDirection: 'column',
padding: 12
},
toot: {
flex: 1,
flexDirection: 'row'
},
details: {
flex: 1,
flexGrow: 1
}
})
Toot.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,
notification: PropTypes.bool
}

View File

@@ -0,0 +1,48 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { Feather } from '@expo/vector-icons'
export default function Actions ({
replies_count,
reblogs_count,
reblogged,
favourites_count,
favourited
}) {
return (
<View style={styles.actions}>
<Pressable style={styles.action}>
<Feather name='message-circle' />
<Text>{replies_count}</Text>
</Pressable>
<Pressable style={styles.action}>
<Feather name='repeat' />
<Text>{reblogs_count}</Text>
</Pressable>
<Pressable style={styles.action}>
<Feather name='heart' />
<Text>{favourites_count}</Text>
</Pressable>
<Pressable style={styles.action}>
<Feather name='share' />
</Pressable>
</View>
)
}
const styles = StyleSheet.create({
actions: {
flex: 1,
flexDirection: 'row'
},
action: {
width: '25%',
flexDirection: 'row',
justifyContent: 'center'
}
})
// Actions.propTypes = {
// uri: PropTypes.string
// }

View File

@@ -0,0 +1,73 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Image, Pressable, StyleSheet, Text, View } from 'react-native'
import { useNavigation } from '@react-navigation/native'
export default function Card ({ card }) {
const navigation = useNavigation()
return (
card && (
<Pressable
style={styles.card}
onPress={() => {
navigation.navigate('Webview', {
uri: card.url
})
}}
>
{card.image && (
<View style={styles.left}>
<Image source={{ uri: card.image }} style={styles.image} />
</View>
)}
<View style={styles.right}>
<Text numberOfLines={1}>{card.title}</Text>
{card.description ? (
<Text numberOfLines={2}>{card.description}</Text>
) : (
<></>
)}
<Text numberOfLines={1}>{card.url}</Text>
</View>
</Pressable>
)
)
}
const styles = StyleSheet.create({
card: {
flex: 1,
flexDirection: 'row',
height: 70,
marginTop: 12
},
left: {
width: 70
},
image: {
width: '100%',
height: '100%'
},
right: {
flex: 1
}
})
Card.propTypes = {
card: PropTypes.exact({
url: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
description: PropTypes.string,
type: PropTypes.oneOf(['link', 'photo', 'video']),
author_name: PropTypes.string,
author_url: PropTypes.string,
provider_name: PropTypes.string,
provider_url: PropTypes.string,
html: PropTypes.string,
width: PropTypes.number,
height: PropTypes.number,
image: PropTypes.string,
embed_url: PropTypes.string,
blurhash: PropTypes.string
}).isRequired
}

View File

@@ -0,0 +1,48 @@
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import { Text } from 'react-native'
import Collapsible from 'react-native-collapsible'
import ParseContent from 'src/components/ParseContent'
export default function Content ({ content, emojis, mentions, spoiler_text }) {
const [spoilerCollapsed, setSpoilerCollapsed] = useState(true)
return (
<>
{content &&
(spoiler_text ? (
<>
<Text>
{spoiler_text}{' '}
<Text onPress={() => setSpoilerCollapsed(!spoilerCollapsed)}>
点击展开
</Text>
</Text>
<Collapsible collapsed={spoilerCollapsed}>
<ParseContent
content={content}
emojis={emojis}
emojiSize={14}
mentions={mentions}
/>
</Collapsible>
</>
) : (
<ParseContent
content={content}
emojis={emojis}
emojiSize={14}
mentions={mentions}
/>
))}
</>
)
}
Content.propTypes = {
content: ParseContent.propTypes.content,
emojis: ParseContent.propTypes.emojis,
mentions: ParseContent.propTypes.mentions,
spoiler_text: PropTypes.string
}

View File

@@ -2,7 +2,7 @@ import React from 'react'
import PropTypes from 'prop-types'
import { Image, Text } from 'react-native'
const regexEmoji = new RegExp(/(:.*?:)/g)
const regexEmoji = new RegExp(/(:[a-z0-9_]+:)/)
export default function Emojis ({ content, emojis, dimension }) {
const hasEmojis = content.match(regexEmoji)
@@ -13,7 +13,11 @@ export default function Emojis ({ content, emojis, dimension }) {
const emojiIndex = emojis.findIndex(emoji => {
return emojiShortcode === `:${emoji.shortcode}:`
})
return (
return emojiIndex === -1 ? (
<Text key={i} style={{ color: 'red' }}>
Something wrong with emoji!
</Text>
) : (
<Image
key={i}
source={{ uri: emojis[emojiIndex].url }}

View File

@@ -27,7 +27,9 @@ export default function Header ({
<View style={styles.name}>
<Emojis content={name} emojis={emojis} dimension={14} />
</View>
<Text style={styles.account}>@{account}</Text>
<Text style={styles.account} numberOfLines={1}>
@{account}
</Text>
</View>
<View style={styles.meta}>
<View>

View File

@@ -9,12 +9,9 @@ import {
Pressable,
View
} from 'react-native'
import Collapsible from 'react-native-collapsible'
import ImageViewer from 'react-native-image-zoom-viewer'
import ParseContent from 'src/components/ParseContent'
function Media ({ media_attachments, sensitive, width }) {
export default function Media ({ media_attachments, sensitive, width }) {
const [mediaSensitive, setMediaSensitive] = useState(sensitive)
const [imageModalVisible, setImageModalVisible] = useState(false)
const [imageModalIndex, setImageModalIndex] = useState(0)
@@ -26,6 +23,7 @@ function Media ({ media_attachments, sensitive, width }) {
}
}, [mediaSensitive])
let media
let images = []
if (width) {
media_attachments = media_attachments.map((m, i) => {
@@ -41,7 +39,7 @@ function Media ({ media_attachments, sensitive, width }) {
return (
<Pressable
key={i}
style={{ flexGrow: 1, height: width / 5, margin: 4 }}
style={{ flexGrow: 1, height: width / 2, margin: 4 }}
onPress={() => {
setImageModalIndex(i)
setImageModalVisible(true)
@@ -57,7 +55,7 @@ function Media ({ media_attachments, sensitive, width }) {
}
})
if (images) {
return (
media = (
<>
<View style={styles.media}>
{media_attachments}
@@ -95,69 +93,25 @@ function Media ({ media_attachments, sensitive, width }) {
</>
)
} else {
return <View style={styles.media}>{media_attachments}</View>
media = <View style={styles.media}>{media_attachments}</View>
}
} else {
return <></>
media = <></>
}
}
export default function Content ({
content,
emojis,
media_attachments,
mentions,
sensitive,
spoiler_text,
width
}) {
const [spoilerCollapsed, setSpoilerCollapsed] = useState(true)
return (
<>
{content &&
(spoiler_text ? (
<>
<Text>
{spoiler_text}{' '}
<Text onPress={() => setSpoilerCollapsed(!spoilerCollapsed)}>
点击展开
</Text>
</Text>
<Collapsible collapsed={spoilerCollapsed}>
<ParseContent
content={content}
emojis={emojis}
emojiSize={14}
mentions={mentions}
/>
</Collapsible>
</>
) : (
<ParseContent
content={content}
emojis={emojis}
emojiSize={14}
mentions={mentions}
/>
))}
{media_attachments.length > 0 && (
<View
style={{
width: width + 8,
height: width / 2,
marginTop: 4,
marginLeft: -4
}}
>
<Media
media_attachments={media_attachments}
sensitive={sensitive}
width={width}
/>
</View>
)}
</>
media_attachments.length > 0 && (
<View
style={{
width: width + 8,
height: width / 2,
marginTop: 4,
marginLeft: -4
}}
>
{media}
</View>
)
)
}
@@ -176,12 +130,8 @@ const styles = StyleSheet.create({
}
})
Content.propTypes = {
content: ParseContent.propTypes.content,
emojis: ParseContent.propTypes.emojis,
Media.propTypes = {
// media_attachments
mentions: ParseContent.propTypes.mentions,
sensitive: PropTypes.bool.isRequired,
spoiler_text: PropTypes.string,
width: PropTypes.number.isRequired
}

View File

@@ -0,0 +1,63 @@
import React from 'react'
import PropTypes from 'prop-types'
import { StyleSheet, Text, View } from 'react-native'
import Emojis from './Emojis'
export default function Poll ({ poll }) {
return (
<View>
{poll.options.map((option, index) => (
<View key={index}>
<View style={{ flexDirection: 'row' }}>
<Text>
{Math.round((option.votes_count / poll.votes_count) * 100)}%
</Text>
<Text>{option.title}</Text>
</View>
<View
style={{
width: `${Math.round(
(option.votes_count / poll.votes_count) * 100
)}%`,
height: 5,
backgroundColor: 'blue'
}}
/>
</View>
))}
</View>
)
}
const styles = StyleSheet.create({
avatar: {
width: 50,
height: 50,
marginRight: 8
},
image: {
width: '100%',
height: '100%'
}
})
Poll.propTypes = {
poll: PropTypes.exact({
id: PropTypes.string.isRequired,
expires_at: PropTypes.string.isRequired,
expired: PropTypes.bool.isRequired,
multiple: PropTypes.bool.isRequired,
votes_count: PropTypes.number,
voters_count: PropTypes.number,
voted: PropTypes.bool.isRequired,
own_votes: PropTypes.array,
options: PropTypes.arrayOf(
PropTypes.exact({
title: PropTypes.string.isRequired,
votes_count: PropTypes.number.isRequired
})
),
emojis: Emojis.propTypes.emojis
}).isRequired
}

View File

@@ -1,104 +0,0 @@
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import { Dimensions, StyleSheet, View } from 'react-native'
import Reblog from './TootTimeline/Reblog'
import Avatar from './TootTimeline/Avatar'
import Header from './TootTimeline/Header'
import Content from './TootTimeline/Content'
import Actions from './TootTimeline/Actions'
// Maybe break away notification types? https://docs.joinmastodon.org/entities/notification/
export default function TootTimeline ({ item, notification }) {
let contentAggregated = {}
let actualContent
if (notification && item.status) {
actualContent = item.status
} else if (item.reblog) {
actualContent = item.reblog
} else {
actualContent = item
}
contentAggregated = {
content: actualContent.content,
emojis: actualContent.emojis,
media_attachments: actualContent.media_attachments,
mentions: actualContent.mentions,
sensitive: actualContent.sensitive,
spoiler_text: actualContent.spoiler_text,
tags: actualContent.tags
}
return (
<View style={styles.tootTimeline}>
{item.reblog && (
<Reblog
name={item.account.display_name || item.account.username}
emojis={item.account.emojis}
/>
)}
<View style={styles.toot}>
<Avatar
uri={item.reblog?.account.avatar || item.account.avatar}
id={item.reblog?.account.id || item.account.id}
/>
<View style={styles.details}>
<Header
name={
(item.reblog?.account.display_name
? item.reblog?.account.display_name
: item.reblog?.account.username) ||
(item.account.display_name
? item.account.display_name
: item.account.username)
}
emojis={item.reblog?.account.emojis || item.account.emojis}
account={item.reblog?.account.acct || item.account.acct}
created_at={item.created_at}
application={item.application || null}
/>
<Content
{...contentAggregated}
style={{ flex: 1 }}
width={Dimensions.get('window').width - 24 - 50 - 8}
/>
</View>
</View>
<Actions />
</View>
)
}
const styles = StyleSheet.create({
tootTimeline: {
flex: 1,
flexDirection: 'column',
padding: 12
},
toot: {
flex: 1,
flexDirection: 'row'
},
details: {
flex: 1,
flexGrow: 1
}
})
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,
notification: PropTypes.bool
}

View File

@@ -1,16 +0,0 @@
import React from 'react'
import PropTypes from 'prop-types'
import { StyleSheet } from 'react-native'
export default function Actions () {
return <></>
}
const styles = StyleSheet.create({
width: 50,
height: 50
})
// Actions.propTypes = {
// uri: PropTypes.string
// }

View File

@@ -1,21 +1,24 @@
import React, { useEffect, useState } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import {
Dimensions,
FlatList,
Image,
ScrollView,
StyleSheet,
Text,
View
} from 'react-native'
import HTMLView from 'react-native-htmlview'
import SegmentedControl from '@react-native-community/segmented-control'
import { useDispatch, useSelector } from 'react-redux'
import { useFocusEffect } from '@react-navigation/native'
import { Feather } from '@expo/vector-icons'
import * as accountSlice from 'src/stacks/common/accountSlice'
import * as relationshipsSlice from 'src/stacks/common/relationshipsSlice'
import * as timelineSlice from 'src/stacks/common/timelineSlice'
import ParseContent from 'src/components/ParseContent'
import Timeline from 'src/stacks/common/Timeline'
// Moved account example: https://m.cmx.im/web/accounts/27812
@@ -65,10 +68,7 @@ function Information ({ account, emojis }) {
</Text>
</View>
))}
<Text>
<ParseContent content={account.note} emojis={emojis} />
</Text>
{account.note && <ParseContent content={account.note} emojis={emojis} />}
<Text>
加入时间{' '}
{new Date(account.created_at).toLocaleDateString('zh-CN', {
@@ -85,13 +85,89 @@ function Information ({ account, emojis }) {
)
}
function Toots ({ account }) {
const [segment, setSegment] = useState(0)
const [segmentManuallyTriggered, setSegmentManuallyTriggered] = useState(
false
)
const horizontalPaging = useRef()
const pages = ['Account_Default', 'Account_All', 'Account_Media']
return (
<>
<SegmentedControl
values={['嘟嘟', '嘟嘟和回复', '媒体']}
selectedIndex={segment}
onChange={({ nativeEvent }) => {
setSegmentManuallyTriggered(true)
setSegment(nativeEvent.selectedSegmentIndex)
horizontalPaging.current.scrollToIndex({
index: nativeEvent.selectedSegmentIndex
})
}}
style={{ width: '100%', height: 30 }}
/>
<FlatList
style={{ width: Dimensions.get('window').width, height: '100%' }}
data={pages}
keyExtractor={page => page}
renderItem={({ item, index }) => {
return (
<View style={{ width: Dimensions.get('window').width }}>
<Timeline
key={index}
page={item}
account={account}
disableRefresh
/>
</View>
)
}}
ref={horizontalPaging}
bounces={false}
getItemLayout={(data, index) => ({
length: Dimensions.get('window').width,
offset: Dimensions.get('window').width * index,
index
})}
horizontal
ListHeaderComponent={
<View
style={{
width: Dimensions.get('window').width,
height: 100,
position: 'absolute'
}}
>
<Text>Test</Text>
</View>
}
onMomentumScrollEnd={() => {
setSegmentManuallyTriggered(false)
}}
onScroll={({ nativeEvent }) =>
!segmentManuallyTriggered &&
setSegment(
nativeEvent.contentOffset.x <= Dimensions.get('window').width / 3
? 0
: 1
)
}
pagingEnabled
showsHorizontalScrollIndicator={false}
/>
</>
)
}
export default function Account ({
route: {
params: { id }
}
}) {
const dispatch = useDispatch()
const accountState = useSelector(accountSlice.retrive)
const accountState = useSelector(state => state.account)
// const stateRelationships = useSelector(relationshipsState)
const [loaded, setLoaded] = useState(false)
const [headerImageSize, setHeaderImageSize] = useState()
@@ -100,7 +176,6 @@ export default function Account ({
if (accountState.status === 'idle') {
dispatch(accountSlice.fetch({ id }))
}
if (accountState.account.header) {
Image.getSize(accountState.account.header, (width, height) => {
setHeaderImageSize({ width, height })
@@ -117,12 +192,15 @@ export default function Account ({
return () => {
dispatch(accountSlice.reset())
dispatch(timelineSlice.reset('Account_Default'))
dispatch(timelineSlice.reset('Account_All'))
dispatch(timelineSlice.reset('Account_Media'))
}
}, [])
)
// add emoji support
return loaded ? (
<ScrollView>
<View>
<Header
uri={accountState.account.header}
size={
@@ -134,9 +212,10 @@ export default function Account ({
/>
<Information
account={accountState.account}
emojis={accountState.emojis}
emojis={accountState.account.emojis}
/>
</ScrollView>
{accountState.account.id && <Toots account={accountState.account.id} />}
</View>
) : (
<></>
)

View File

@@ -0,0 +1,28 @@
import React from 'react'
import { useDispatch } from 'react-redux'
import { useFocusEffect } from '@react-navigation/native'
import Timeline from 'src/stacks/common/Timeline'
import { reset } from 'src/stacks/common/timelineSlice'
// Show remote hashtag? Only when private, show local version?
export default function Toot ({
route: {
params: { toot }
}
}) {
const dispatch = useDispatch()
useFocusEffect(
React.useCallback(() => {
// Do something when the screen is focused
return () => {
dispatch(reset('Toot'))
}
}, [])
)
return <Timeline page='Toot' toot={toot} disableRefresh />
}

View File

@@ -2,7 +2,7 @@ import React from 'react'
import PropTypes from 'prop-types'
import { WebView } from 'react-native-webview'
// Webview preview card
// Update page title
export default function Webview ({
route: {

View File

@@ -1,63 +1,73 @@
import React, { useEffect, useState } from 'react'
import PropTypes from 'prop-types'
import { ActivityIndicator, FlatList, View } from 'react-native'
import { ActivityIndicator, FlatList, Text, View } from 'react-native'
import { useSelector, useDispatch } from 'react-redux'
import TootTimeline from 'src/components/TootTimeline'
import { fetch, getState } from './timelineSlice'
import Toot from 'src/components/Toot'
import { fetch } from './timelineSlice'
// Opening nesting hashtag pages
export default function Timeline ({ page, hashtag, list }) {
export default function Timeline ({
page,
hashtag,
list,
toot,
account,
disableRefresh
}) {
const dispatch = useDispatch()
const state = useSelector(state => getState(state)(page))
const state = useSelector(state => state.timelines[page])
const [timelineReady, setTimelineReady] = useState(false)
useEffect(() => {
if (state.status === 'idle') {
dispatch(fetch({ page, hashtag, list }))
dispatch(fetch({ page, hashtag, list, toot, account }))
setTimelineReady(true)
}
}, [state, dispatch])
let content
if (state.status === 'error') {
if (state.status === 'failed') {
content = <Text>Error message</Text>
} else {
content = (
<>
<FlatList
style={{ minHeight: '100%' }}
data={state.toots}
keyExtractor={({ id }) => id}
renderItem={({ item, index, separators }) => (
<TootTimeline key={item.key} item={item} />
<Toot key={item.key} item={item} />
)}
onRefresh={() =>
dispatch(
fetch({
page,
query: [{ key: 'since_id', value: state.toots[0].id }]
})
)
}
refreshing={state.status === 'loading'}
onEndReached={() => {
if (!timelineReady) {
{...(state.pointer && { initialScrollIndex: state.pointer })}
{...(!disableRefresh && {
onRefresh: () =>
dispatch(
fetch({
page,
query: [
{
key: 'max_id',
value: state.toots[state.toots.length - 1].id
}
]
query: [{ key: 'since_id', value: state.toots[0].id }]
})
)
setTimelineReady(true)
}
}}
onEndReachedThreshold={0.5}
),
refreshing: state.status === 'loading',
onEndReached: () => {
if (!timelineReady) {
dispatch(
fetch({
page,
query: [
{
key: 'max_id',
value: state.toots[state.toots.length - 1].id
}
]
})
)
setTimelineReady(true)
}
},
onEndReachedThreshold: 0.5
})}
onMomentumScrollBegin={() => setTimelineReady(false)}
/>
{state.status === 'loading' && <ActivityIndicator />}
@@ -71,5 +81,7 @@ export default function Timeline ({ page, hashtag, list }) {
Timeline.propTypes = {
page: PropTypes.string.isRequired,
hashtag: PropTypes.string,
list: PropTypes.string
list: PropTypes.string,
toot: PropTypes.string,
disableRefresh: PropTypes.bool
}

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'
import PropTypes from 'prop-types'
import { Animated, Dimensions, Text, View } from 'react-native'
import { Dimensions, FlatList, View } from 'react-native'
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
import SegmentedControl from '@react-native-community/segmented-control'
import { Feather } from '@expo/vector-icons'
@@ -8,20 +8,32 @@ import { Feather } from '@expo/vector-icons'
import Timeline from './Timeline'
import Account from 'src/stacks/Shared/Account'
import Hashtag from 'src/stacks/Shared/Hashtag'
import Toot from 'src/stacks/Shared/Toot'
import Webview from 'src/stacks/Shared/Webview'
const Stack = createNativeStackNavigator()
function Page ({ item: { page } }) {
return (
<View style={{ width: Dimensions.get('window').width }}>
<Timeline page={page} />
</View>
)
}
export default function TimelinesCombined ({ name, content }) {
const [segment, setSegment] = useState(0)
const [renderHeader, setRenderHeader] = useState(false)
const [segmentManuallyTriggered, setSegmentManuallyTriggered] = useState(
false
)
useEffect(() => {
const nbr = setTimeout(() => setRenderHeader(true), 50)
return
}, [])
const moveAnimation = useRef(new Animated.Value(0)).current
const horizontalPaging = useRef()
return (
<Stack.Navigator>
@@ -38,37 +50,47 @@ export default function TimelinesCombined ({ name, content }) {
<SegmentedControl
values={[content[0].title, content[1].title]}
selectedIndex={segment}
onChange={e => {
setSegment(e.nativeEvent.selectedSegmentIndex)
Animated.timing(moveAnimation, {
toValue:
-e.nativeEvent.selectedSegmentIndex *
Dimensions.get('window').width,
duration: 250,
useNativeDriver: false
}).start()
onChange={({ nativeEvent }) => {
setSegmentManuallyTriggered(true)
setSegment(nativeEvent.selectedSegmentIndex)
horizontalPaging.current.scrollToIndex({
index: nativeEvent.selectedSegmentIndex
})
}}
style={{ width: 150, height: 30 }}
/>
) : null
}}
>
{props => (
<Animated.View
style={{
flexDirection: 'row',
width: Dimensions.get('window').width * 2,
left: moveAnimation
{() => (
<FlatList
style={{ width: Dimensions.get('window').width, height: '100%' }}
data={content}
keyExtractor={({ page }) => page}
renderItem={({ item, index }) => {
return <Page key={index} item={item} />
}}
{...props}
>
<View style={{ width: Dimensions.get('window').width }}>
<Timeline page={content[0].page} />
</View>
<View style={{ width: Dimensions.get('window').width }}>
<Timeline page={content[1].page} />
</View>
</Animated.View>
ref={horizontalPaging}
bounces={false}
getItemLayout={(data, index) => ({
length: Dimensions.get('window').width,
offset: Dimensions.get('window').width * index,
index
})}
horizontal
onMomentumScrollEnd={() => setSegmentManuallyTriggered(false)}
onScroll={({ nativeEvent }) =>
!segmentManuallyTriggered &&
setSegment(
nativeEvent.contentOffset.x <=
Dimensions.get('window').width / 2
? 0
: 1
)
}
pagingEnabled
showsHorizontalScrollIndicator={false}
/>
)}
</Stack.Screen>
<Stack.Screen
@@ -87,12 +109,19 @@ export default function TimelinesCombined ({ name, content }) {
title: `#${decodeURIComponent(route.params.hashtag)}`
})}
/>
<Stack.Screen
name='Toot'
component={Toot}
options={() => ({
title: '对话'
})}
/>
<Stack.Screen
name='Webview'
component={Webview}
options={({ route }) => ({
title: `${route.params.domain}`
})}
// options={({ route }) => ({
// title: `${route.params.domain}`
// })}
/>
</Stack.Navigator>
)

View File

@@ -16,8 +16,6 @@ const accountInitState = {
status: 'idle'
}
export const retrive = state => state.account
export const accountSlice = createSlice({
name: 'account',
initialState: accountInitState,

View File

@@ -30,8 +30,6 @@ const relationshipsInitState = {
status: 'idle'
}
export const retrive = state => state.relationships
export const relationshipSlice = createSlice({
name: 'relationships',
initialState: {

View File

@@ -13,9 +13,10 @@ import { client } from 'src/api/client'
export const fetch = createAsyncThunk(
'timeline/fetch',
async ({ page, query = [], hashtag, list }, { getState }) => {
async ({ page, query = [], account, hashtag, list, toot }, { getState }) => {
const instanceLocal = getState().instanceInfo.local + '/api/v1/'
const instanceRemote = getState().instanceInfo.remote + '/api/v1/'
// If header if needed for remote server
const header = {
headers: {
Authorization: `Bearer ${getState().instanceInfo.localToken}`
@@ -24,36 +25,103 @@ export const fetch = createAsyncThunk(
switch (page) {
case 'Following':
return await client.get(`${instanceLocal}timelines/home`, query, header)
return {
toots: await client.get(
`${instanceLocal}timelines/home`,
query,
header
)
}
case 'Local':
query.push({ key: 'local', value: 'true' })
return await client.get(
`${instanceLocal}timelines/public`,
query,
header
)
return {
toots: await client.get(
`${instanceLocal}timelines/public`,
query,
header
)
}
case 'LocalPublic':
return await client.get(
`${instanceLocal}timelines/public`,
query,
header
)
return {
toots: await client.get(
`${instanceLocal}timelines/public`,
query,
header
)
}
case 'RemotePublic':
return await client.get(`${instanceRemote}timelines/public`, query)
return {
toots: await client.get(`${instanceRemote}timelines/public`, query)
}
case 'Notifications':
return await client.get(`${instanceLocal}notifications`, query, header)
return {
toots: await client.get(
`${instanceLocal}notifications`,
query,
header
)
}
case 'Account_Default':
const toots = await client.get(
`${instanceLocal}accounts/${account}/statuses`,
[{ key: 'pinned', value: 'true' }],
header
)
toots.push(
...(await client.get(
`${instanceLocal}accounts/${account}/statuses`,
[{ key: 'exclude_replies', value: 'true' }],
header
))
)
return { toots: toots }
case 'Account_All':
return {
toots: await client.get(
`${instanceLocal}accounts/${account}/statuses`,
query,
header
)
}
case 'Account_Media':
return {
toots: await client.get(
`${instanceLocal}accounts/${account}/statuses`,
[{ key: 'only_media', value: 'true' }],
header
)
}
case 'Hashtag':
return await client.get(
`${instanceLocal}timelines/tag/${hashtag}`,
query,
header
)
return {
toots: await client.get(
`${instanceLocal}timelines/tag/${hashtag}`,
query,
header
)
}
case 'List':
return await client.get(
`${instanceLocal}timelines/list/${list}`,
query,
return {
toots: await client.get(
`${instanceLocal}timelines/list/${list}`,
query,
header
)
}
case 'Toot':
const current = await client.get(
`${instanceLocal}statuses/${toot}`,
[],
header
)
const context = await client.get(
`${instanceLocal}statuses/${toot}/context`,
[],
header
)
return {
toots: [...context.ancestors, current, ...context.descendants],
pointer: context.ancestors.length
}
default:
console.error('Timeline type error')
}
@@ -62,11 +130,10 @@ export const fetch = createAsyncThunk(
const timelineInitState = {
toots: [],
pointer: undefined,
status: 'idle'
}
export const getState = state => page => state.timelines[page]
export const timelineSlice = createSlice({
name: 'timeline',
initialState: {
@@ -76,7 +143,11 @@ export const timelineSlice = createSlice({
RemotePublic: timelineInitState,
Notifications: timelineInitState,
Hashtag: timelineInitState,
List: timelineInitState
List: timelineInitState,
Toot: timelineInitState,
Account_Default: timelineInitState,
Account_All: timelineInitState,
Account_Media: timelineInitState
},
reducers: {
reset (state, action) {
@@ -89,11 +160,15 @@ export const timelineSlice = createSlice({
},
[fetch.fulfilled]: (state, action) => {
state[action.meta.arg.page].status = 'succeeded'
if (action.meta.arg.query && action.meta.arg.query.since_id) {
state[action.meta.arg.page].toots.unshift(...action.payload)
if (
action.meta.arg.query &&
action.meta.arg.query[0].key === 'since_id'
) {
state[action.meta.arg.page].toots.unshift(...action.payload.toots)
} else {
state[action.meta.arg.page].toots.push(...action.payload)
state[action.meta.arg.page].toots.push(...action.payload.toots)
}
state[action.meta.arg.page].pointer = action.payload.pointer
},
[fetch.rejected]: (state, action) => {
state[action.meta.arg.page].status = 'failed'