From a6e33d8b0ab30e9987fc2e7d5c191a766527a86e Mon Sep 17 00:00:00 2001 From: Zhiyuan Zheng Date: Wed, 28 Oct 2020 00:02:37 +0100 Subject: [PATCH] Basic account page works --- package-lock.json | 8 ++ package.json | 1 + src/api/client.js | 4 +- src/components/ParseContent.jsx | 119 ++++++++++++++++ src/components/TootTimeline.jsx | 20 +-- src/components/TootTimeline/Avatar.jsx | 26 +++- src/components/TootTimeline/Content.jsx | 181 +++++++++--------------- src/components/TootTimeline/Emojis.jsx | 7 +- src/components/TootTimeline/Header.jsx | 3 +- src/stacks/Shared/Account.jsx | 155 +++++++++++++++++--- src/stacks/common/Timeline.jsx | 16 ++- src/stacks/common/TimelinesCombined.jsx | 8 +- src/stacks/common/accountSlice.js | 11 +- src/stacks/common/interfaceSlice.js | 0 src/stacks/common/relationshipsSlice.js | 60 ++++++++ src/stacks/common/store.js | 4 +- src/stacks/common/timelineSlice.js | 4 +- 17 files changed, 458 insertions(+), 169 deletions(-) create mode 100644 src/components/ParseContent.jsx create mode 100644 src/stacks/common/interfaceSlice.js create mode 100644 src/stacks/common/relationshipsSlice.js diff --git a/package-lock.json b/package-lock.json index 5b9c183a..cac3aaba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6570,6 +6570,14 @@ } } }, + "react-native-collapsible": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/react-native-collapsible/-/react-native-collapsible-1.5.3.tgz", + "integrity": "sha512-Ciuy3yF8wcHeLxXFtx9vxoyrq8l+9EJqLM/ADw54gVwzTQc1/e2ojhQbVFPlxhDA+Pba2qezkaKfv5ifxg8hoQ==", + "requires": { + "prop-types": "^15.6.2" + } + }, "react-native-gesture-handler": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-1.7.0.tgz", diff --git a/package.json b/package.json index 748c0857..66acbea5 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "react": "16.13.1", "react-dom": "16.13.1", "react-native": "https://github.com/expo/react-native/archive/sdk-39.0.3.tar.gz", + "react-native-collapsible": "^1.5.3", "react-native-gesture-handler": "~1.7.0", "react-native-htmlview": "^0.16.0", "react-native-image-zoom-viewer": "^3.0.1", diff --git a/src/api/client.js b/src/api/client.js index fb501a3f..0e0d3723 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -14,9 +14,7 @@ export async function client (url, query, { body, ...customConfig } = {}) { } const queryString = query - ? `?${Object.keys(query) - .map(key => `${key}=${query[key]}`) - .join('&')}` + ? `?${query.map(({ key, value }) => `${key}=${value}`).join('&')}` : '' if (body) { diff --git a/src/components/ParseContent.jsx b/src/components/ParseContent.jsx new file mode 100644 index 00000000..0e3f6dbc --- /dev/null +++ b/src/components/ParseContent.jsx @@ -0,0 +1,119 @@ +import React from 'react' +import PropTypes from 'prop-types' +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' + +function renderNode ({ node, index, navigation, mentions, showFullLink }) { + if (node.name == 'a') { + const classes = node.attribs.class + const href = node.attribs.href + if (classes) { + if (classes.includes('hashtag')) { + return ( + { + const tag = href.split(new RegExp(/\/tag\/(.*)|\/tags\/(.*)/)) + navigation.navigate('Hashtag', { + hashtag: tag[1] || tag[2] + }) + }} + > + {node.children[0].data} + {node.children[1]?.children[0].data} + + ) + } else if (classes.includes('mention')) { + return ( + { + const username = href.split(new RegExp(/@(.*)/)) + const usernameIndex = mentions.findIndex( + m => m.username === username[1] + ) + navigation.navigate('Account', { + id: mentions[usernameIndex].id + }) + }} + > + {node.children[0].data} + {node.children[1]?.children[0].data} + + ) + } + } else { + const domain = href.split(new RegExp(/:\/\/(.*?)\//)) + return ( + { + navigation.navigate('Webview', { + uri: href, + domain: domain[1] + }) + }} + > + {showFullLink ? href : domain[1]} + + ) + } + } +} + +export default function ParseContent ({ + content, + emojis, + emojiSize = 14, + mentions, + showFullLink = false +}) { + const navigation = useNavigation() + + return ( + + renderNode({ node, index, navigation, mentions, showFullLink }) + } + TextComponent={({ children }) => ( + + )} + /> + ) +} + +const styles = StyleSheet.create({ + a: { + color: 'blue' + } +}) + +const HTMLstyles = StyleSheet.create({ + p: { + marginBottom: 12 + } +}) + +ParseContent.propTypes = { + content: PropTypes.string.isRequired, + emojis: Emojis.propTypes.emojis, + emojiSize: PropTypes.number, + mentions: PropTypes.arrayOf( + PropTypes.exact({ + id: PropTypes.string.isRequired, + username: PropTypes.string.isRequired, + url: PropTypes.string.isRequired, + acct: PropTypes.string.isRequired + }) + ), + showFullLink: PropTypes.bool +} diff --git a/src/components/TootTimeline.jsx b/src/components/TootTimeline.jsx index 7ed61a67..730ca875 100644 --- a/src/components/TootTimeline.jsx +++ b/src/components/TootTimeline.jsx @@ -1,6 +1,6 @@ import React, { useState } from 'react' import PropTypes from 'prop-types' -import { StyleSheet, View } from 'react-native' +import { Dimensions, StyleSheet, View } from 'react-native' import Reblog from './TootTimeline/Reblog' import Avatar from './TootTimeline/Avatar' @@ -11,8 +11,6 @@ import Actions from './TootTimeline/Actions' // Maybe break away notification types? https://docs.joinmastodon.org/entities/notification/ export default function TootTimeline ({ item, notification }) { - const [viewWidth, setViewWidth] = useState() - let contentAggregated = {} let actualContent if (notification && item.status) { @@ -41,11 +39,11 @@ export default function TootTimeline ({ item, notification }) { /> )} - - setViewWidth(e.nativeEvent.layout.width)} - > + +
- + diff --git a/src/components/TootTimeline/Avatar.jsx b/src/components/TootTimeline/Avatar.jsx index 78c6a87a..dc3b95ea 100644 --- a/src/components/TootTimeline/Avatar.jsx +++ b/src/components/TootTimeline/Avatar.jsx @@ -1,9 +1,22 @@ import React from 'react' import PropTypes from 'prop-types' -import { Image, StyleSheet, View } from 'react-native' +import { Image, Pressable, StyleSheet } from 'react-native' +import { useNavigation } from '@react-navigation/native' -export default function Avatar ({ uri }) { - return +export default function Avatar ({ uri, id }) { + const navigation = useNavigation() + return ( + { + navigation.navigate('Account', { + id: id + }) + }} + > + + + ) } const styles = StyleSheet.create({ @@ -11,9 +24,14 @@ const styles = StyleSheet.create({ width: 50, height: 50, marginRight: 8 + }, + image: { + width: '100%', + height: '100%' } }) Avatar.propTypes = { - uri: PropTypes.string.isRequired + uri: PropTypes.string.isRequired, + id: PropTypes.string.isRequired } diff --git a/src/components/TootTimeline/Content.jsx b/src/components/TootTimeline/Content.jsx index 5fcd833c..709a360e 100644 --- a/src/components/TootTimeline/Content.jsx +++ b/src/components/TootTimeline/Content.jsx @@ -6,75 +6,13 @@ import { Modal, StyleSheet, Text, - TouchableHighlight, + Pressable, View } from 'react-native' -import HTMLView from 'react-native-htmlview' +import Collapsible from 'react-native-collapsible' import ImageViewer from 'react-native-image-zoom-viewer' -import { useNavigation } from '@react-navigation/native' -import Emojis from './Emojis' - -function renderNode (navigation, node, index, mentions) { - if (node.name == 'a') { - const classes = node.attribs.class - const href = node.attribs.href - if (classes) { - if (classes.includes('hashtag')) { - return ( - { - const tag = href.split(new RegExp(/\/tag\/(.*)|\/tags\/(.*)/)) - navigation.navigate('Hashtag', { - hashtag: tag[1] || tag[2] - }) - }} - > - {node.children[0].data} - {node.children[1]?.children[0].data} - - ) - } else if (classes.includes('mention')) { - return ( - { - const username = href.split(new RegExp(/@(.*)/)) - const usernameIndex = mentions.findIndex( - m => m.username === username[1] - ) - navigation.navigate('Account', { - id: mentions[usernameIndex].id - }) - }} - > - {node.children[0].data} - {node.children[1]?.children[0].data} - - ) - } - } else { - const domain = href.split(new RegExp(/:\/\/(.*?)\//)) - return ( - { - navigation.navigate('Webview', { - uri: href, - domain: domain[1] - }) - }} - > - {domain[1]} - - ) - } - } -} +import ParseContent from 'src/components/ParseContent' function Media ({ media_attachments, sensitive, width }) { const [mediaSensitive, setMediaSensitive] = useState(sensitive) @@ -90,16 +28,6 @@ function Media ({ media_attachments, sensitive, width }) { let images = [] if (width) { - const calWidth = i => { - if (media_attachments.length === 1) { - return { flexGrow: 1, aspectRatio: 16 / 9 } - } else if (media_attachments.length === 3 && i === 2) { - return { flexGrow: 1, aspectRatio: 16 / 9 } - } else { - return { flexBasis: width / 2 - 4, aspectRatio: 16 / 9 } - } - } - media_attachments = media_attachments.map((m, i) => { switch (m.type) { case 'unknown': @@ -111,9 +39,9 @@ function Media ({ media_attachments, sensitive, width }) { height: m.meta.original.height }) return ( - { setImageModalIndex(i) setImageModalVisible(true) @@ -122,9 +50,9 @@ function Media ({ media_attachments, sensitive, width }) { - + ) } }) @@ -181,54 +109,79 @@ export default function Content ({ mentions, sensitive, spoiler_text, - tags, width }) { - const navigation = useNavigation() + const [spoilerCollapsed, setSpoilerCollapsed] = useState(true) - let fullContent = [] - if (content) { - fullContent.push( - - renderNode(navigation, node, index, mentions) - } - TextComponent={({ children }) => ( - - )} - /> - ) - } - if (media_attachments) { - fullContent.push( - - ) - } - - return fullContent + return ( + <> + {content && + (spoiler_text ? ( + <> + + {spoiler_text}{' '} + setSpoilerCollapsed(!spoilerCollapsed)}> + 点击展开 + + + + + + + ) : ( + + ))} + {media_attachments.length > 0 && ( + + + + )} + + ) } const styles = StyleSheet.create({ media: { - flexDirection: 'row', - justifyContent: 'space-between' + flex: 1, + flexDirection: 'column', + flexWrap: 'wrap', + justifyContent: 'space-between', + alignItems: 'stretch', + alignContent: 'stretch' }, image: { width: '100%', height: '100%' - }, - a: { - color: 'blue' } }) Content.propTypes = { - content: PropTypes.string + content: ParseContent.propTypes.content, + emojis: ParseContent.propTypes.emojis, + // media_attachments + mentions: ParseContent.propTypes.mentions, + sensitive: PropTypes.bool.isRequired, + spoiler_text: PropTypes.string, + width: PropTypes.number.isRequired } diff --git a/src/components/TootTimeline/Emojis.jsx b/src/components/TootTimeline/Emojis.jsx index ea8877bb..cf0e6d30 100644 --- a/src/components/TootTimeline/Emojis.jsx +++ b/src/components/TootTimeline/Emojis.jsx @@ -2,17 +2,16 @@ import React from 'react' import PropTypes from 'prop-types' import { Image, Text } from 'react-native' -const regexEmoji = new RegExp(/(:[a-z0-9_]+:)/g) -const regexEmojiSelect = new RegExp(/:([a-z0-9_]+):/) +const regexEmoji = new RegExp(/(:.*?:)/g) export default function Emojis ({ content, emojis, dimension }) { const hasEmojis = content.match(regexEmoji) return hasEmojis ? ( content.split(regexEmoji).map((str, i) => { if (str.match(regexEmoji)) { - const emojiShortcode = str.split(regexEmojiSelect)[1] + const emojiShortcode = str.split(regexEmoji)[1] const emojiIndex = emojis.findIndex(emoji => { - return emoji.shortcode === emojiShortcode + return emojiShortcode === `:${emoji.shortcode}:` }) return ( { setTimeout(() => { setSince(relativeTime(created_at)) }, 1000) - }) + }, [since]) return ( diff --git a/src/stacks/Shared/Account.jsx b/src/stacks/Shared/Account.jsx index 2dd0a63e..57e37d2a 100644 --- a/src/stacks/Shared/Account.jsx +++ b/src/stacks/Shared/Account.jsx @@ -1,11 +1,89 @@ -import React, { useEffect } from 'react' -import { SafeAreaView, ScrollView, Text } from 'react-native' +import React, { useEffect, useState } from 'react' +import { + Dimensions, + Image, + ScrollView, + StyleSheet, + Text, + View +} from 'react-native' +import HTMLView from 'react-native-htmlview' import { useDispatch, useSelector } from 'react-redux' import { useFocusEffect } from '@react-navigation/native' +import { Feather } from '@expo/vector-icons' -import { fetch, getState, reset } from 'src/stacks/common/accountSlice' +import * as accountSlice from 'src/stacks/common/accountSlice' +import * as relationshipsSlice from 'src/stacks/common/relationshipsSlice' -// Show remote hashtag? Only when private, show local version? +import ParseContent from 'src/components/ParseContent' + +// Moved account example: https://m.cmx.im/web/accounts/27812 + +function Header ({ uri, size }) { + if (uri) { + return ( + + ) + } else { + return + } +} + +function Information ({ account, emojis }) { + return ( + + {/* Moved or not: {account.moved} */} + + + + {account.display_name || account.username} + {account.bot && ( + + )} + + + {account.acct} + {account.locked && } + + + {account.fields && + account.fields.map((field, index) => ( + + + {' '} + {field.verified_at && } + + + + + + ))} + + + + + + 加入时间{' '} + {new Date(account.created_at).toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric' + })} + + + Toots: {account.statuses_count} + Followers: {account.followers_count} + Following: {account.following_count} + + ) +} export default function Account ({ route: { @@ -13,29 +91,74 @@ export default function Account ({ } }) { const dispatch = useDispatch() - const state = useSelector(getState) + const accountState = useSelector(accountSlice.retrive) + // const stateRelationships = useSelector(relationshipsState) + const [loaded, setLoaded] = useState(false) + const [headerImageSize, setHeaderImageSize] = useState() useEffect(() => { - if (state.status === 'idle') { - dispatch(fetch({ id })) + if (accountState.status === 'idle') { + dispatch(accountSlice.fetch({ id })) } - }, [state, dispatch]) + + if (accountState.account.header) { + Image.getSize(accountState.account.header, (width, height) => { + setHeaderImageSize({ width, height }) + setLoaded(true) + }) + } else { + setLoaded(true) + } + }, [accountState, dispatch]) useFocusEffect( React.useCallback(() => { // Do something when the screen is focused return () => { - dispatch(reset()) + dispatch(accountSlice.reset()) } }, []) ) - - return ( - - - {state.account.acct} - - + // add emoji support + return loaded ? ( + +
+ + + ) : ( + <> ) } + +const styles = StyleSheet.create({ + header: ratio => ({ + width: '100%', + height: Dimensions.get('window').width * ratio, + backgroundColor: 'gray' + }), + information: { marginTop: -30, paddingLeft: 12, paddingRight: 12 }, + avatar: { + width: 90, + height: 90 + }, + display_name: { + fontSize: 18, + fontWeight: 'bold', + marginTop: 12 + }, + account: { + marginTop: 4 + } +}) diff --git a/src/stacks/common/Timeline.jsx b/src/stacks/common/Timeline.jsx index e2c15d8c..0b65245e 100644 --- a/src/stacks/common/Timeline.jsx +++ b/src/stacks/common/Timeline.jsx @@ -33,7 +33,12 @@ export default function Timeline ({ page, hashtag, list }) { )} onRefresh={() => - dispatch(fetch({ page, query: { since_id: state.toots[0].id } })) + dispatch( + fetch({ + page, + query: [{ key: 'since_id', value: state.toots[0].id }] + }) + ) } refreshing={state.status === 'loading'} onEndReached={() => { @@ -41,9 +46,12 @@ export default function Timeline ({ page, hashtag, list }) { dispatch( fetch({ page, - query: { - max_id: state.toots[state.toots.length - 1].id - } + query: [ + { + key: 'max_id', + value: state.toots[state.toots.length - 1].id + } + ] }) ) setTimelineReady(true) diff --git a/src/stacks/common/TimelinesCombined.jsx b/src/stacks/common/TimelinesCombined.jsx index 5011abbc..3a6b5927 100644 --- a/src/stacks/common/TimelinesCombined.jsx +++ b/src/stacks/common/TimelinesCombined.jsx @@ -74,9 +74,11 @@ export default function TimelinesCombined ({ name, content }) { ({ - title: `${route.params.id}` - })} + options={{ + headerTranslucent: true, + headerStyle: { backgroundColor: 'rgba(255, 255, 255, 0)' }, + headerCenter: () => {} + }} /> state.account +export const retrive = state => state.account export const accountSlice = createSlice({ name: 'account', - initialState: { - account: {}, - status: 'idle' - }, + initialState: accountInitState, reducers: { - reset: state => { - state.account = accountInitState - } + reset: () => accountInitState }, extraReducers: { [fetch.pending]: state => { diff --git a/src/stacks/common/interfaceSlice.js b/src/stacks/common/interfaceSlice.js new file mode 100644 index 00000000..e69de29b diff --git a/src/stacks/common/relationshipsSlice.js b/src/stacks/common/relationshipsSlice.js new file mode 100644 index 00000000..fb599d3d --- /dev/null +++ b/src/stacks/common/relationshipsSlice.js @@ -0,0 +1,60 @@ +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit' + +import { client } from 'src/api/client' + +export const fetch = createAsyncThunk( + 'relationships/fetch', + async ({ ids }, { getState }) => { + if (!ids.length) console.error('Relationships empty') + const instanceLocal = getState().instanceInfo.local + '/api/v1/' + const query = ids.map(id => ({ + key: 'id[]', + value: id + })) + const header = { + headers: { + Authorization: `Bearer ${getState().instanceInfo.localToken}` + } + } + + return await client.get( + `${instanceLocal}accounts/relationships`, + query, + header + ) + } +) + +const relationshipsInitState = { + relationships: [], + status: 'idle' +} + +export const retrive = state => state.relationships + +export const relationshipSlice = createSlice({ + name: 'relationships', + initialState: { + relationships: [], + status: 'idle' + }, + reducers: { + reset: () => relationshipsInitState + }, + extraReducers: { + [fetch.pending]: state => { + state.status = 'loading' + }, + [fetch.fulfilled]: (state, action) => { + state.status = 'succeeded' + state.relationships = action.payload + }, + [fetch.rejected]: (state, action) => { + state.status = 'failed' + console.error(action.error.message) + } + } +}) + +export const { reset } = relationshipSlice.actions +export default relationshipSlice.reducer diff --git a/src/stacks/common/store.js b/src/stacks/common/store.js index abf09bd0..8d2b488b 100644 --- a/src/stacks/common/store.js +++ b/src/stacks/common/store.js @@ -3,6 +3,7 @@ import { configureStore } from '@reduxjs/toolkit' import instanceInfoSlice from 'src/stacks/common/instanceInfoSlice' import timelineSlice from 'src/stacks/common/timelineSlice' import accountSlice from 'src/stacks/common/accountSlice' +import relationshipsSlice from 'src/stacks/common/relationshipsSlice' // get site information from local storage and pass to reducers const preloadedState = { @@ -16,7 +17,8 @@ const preloadedState = { const reducer = { instanceInfo: instanceInfoSlice, timelines: timelineSlice, - account: accountSlice + account: accountSlice, + relationships: relationshipsSlice } export default configureStore({ diff --git a/src/stacks/common/timelineSlice.js b/src/stacks/common/timelineSlice.js index 87ed871c..7ea71f37 100644 --- a/src/stacks/common/timelineSlice.js +++ b/src/stacks/common/timelineSlice.js @@ -13,7 +13,7 @@ import { client } from 'src/api/client' export const fetch = createAsyncThunk( 'timeline/fetch', - async ({ page, query, hashtag, list }, { getState }) => { + async ({ page, query = [], hashtag, list }, { getState }) => { const instanceLocal = getState().instanceInfo.local + '/api/v1/' const instanceRemote = getState().instanceInfo.remote + '/api/v1/' const header = { @@ -26,7 +26,7 @@ export const fetch = createAsyncThunk( case 'Following': return await client.get(`${instanceLocal}timelines/home`, query, header) case 'Local': - query ? (query.local = true) : (query = { local: 'true' }) + query.push({ key: 'local', value: 'true' }) return await client.get( `${instanceLocal}timelines/public`, query,