diff --git a/README.md b/README.md index 9e3472d..37b5d22 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ For all officially recommended clients, please visit http://bbs.uestc.edu.cn/for ![app_icon](https://cloud.githubusercontent.com/assets/7512625/18613513/348f7322-7daf-11e6-902d-94776bb55670.jpg) -## Status (v1.5.2) +## Status (v1.6.0) [download_on_the_app_store](https://itunes.apple.com/cn/app/qing-shui-he-pan-stuhome/id1190564355) @@ -44,6 +44,7 @@ For all officially recommended clients, please visit http://bbs.uestc.edu.cn/for - [x] Favor topic - [x] Upload images - [x] Emoji + - [x] Show friend list to @ - [ ] Report objectionable content - [ ] Vote - [ ] Create vote @@ -51,9 +52,10 @@ For all officially recommended clients, please visit http://bbs.uestc.edu.cn/for - [x] View vote results - [x] Search - [x] Notifications - - [x] View list mentioned(@) me + - [x] View list mentioned (@) me - [x] View list replied me - [x] View private messages + - [x] View system notifications - [x] Notification alert - [ ] Individual - [x] View my recent topics diff --git a/ios/stuhome/Info.plist b/ios/stuhome/Info.plist index d362bf3..3da28ee 100644 --- a/ios/stuhome/Info.plist +++ b/ios/stuhome/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.5.2 + 1.6.0 CFBundleSignature ???? CFBundleVersion diff --git a/package-lock.json b/package-lock.json index fd4a25d..af2e838 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "stuhome", - "version": "1.5.2", + "version": "1.6.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -6034,10 +6034,15 @@ "react-native-scrollable-tab-view": "0.8.0" } }, + "react-native-smart-timer-enhance": { + "version": "1.0.3", + "resolved": "http://registry.npm.taobao.org/react-native-smart-timer-enhance/download/react-native-smart-timer-enhance-1.0.3.tgz", + "integrity": "sha1-R93Bm+WmwZGERJA5SV8lVGvNlP4=" + }, "react-native-sticky-keyboard-accessory": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/react-native-sticky-keyboard-accessory/-/react-native-sticky-keyboard-accessory-0.1.1.tgz", - "integrity": "sha512-QgblDb4zyytv75enS+fvjTp9Bb01Rq6sItSy7YXJQIKHw0hXAZen/UoZhbdPwN0IrMe5moYPjfN4a0nnulHB6w==", + "integrity": "sha1-F2wpkyp+kptq8ULC1WUea5zrVH4=", "requires": { "react-native-iphone-x-helper": "1.0.2" }, diff --git a/package.json b/package.json index 70e8c69..d698310 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stuhome", - "version": "1.5.2", + "version": "1.6.0", "description": "An iOS client for http://bbs.uestc.edu.cn/ written in React Native with Redux.", "author": "just4fun ", "scripts": { @@ -34,6 +34,7 @@ "react-native-scrollable-tab-view": "0.8.0", "react-native-search-bar": "3.1.0", "react-native-smart-emoji-picker": "0.1.0", + "react-native-smart-timer-enhance": "^1.0.3", "react-native-sticky-keyboard-accessory": "0.1.1", "react-native-vector-icons": "4.4.3", "react-navigation": "1.0.3", diff --git a/src/actions/message/notifyListAction.js b/src/actions/message/notifyListAction.js index ad59233..83f3ed7 100644 --- a/src/actions/message/notifyListAction.js +++ b/src/actions/message/notifyListAction.js @@ -4,11 +4,14 @@ export const REQUEST = Symbol(); export const INVALIDATE = Symbol(); export const MARK_AT_ME_AS_READ = Symbol(); export const MARK_REPLY_AS_READ = Symbol(); +export const MARK_SYSTEM_AS_READ = Symbol(); export const fetchNotifyList = createAction(REQUEST); export const invalidateNotifyList = createAction(INVALIDATE); const markAtMeAsRead = createAction(MARK_AT_ME_AS_READ); const markReplyAsRead = createAction(MARK_REPLY_AS_READ); +const markSystemAsRead = createAction(MARK_SYSTEM_AS_READ); + // Update unread message count immediately instead of // clearing them with next poll after 0 ~ 15s. export const successfulCallback = (payload) => { @@ -17,6 +20,8 @@ export const successfulCallback = (payload) => { return markAtMeAsRead(); case 'post': return markReplyAsRead(); + case 'system': + return markSystemAsRead(); } } diff --git a/src/actions/user/friendListAction.js b/src/actions/user/friendListAction.js new file mode 100644 index 0000000..2cc13c5 --- /dev/null +++ b/src/actions/user/friendListAction.js @@ -0,0 +1,14 @@ +import { createAction } from 'redux-actions'; + +export const REQUEST = Symbol(); +export const INVALIDATE = Symbol(); +export const fetchFriendList = createAction(REQUEST); +export const invalidateFriendList = createAction(INVALIDATE); + +export const REQUEST_STARTED = Symbol(); +export const REQUEST_COMPELTED = Symbol(); +export const REQUEST_FAILED = Symbol(); +export const request = createAction(REQUEST_STARTED); +// return 2nd argument as `meta` field +export const success = createAction(REQUEST_COMPELTED, null, (...args) => args[1]); +export const failure = createAction(REQUEST_FAILED); diff --git a/src/components/3rd_party/LoadingSpinnerOverlay.js b/src/components/3rd_party/LoadingSpinnerOverlay.js new file mode 100644 index 0000000..db655a5 --- /dev/null +++ b/src/components/3rd_party/LoadingSpinnerOverlay.js @@ -0,0 +1,210 @@ +import React, { + Component, +} from 'react' +import { + View, + Modal, + StyleSheet, + Animated, + Easing, + Dimensions, + ActivityIndicator, + ActivityIndicatorIOS, + ProgressBarAndroid, +} from 'react-native' + +import TimerEnhance from 'react-native-smart-timer-enhance' + +const styles = StyleSheet.create({ + overlay: { + position: 'absolute', + left: 0, + top: 0, + zIndex: 998, + }, + container: { + backgroundColor: 'rgba(0, 0, 0, 0.8)', + position: 'absolute', + borderRadius: 8, + padding: 20.5, + left: -9999, + top: -9999, + zIndex: 999, + }, +}) +const noop = () => {} + +class LoadingSpinnerOverlay extends Component { + + static defaultProps = { + duration: 255, + delay: 0, + marginTop: 0, + modal: true, + } + + constructor(props) { + super(props) + this.state = { + visible: false, + opacity: new Animated.Value(0), + children: props.children, + modal: props.modal, + marginTop: props.marginTop, + } + this._loadingSpinnerWidth = null + this._loadingSpinnerHeight = null + this._loadingSpinnerShowAnimation = null + this._loadingSpinnerHideAnimation = null + this._loadingSpinnerAnimationToggle = null + } + + render() { + let loadingSpinner = this._renderLoadingSpinner() + return this._renderOverLay(loadingSpinner) + } + + _renderOverLay(loadingSpinner) { + let {width: deviceWidth, height: deviceHeight,} = Dimensions.get('window') + return ( + this.state.modal ? + (this.state.marginTop === 0 ? + + {loadingSpinner} + : + (this.state.visible ? + + {loadingSpinner} + : null)) + : loadingSpinner + ) + } + + _renderLoadingSpinner() { + let children + if(this.state.children == null) { + children = this._renderActivityIndicator() + } + else { + children = React.Children.map(this.state.children, (child) => { + if (!React.isValidElement(child)) { + return null + } + return child + }) + } + return ( + this.state.visible ? + this._container = component } + onLayout={this._onLoadingSpinnerLayout} + style={[styles.container, this.props.style, {opacity:this.state.opacity, }]}> + {children} + : null + ) + } + + show({modal = this.state.modal, marginTop = this.state.marginTop, children = this.state.children, duration = this.props.duration, easing = Easing.linear, delay = this.props.delay, animationEnd,} + = {modal: this.state.modal, marginTop: this.state.marginTop, children: this.state.children, duration: this.props.duration, easing: Easing.linear, delay: this.props.delay,}) { + + this._loadingSpinnerShowAnimation && this._loadingSpinnerShowAnimation.stop() + this._loadingSpinnerHideAnimation && this._loadingSpinnerHideAnimation.stop() + this._loadingSpinnerAnimationToggle && this.clearTimeout(this._loadingSpinnerAnimationToggle) + + if(this.state.visible) { + this._setLoadingSpinnerOverlayPosition({modal, marginTop}) + } + + this.setState({ + children, + modal, + marginTop, + visible: true, + }) + + this._loadingSpinnerShowAnimation = Animated.timing( + this.state.opacity, + { + toValue: 1, + duration, + easing, + delay, + } + ) + this._loadingSpinnerShowAnimation.start(() => { + this._loadingSpinnerShowAnimation = null + animationEnd && animationEnd() + }) + } + + hide({duration = this.props.duration, easing = Easing.linear, delay = this.props.delay, animationEnd,} + = {duration: this.props.duration, easing: Easing.linear, delay: this.props.delay,}) { + + this._loadingSpinnerShowAnimation && this._loadingSpinnerShowAnimation.stop() + this._loadingSpinnerHideAnimation && this._loadingSpinnerHideAnimation.stop() + this.clearTimeout(this._loadingSpinnerAnimationToggle) + + this._loadingSpinnerHideAnimation = Animated.timing( + this.state.opacity, + { + toValue: 0, + duration, + easing, + delay, + } + ) + this._loadingSpinnerHideAnimation.start( () => { + this._loadingSpinnerHideAnimation = null + this.setState({ + visible: false, + }) + animationEnd && animationEnd() + }) + } + + _onLoadingSpinnerLayout = (e) => { + this._loadingSpinnerWidth = e.nativeEvent.layout.width + this._loadingSpinnerHeight = e.nativeEvent.layout.height + this._setLoadingSpinnerOverlayPosition() + } + + _setLoadingSpinnerOverlayPosition({modal, marginTop} = {modal: this.state.modal, marginTop: this.state.marginTop}) { + if(!this._loadingSpinnerWidth || !this._loadingSpinnerHeight) { + return + } + let {width: deviceWidth, height: deviceHeight,} = Dimensions.get('window') + let left = (deviceWidth - this._loadingSpinnerWidth) / 2 + let top = (deviceHeight - this._loadingSpinnerHeight) / 2 - (modal && marginTop === 0 ? 0 : marginTop) + this._container.setNativeProps({ + style: { + left, + top, + } + }) + } + + _renderActivityIndicator() { + return ActivityIndicator ? ( + + ) : Platform.OS == 'android' ? + ( + + + ) : ( + + ) + } + +} + +export default TimerEnhance(LoadingSpinnerOverlay) diff --git a/src/components/Comment.js b/src/components/Comment.js index 53ed4cf..337b07c 100644 --- a/src/components/Comment.js +++ b/src/components/Comment.js @@ -35,9 +35,13 @@ export default class Comment extends Component { '复制' ]; let isLoginUser = currentUserId === userId; - if (!isLoginUser) { + // If userId is 0, it's anonymous user. + let canSendPrivateMessage = !isLoginUser && userId !== 0; + let editable = + isLoginUser && managePanel && managePanel.length > 0 && !!managePanel.find(item => item.title === '编辑'); + if (canSendPrivateMessage) { options.push('私信'); - } else { + } else if (editable) { options.push('编辑'); } options.push('取消'); @@ -70,13 +74,11 @@ export default class Comment extends Component { }); break; case 2: - if (!isLoginUser) { + if (canSendPrivateMessage) { navigation.navigate('PrivateMessage', { userId }); - } else if (managePanel && managePanel.length > 0) { + } else if (editable) { let editAction = managePanel.find(item => item.title === '编辑'); - if (editAction) { - SafariView.show(editAction.action); - } + SafariView.show(editAction.action); } break; } diff --git a/src/components/FriendItem.js b/src/components/FriendItem.js new file mode 100644 index 0000000..55b6766 --- /dev/null +++ b/src/components/FriendItem.js @@ -0,0 +1,46 @@ +import React, { Component } from 'react'; +import { + View, + Text, + TouchableHighlight +} from 'react-native'; +import moment from 'moment'; +import Avatar from './Avatar'; +import { AVATAR_ROOT } from '../config'; +import colors from '../styles/common/_colors'; +import styles from '../styles/components/_FriendItem'; + +export default class FriendItem extends Component { + render() { + let { + navigation, + currentUserId, + friend, + friend: { + name, + uid + } + } = this.props; + let avatar = `${AVATAR_ROOT}&uid=${uid}`; + + return ( + this.props.handleSelectFriend(friend)}> + + + + {name} + + + + ); + } +} diff --git a/src/components/FriendList.js b/src/components/FriendList.js new file mode 100644 index 0000000..3908ac9 --- /dev/null +++ b/src/components/FriendList.js @@ -0,0 +1,100 @@ +import React, { Component } from 'react'; +import { + View, + Text, + FlatList, + RefreshControl, + ActivityIndicator +} from 'react-native'; +import listStyles from '../styles/common/_List'; +import indicatorStyles from '../styles/common/_Indicator'; +import FriendItem from './FriendItem'; + +export default class FriendList extends Component { + endReached() { + let { + hasMore, + isRefreshing, + isEndReached, + page, + list + } = this.props.friendList; + + if (!hasMore || isRefreshing || isEndReached) { return; } + + this.props.refreshFriendList({ + page: page + 1, + isEndReached: true + }); + } + + renderFooter() { + let { + hasMore, + isEndReached + } = this.props.friendList; + + if (!hasMore || !isEndReached) { return ; } + + return ( + + + + ); + } + + renderListEmptyComponent() { + return ( + + + 暂无好友 + + + ); + } + + render() { + let { + friendList, + navigation, + refreshFriendList, + currentUserId, + handleSelectFriend + } = this.props; + let realFriendList = []; + let isRefreshing = false; + + if (friendList.list) { + realFriendList = friendList.list; + isRefreshing = friendList.isRefreshing; + }; + + return ( + index} + removeClippedSubviews={false} + enableEmptySections={true} + renderItem={({ item: friend }) => { + return ( + + ); + }} + onEndReached={() => this.endReached()} + onEndReachedThreshold={0} + ListFooterComponent={() => this.renderFooter()} + ListEmptyComponent={() => !isRefreshing && this.renderListEmptyComponent()} + refreshControl={ + refreshFriendList({ page: 1 })} + refreshing={isRefreshing} /> + } /> + ); + } +} diff --git a/src/components/ImageUploader.js b/src/components/ImageUploader.js index dc989fa..7c2c684 100644 --- a/src/components/ImageUploader.js +++ b/src/components/ImageUploader.js @@ -23,7 +23,6 @@ export default class ImageUploader extends Component { handleUploaderPress() { if (this.props.disabled) { return; } - let takePhotoOptions = { compressImageQuality: 0.5, loadingLabelText: '处理中...' diff --git a/src/components/LoadingSpinner.js b/src/components/LoadingSpinner.js new file mode 100644 index 0000000..8ec53f9 --- /dev/null +++ b/src/components/LoadingSpinner.js @@ -0,0 +1,26 @@ +import React, { Component } from 'react'; +import { + View, + Text, + ActivityIndicator +} from 'react-native'; +import mainStyles from '../styles/components/_Main'; +import indicatorStyles from '../styles/common/_Indicator'; +import styles from '../styles/components/_LoadingSpinner'; +import TIPS from '../constants/funnyTips'; + +export default class LoadingSpinner extends Component { + render() { + let { text } = this.props; + return ( + + + + + {text || TIPS[Math.floor(Math.random() * TIPS.length)]} + + + + ); + } +} diff --git a/src/components/NotifyList.js b/src/components/NotifyList.js index 3e88060..493d0fb 100644 --- a/src/components/NotifyList.js +++ b/src/components/NotifyList.js @@ -9,6 +9,7 @@ import { import listStyles from '../styles/common/_List'; import indicatorStyles from '../styles/common/_Indicator'; import NotifyItem from './NotifyItem'; +import NotifySystemItem from './NotifySystemItem'; export default class NotifyList extends Component { componentDidMount() { @@ -78,7 +79,15 @@ export default class NotifyList extends Component { keyExtractor={(item, index) => index} removeClippedSubviews={false} enableEmptySections={true} - renderItem={({ item: notification }) => { + renderItem={({ item: notification, index }) => { + if (notification.type === 'system') { + return ( + + ); + } + return ( + + + + + {'系统'} + {dateline} + + + {note} + + + ); + } +} diff --git a/src/components/PmSessionList.js b/src/components/PmSessionList.js index 841eaeb..8c4b283 100644 --- a/src/components/PmSessionList.js +++ b/src/components/PmSessionList.js @@ -12,7 +12,7 @@ import PmSessionItem from './PmSessionItem'; export default class PrivateList extends Component { componentDidMount() { - this.props.fetchPmSessionList(); + this.props.fetchPmSessionList({ page: 1 }); } endReached() { diff --git a/src/components/ProgressImage.js b/src/components/ProgressImage.js index 4414458..7d98ac6 100644 --- a/src/components/ProgressImage.js +++ b/src/components/ProgressImage.js @@ -2,6 +2,7 @@ import React, { Component } from 'react'; import { View, Image, + Text, ActivityIndicator, TouchableHighlight } from 'react-native'; @@ -25,7 +26,8 @@ export default class ProgressImage extends Component { super(props); this.state = { - isLoading: false + isLoading: false, + isLoadError: false }; } @@ -70,22 +72,35 @@ export default class ProgressImage extends Component { render() { let { style, thumbUri, originalUri } = this.props; - let { isLoading } = this.state; + let { isLoading, isLoadError } = this.state; return ( SafariView.show(originalUri)}> - this.setState({ isLoading: true })} - onLoadEnd={() => this.setState({ isLoading: false })} - resizeMode={'contain'} - // onLayout={event => this.handleLayout(event)} - style={[styles.image, style]} /> - {isLoading && } + {isLoadError && + + + 图片加载失败或图片已失效 + + || + + this.setState({ isLoading: true })} + onLoadEnd={() => this.setState({ isLoading: false })} + onError={() => this.setState({ isLoadError: true })} + resizeMode={'contain'} + // onLayout={event => this.handleLayout(event)} + style={[styles.image, style]} /> + {isLoading && } + + } ); diff --git a/src/components/modal/ForumListModal.js b/src/components/modal/ForumListModal.js index f9129ac..94059c2 100644 --- a/src/components/modal/ForumListModal.js +++ b/src/components/modal/ForumListModal.js @@ -1,8 +1,7 @@ import React, { Component } from 'react'; import { View, - Text, - ActivityIndicator + Text } from 'react-native'; import { connect } from 'react-redux'; import _ from 'lodash'; diff --git a/src/components/modal/FriendListModal.js b/src/components/modal/FriendListModal.js new file mode 100644 index 0000000..6727b05 --- /dev/null +++ b/src/components/modal/FriendListModal.js @@ -0,0 +1,68 @@ +import React, { Component } from 'react'; +import { + View, + Text +} from 'react-native'; +import { connect } from 'react-redux'; +import _ from 'lodash'; +import FriendList from '../FriendList'; +import mainStyles from '../../styles/components/_Main'; +import modalStyles from '../../styles/common/_Modal'; +import Header from '../Header'; +import { invalidateFriendList, fetchFriendList } from '../../actions/user/friendListAction'; + +class FriendListModal extends Component { + constructor(props) { + super(props); + this.title = '选择好友'; + } + + componentDidMount() { + this.fetchFriendList(); + } + + fetchFriendList(options = {}) { + this.props.fetchFriendList(options); + } + + refreshFriendList(options) { + this.props.invalidateFriendList(); + this.fetchFriendList(options); + } + + handleSelectFriend(friend) { + // Invoke callback to set @friend in TextInput. + this.props.navigation.state.params.callback(friend); + // Close modal. + this.props.navigation.goBack(); + } + + render() { + let { friendList, navigation } = this.props; + + return ( + +
+ this.handleSelectFriend()}>取消 +
+ this.handleSelectFriend(friend)} + refreshFriendList={(options) => this.refreshFriendList(options)} /> +
+ ); + } +} + +function mapStateToProps({ friendList }) { + return { + friendList + }; +} + +export default connect(mapStateToProps, { + invalidateFriendList, + fetchFriendList +})(FriendListModal); diff --git a/src/components/modal/LoginModal.js b/src/components/modal/LoginModal.js index c0e498b..a7b902a 100644 --- a/src/components/modal/LoginModal.js +++ b/src/components/modal/LoginModal.js @@ -18,7 +18,8 @@ import Button from 'apsl-react-native-button'; import mainStyles from '../../styles/components/_Main'; import styles from '../../styles/components/modal/_LoginModal'; import Header from '../Header'; -import RegisterModal from './RegisterModal'; +import SafariView from '../../services/SafariView'; +import { REGISTER_URL } from '../../config'; import { userLogin, resetAuthrization, @@ -100,7 +101,7 @@ class LoginModal extends Component { navigation.navigate('RegisterModal')}> + onPress={() => SafariView.show(REGISTER_URL)}> 注册 diff --git a/src/components/modal/PublishModal.js b/src/components/modal/PublishModal.js index 5b3a498..ee1b1ad 100644 --- a/src/components/modal/PublishModal.js +++ b/src/components/modal/PublishModal.js @@ -10,6 +10,7 @@ import { ActivityIndicator, LayoutAnimation } from 'react-native'; +import LoadingSpinnerOverlay from '../3rd_party/LoadingSpinnerOverlay'; import { isIphoneX } from 'react-native-iphone-x-helper'; import { connect } from 'react-redux'; import { NavigationActions } from 'react-navigation'; @@ -126,7 +127,8 @@ class PublishModal extends Component { // Hide keyboard. this.hideKeyboard(); - this.setState({ isPublishing: true }); + // this.setState({ isPublishing: true }); + this.modalLoadingSpinnerOverLay.show(); api.uploadImages(this.state.images).then(data => { let { typeId, title, content } = this.state; return api.publishTopic({ @@ -157,7 +159,8 @@ class PublishModal extends Component { } } }).finally(() => { - this.setState({ isPublishing: false }); + // this.setState({ isPublishing: false }); + this.modalLoadingSpinnerOverLay.hide(); }); } @@ -174,10 +177,12 @@ class PublishModal extends Component { this.setState({ selectedPanel: item }); } - handleEmojiPress = (emoji) => { + handleExtraContentPress = (extraContent) => { + if (this.state.isPublishing) { return; } + this.setState((prevState) => { let newContent = prevState.content.substr(0, this.contentCursorLocation) - + emoji.code + + extraContent + prevState.content.substr(this.contentCursorLocation); return { content: newContent }; }); @@ -240,6 +245,17 @@ class PublishModal extends Component { } } + showFriendList() { + if (this.state.isPublishing) { return; } + + this.props.navigation.navigate('FriendListModal', { + callback: (friend) => { + friend && this.handleExtraContentPress(`@${friend.name} `); + this.showKeyboard(); + } + }); + } + render() { let { typeId, @@ -263,11 +279,18 @@ class PublishModal extends Component { setSelection={typeId => this.setState({ typeId })} /> }
- this.handleCancel()}> - 取消 - + {isPublishing && + + 取消 + + || + this.handleCancel()}> + 取消 + + } {this.isFormValid() && (isPublishing && @@ -375,6 +398,13 @@ class PublishModal extends Component { onPress={() => this.handlePanelSelect('emoji')} /> ) } + {!isTitleFocused && + this.showFriendList()} /> + } + onEmojiPress={(emoji) => this.handleExtraContentPress(emoji.code)} /> + this.modalLoadingSpinnerOverLay = component }/> ); } diff --git a/src/components/modal/RegisterModal.js b/src/components/modal/RegisterModal.js deleted file mode 100644 index a95ba67..0000000 --- a/src/components/modal/RegisterModal.js +++ /dev/null @@ -1,23 +0,0 @@ -import React, { Component } from 'react'; -import { View } from 'react-native'; -import WebPage from '../../containers/WebPage'; -import Header from '../../components/Header'; -import { PopButton } from '../../components/button'; -import { REGISTER_URL } from '../../config'; -import mainStyles from '../../styles/components/_Main'; - -export default class RegisterModal extends Component { - render() { - let { navigation } = this.props; - return ( - -
- -
- -
- ); - } -} diff --git a/src/components/modal/ReplyModal.js b/src/components/modal/ReplyModal.js index bf43965..714d5a1 100644 --- a/src/components/modal/ReplyModal.js +++ b/src/components/modal/ReplyModal.js @@ -10,6 +10,7 @@ import { TouchableHighlight, ActivityIndicator } from 'react-native'; +import LoadingSpinnerOverlay from '../3rd_party/LoadingSpinnerOverlay'; import Icon from 'react-native-vector-icons/FontAwesome'; import KeyboardAccessory from 'react-native-sticky-keyboard-accessory'; import EmojiPicker from 'react-native-smart-emoji-picker'; @@ -119,7 +120,8 @@ class ReplyModal extends Component { // Hide keyboard. this.hideKeyboard(); - this.setState({ isPublishing: true }); + // this.setState({ isPublishing: true }); + this.modalLoadingSpinnerOverLay.show(); api.uploadImages(this.state.images).then(data => { // Actually there is no need to pass `boardId` when we // reply a topic. @@ -151,7 +153,8 @@ class ReplyModal extends Component { } } }).finally(() => { - this.setState({ isPublishing: false }); + // this.setState({ isPublishing: false }); + this.modalLoadingSpinnerOverLay.hide(); }); } @@ -180,10 +183,12 @@ class ReplyModal extends Component { this.setState({ selectedPanel: item }); } - handleEmojiPress = (emoji) => { + handleExtraContentPress = (extraContent) => { + if (this.state.isPublishing) { return; } + this.setState((prevState) => { let newContent = prevState.replyContent.substr(0, this.contentCursorLocation) - + emoji.code + + extraContent + prevState.replyContent.substr(this.contentCursorLocation); return { replyContent: newContent }; }); @@ -218,6 +223,17 @@ class ReplyModal extends Component { } } + showFriendList() { + if (this.state.isPublishing) { return; } + + this.props.navigation.navigate('FriendListModal', { + callback: (friend) => { + friend && this.handleExtraContentPress(`@${friend.name} `); + this.showKeyboard(); + } + }); + } + render() { let { replyContent, @@ -229,11 +245,18 @@ class ReplyModal extends Component { return (
- this.handleCancel()}> - 取消 - + {isPublishing && + + 取消 + + || + this.handleCancel()}> + 取消 + + } {replyContent.length && (isPublishing && @@ -295,6 +318,11 @@ class ReplyModal extends Component { size={30} onPress={() => this.handlePanelSelect('emoji')} /> } + this.showFriendList()} /> + onEmojiPress={(emoji) => this.handleExtraContentPress(emoji.code)} /> + this.modalLoadingSpinnerOverLay = component }/> ); } diff --git a/src/config.js b/src/config.js index 2555ae9..616781d 100644 --- a/src/config.js +++ b/src/config.js @@ -21,7 +21,7 @@ module.exports = { MAX_UPLOAD_IMAGES_COUNT: 9, - VERSION: 'v1.5.2', + VERSION: 'v1.6.0', AUTHOR_ID: 32044, diff --git a/src/constants/funnyTips.js b/src/constants/funnyTips.js new file mode 100644 index 0000000..e749eb2 --- /dev/null +++ b/src/constants/funnyTips.js @@ -0,0 +1,12 @@ +export default [ + '这款 APP 的大部分程序是在星巴克完成的', + '正在前往沙河', + '少刷河畔,多看看世界', + '正在前往顺江', + '有建议?在关于页面可以直接私信作者', + '你沉迷河畔的时候室友已经带女友出去嗨了', + '正在前往皮卡多', + '正在前往军事基地', + '年轻人,不要着急', + '楼主正在吃鸡,等 TA 一会儿' +]; diff --git a/src/containers/Information.js b/src/containers/Information.js index 1c874a7..49438fc 100644 --- a/src/containers/Information.js +++ b/src/containers/Information.js @@ -1,15 +1,13 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; -import { - View, - ActivityIndicator -} from 'react-native'; +import { View } from 'react-native'; import _ from 'lodash'; import Icon from 'react-native-vector-icons/FontAwesome'; import ImagePicker from '../services/ImagePicker'; import { setAuthrization } from '../actions/authorizeAction'; import menus from '../constants/menus'; import SettingItem from '../components/SettingItem'; +import LoadingSpinner from '../components/LoadingSpinner'; import mainStyles from '../styles/components/_Main'; import indicatorStyles from '../styles/common/_Indicator'; import styles from '../styles/containers/_About'; @@ -82,11 +80,7 @@ class Information extends Component { if (isFetching || !_.get(userItem, ['user', 'name'])) { return ( - - - - - + ); } diff --git a/src/containers/Message.js b/src/containers/Message.js index 8ec9f44..a919fea 100644 --- a/src/containers/Message.js +++ b/src/containers/Message.js @@ -13,12 +13,13 @@ import ReplyModal from '../components/modal/ReplyModal'; import menus from '../constants/menus'; import { invalidateNotifyList, fetchNotifyList } from '../actions/message/notifyListAction'; import { invalidatePmSessionList, fetchPmSessionList, markPmAsRead } from '../actions/message/pmSessionListAction'; -import { getAtMeCount, getReplyCount, getPmCount } from '../selectors/alert'; +import { getAtMeCount, getReplyCount, getPmCount, getSystemCount } from '../selectors/alert'; const TABS = [ { label: '@', type: 'at' }, { label: '回复', type: 'post' }, - { label: '私信', type: 'private' } + { label: '私信', type: 'private' }, + { label: '系统提醒', type: 'system' } ]; class Message extends Component { @@ -62,10 +63,11 @@ class Message extends Component { // for each tab of component. getTabsWithAlertCount(tabs) { let newTabs = []; - let { atMeCount, replyCount, pmCount } = this.props; + let { atMeCount, replyCount, pmCount, systemCount } = this.props; newTabs.push({ name: tabs[0], count: atMeCount }); newTabs.push({ name: tabs[1], count: replyCount }); newTabs.push({ name: tabs[2], count: pmCount }); + newTabs.push({ name: tabs[3], count: systemCount }); return newTabs; } @@ -97,7 +99,7 @@ class Message extends Component { navigation={navigation} currentUserId={userId} markPmAsRead={({ plid }) => this.props.markPmAsRead({ plid })} - fetchPmSessionList={() => this.fetchPmSessionList({})} + fetchPmSessionList={({ page }) => this.fetchPmSessionList({ page })} refreshPmSessionList={({ page, isEndReached }) => this.refreshPmSessionList({ page, isEndReached })} /> ); } @@ -126,6 +128,7 @@ function mapStateToProps({ notifyList, pmSessionList, alert, user }) { atMeCount: getAtMeCount(alert), replyCount: getReplyCount(alert), pmCount: getPmCount(alert), + systemCount: getSystemCount(alert), userId: _.get(user, ['authrization', 'uid']) }; } diff --git a/src/containers/Navigator.js b/src/containers/Navigator.js index 867364c..c68b1dc 100644 --- a/src/containers/Navigator.js +++ b/src/containers/Navigator.js @@ -17,10 +17,10 @@ import InformationScreen from './Information'; import SettingsScreen from './Settings'; import WebPageScreen from './WebPage'; import LoginModalScreen from '../components/modal/LoginModal'; -import RegisterModalScreen from '../components/modal/RegisterModal'; import PublishModalScreen from '../components/modal/PublishModal'; import ReplyModalScreen from '../components/modal/ReplyModal'; import ForumListModalScreen from '../components/modal/ForumListModal'; +import FriendListModalScreen from '../components/modal/FriendListModal'; import colors from '../styles/common/_colors'; import { getUserFromStorage } from '../actions/authorizeAction'; import { getSettingsFromStorage } from '../actions/settingsAction'; @@ -83,9 +83,6 @@ const AppNavigator = DrawerNavigator({ LoginModal: { screen: LoginModalScreen }, - RegisterModal: { - screen: RegisterModalScreen - }, PublishModal: { screen: PublishModalScreen }, @@ -94,6 +91,9 @@ const AppNavigator = DrawerNavigator({ }, ForumListModal: { screen: ForumListModalScreen + }, + FriendListModal: { + screen: FriendListModalScreen } }, { // Without `headerMode: 'none'`, there will be two headers since there are two diff --git a/src/containers/PmList.js b/src/containers/PmList.js index 95ea105..4dac870 100644 --- a/src/containers/PmList.js +++ b/src/containers/PmList.js @@ -5,9 +5,9 @@ import { Text, Image, AlertIOS, - ScrollView, - ActivityIndicator + ScrollView } from 'react-native'; +import LoadingSpinner from '../components/LoadingSpinner'; import { GiftedChat } from 'react-native-gifted-chat'; import GiftedChatSendButton from '../components/3rd_party/GiftedChatSendButton'; import GiftedChatLoadEarlierButton from '../components/3rd_party/GiftedChatLoadEarlierButton'; @@ -21,7 +21,6 @@ import { resetPmListResponseStatus } from '../actions/message/pmListAction'; import mainStyles from '../styles/components/_Main'; -import indicatorStyles from '../styles/common/_Indicator'; import styles from '../styles/containers/_PmList'; import { PRIVATE_MESSAGE_POLL_FREQUENCY } from '../config'; @@ -157,11 +156,7 @@ class PmList extends Component { if (isRefreshing && page === 0) { return ( - - - - - + ); } diff --git a/src/containers/Search.js b/src/containers/Search.js index b8542c5..f493e14 100644 --- a/src/containers/Search.js +++ b/src/containers/Search.js @@ -1,14 +1,14 @@ import React, { Component } from 'react'; import { View, - AlertIOS, - ActivityIndicator + AlertIOS } from 'react-native'; import { connect } from 'react-redux'; import _ from 'lodash'; import mainStyles from '../styles/components/_Main'; import indicatorStyles from '../styles/common/_Indicator'; import TopicList from '../components/TopicList'; +import LoadingSpinner from '../components/LoadingSpinner'; import SearchBar from 'react-native-search-bar'; import menus from '../constants/menus'; import { fetchSearch, resetSearch } from '../actions/topic/searchAction'; @@ -92,9 +92,7 @@ class Search extends Component { onSearchButtonPress={() => this.handleSearch()} onCancelButtonPress={() => this.getSearchBarBlur()} /> {search.isRefreshing && ( - - - + ) || ( this.searchList = component} diff --git a/src/containers/TopicDetail.js b/src/containers/TopicDetail.js index 0029b8c..3fe6659 100644 --- a/src/containers/TopicDetail.js +++ b/src/containers/TopicDetail.js @@ -27,6 +27,7 @@ import Comment from '../components/Comment'; import Content from '../components/Content'; import VoteList from '../components/VoteList'; import RewardList from '../components/RewardList'; +import LoadingSpinner from '../components/LoadingSpinner'; import MessageBar from '../services/MessageBar'; import SafariView from '../services/SafariView'; import colors from '../styles/common/_colors'; @@ -175,7 +176,7 @@ class TopicDetail extends Component { authrization: { uid } } } = this.props; - let { isFavoring } = this.state; + let { isFavoring, isVoting } = this.state; let create_date = moment(+topic.create_date).startOf('minute').fromNow(); let commentHeaderText = topic.replies > 0 ? (topic.replies + '条评论') : '还没有评论,快来抢沙发!'; @@ -242,6 +243,7 @@ class TopicDetail extends Component { {topic.poll_info && this.publishVote(voteIds)} /> } @@ -328,7 +330,9 @@ class TopicDetail extends Component { } } = this.props; let isLoginUser = uid === user_id; - if (isLoginUser) { + let editable = + isLoginUser && managePanel && managePanel.length > 0 && !!managePanel.find(item => item.title === '编辑'); + if (editable) { options.push('编辑帖子'); } options.push('取消'); @@ -366,11 +370,9 @@ class TopicDetail extends Component { }); break; case 5: - if (isLoginUser && managePanel && managePanel.length > 0) { + if (editable) { let editAction = managePanel.find(item => item.title === '编辑'); - if (editAction) { - SafariView.show(editAction.action); - } + SafariView.show(editAction.action); } break; } @@ -378,28 +380,11 @@ class TopicDetail extends Component { } render() { - let { - topicItem, - user: { - authrization: { uid } - }, - navigation - } = this.props; + let { topicItem } = this.props; - if (topicItem.isFetching) { + if (topicItem.isFetching || !_.get(topicItem, ['topic', 'topic_id'])) { return ( - - - - - - ); - } - - if (!_.get(topicItem, ['topic', 'topic_id'])) { - return ( - - + ); } @@ -410,6 +395,10 @@ class TopicDetail extends Component { topic_id, replies } + }, + navigation, + user: { + authrization: { uid } } } = this.props; diff --git a/src/images/image_fail.png b/src/images/image_fail.png new file mode 100644 index 0000000..931e8c8 Binary files /dev/null and b/src/images/image_fail.png differ diff --git a/src/reducers/index.js b/src/reducers/index.js index 1f294d4..72257f6 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -12,6 +12,7 @@ import send from './message/send'; import alert from './message/alert'; import settings from './settings'; import userItem from './user/userItem'; +import friendList from './user/friendList'; export default combineReducers({ forumList, @@ -26,6 +27,7 @@ export default combineReducers({ userItem, topicItem, search, + friendList, userTopicList, diff --git a/src/reducers/message/alert.js b/src/reducers/message/alert.js index 005d27d..66f4d5d 100644 --- a/src/reducers/message/alert.js +++ b/src/reducers/message/alert.js @@ -7,7 +7,8 @@ import { } from '../../actions/message/alertAction'; import { MARK_AT_ME_AS_READ, - MARK_REPLY_AS_READ + MARK_REPLY_AS_READ, + MARK_SYSTEM_AS_READ } from '../../actions/message/notifyListAction'; import { MARK_PM_AS_READ } from '../../actions/message/pmSessionListAction'; import { REMOVE_CACHE } from '../../actions/authorizeAction'; @@ -17,7 +18,8 @@ const defaultAlertState = { response: { atMeInfo: { count: 0 }, replyInfo: { count: 0 }, - pmInfos: [] + pmInfos: [], + systemInfo: { count: 0 } } }; @@ -42,55 +44,42 @@ export default function alert(state = defaultAlertState, action) { }; } case MARK_AT_ME_AS_READ: { - let { - response: { - replyInfo, - pmInfos - } - } = state; return { ...state, response: { - atMeInfo: { count: 0 }, - // Keep another information. - replyInfo, - pmInfos + ...state.response, + atMeInfo: { count: 0 } } }; } case MARK_REPLY_AS_READ: { - let { - response: { - atMeInfo, - pmInfos - } - } = state; return { ...state, response: { - atMeInfo, + ...state.response, replyInfo: { count: 0 }, - pmInfos } }; } case MARK_PM_AS_READ: { - let { - response: { - atMeInfo, - replyInfo - } - } = state; let { plid } = action.payload; return { ...state, response: { - atMeInfo, - replyInfo, + ...state.response, pmInfos: state.response.pmInfos.filter(item => item.plid !== plid) } }; } + case MARK_SYSTEM_AS_READ: { + return { + ...state, + response: { + ...state.response, + systemInfo: { count: 0 }, + } + }; + } case RESET: case REMOVE_CACHE: return defaultAlertState; diff --git a/src/reducers/message/notifyList.js b/src/reducers/message/notifyList.js index ecd0b28..dc0aa2d 100644 --- a/src/reducers/message/notifyList.js +++ b/src/reducers/message/notifyList.js @@ -53,6 +53,11 @@ export default function notifyList(state = defaultState, action) { } } = action; + // `list` is `[]` for `system` type. + if (notifyType === 'system') { + notifyList.list = notifyList.body.data; + } + return { ...state, [notifyType]: { diff --git a/src/reducers/user/friendList.js b/src/reducers/user/friendList.js new file mode 100644 index 0000000..04d1679 --- /dev/null +++ b/src/reducers/user/friendList.js @@ -0,0 +1,63 @@ +import { + REQUEST_STARTED, + REQUEST_COMPELTED, + REQUEST_FAILED, + INVALIDATE +} from '../../actions/user/friendListAction'; + +const defaultSearchState = { + // indicate fetching via pull to refresh + isRefreshing: false, + // indicate fetching via end reached + isEndReached: false, + didInvalidate: false, + list: [], + hasMore: false, + page: 0, + errCode: '' +}; + +export default function search(state = defaultSearchState, action) { + switch (action.type) { + case INVALIDATE: + return { + ...state, + didInvalidate: true + }; + case REQUEST_STARTED: + return { + ...state, + isRefreshing: !action.payload.isEndReached, + isEndReached: action.payload.isEndReached, + didInvalidate: false + }; + case REQUEST_COMPELTED: + let { + payload: friendList + } = action; + + if (friendList.page !== 1 && friendList.list) { + friendList.list = state.list.concat(friendList.list); + } + + return { + ...state, + isRefreshing: false, + isEndReached: false, + didInvalidate: false, + list: friendList.list || [], + hasMore: !!friendList.has_next, + page: friendList.page, + errCode: friendList.errcode + }; + case REQUEST_FAILED: + return { + ...state, + isRefreshing: false, + isEndReached: false, + didInvalidate: false + }; + default: + return state; + } +} diff --git a/src/sagas/index.js b/src/sagas/index.js index 060a464..04e4a46 100644 --- a/src/sagas/index.js +++ b/src/sagas/index.js @@ -14,6 +14,7 @@ import * as sendActions from '../actions/message/sendAction'; import * as alertActions from '../actions/message/alertAction'; import * as settingsActions from '../actions/settingsAction'; import * as userActions from '../actions/user/userAction'; +import * as friendListActions from '../actions/user/friendListAction'; import cacheManager from '../services/cacheManager'; import { fetchResource } from '../utils/sagaHelper'; @@ -31,6 +32,7 @@ const fetchPmListApi = fetchResource.bind(null, pmListActions, api.fetchPmList); const sendMessageApi = fetchResource.bind(null, sendActions, api.sendMessage); const fetchAlertsApi = fetchResource.bind(null, alertActions, api.fetchAlerts); const fetchUserApi = fetchResource.bind(null, userActions, api.fetchUser); +const fetchFriendListApi = fetchResource.bind(null, friendListActions, api.fetchFriendList); // user login sagas @@ -240,6 +242,23 @@ function* watchUsers() { } } +// friend list sagas + +function* watchFriendList() { + while(true) { + const { payload } = yield take(friendListActions.REQUEST); + yield fork(fetchFriendList, payload); + } +} + +function* fetchFriendList(payload) { + const state = yield select(); + + if (cacheManager.shouldFetchList(state, 'friendList')) { + yield fork(fetchFriendListApi, payload); + } +} + export default function* rootSaga() { yield fork(watchRetrieveUser); yield fork(watchLogin); @@ -256,4 +275,5 @@ export default function* rootSaga() { yield fork(watchRetrieveSettings); yield fork(watchStoreSettings); yield fork(watchUsers); + yield fork(watchFriendList); } diff --git a/src/selectors/alert.js b/src/selectors/alert.js index 3c6d443..5ed554f 100644 --- a/src/selectors/alert.js +++ b/src/selectors/alert.js @@ -3,7 +3,11 @@ import _ from 'lodash'; export const getAtMeCount = alertState => _.get(alertState, ['response', 'atMeInfo', 'count'], 0); export const getReplyCount = alertState => _.get(alertState, ['response', 'replyInfo', 'count'], 0); export const getPmCount = alertState => _.get(alertState, ['response', 'pmInfos', 'length'], 0); +export const getSystemCount = alertState => _.get(alertState, ['response', 'systemInfo', 'count'], 0); export const getAlertCount = alertState => { - return getAtMeCount(alertState) + getReplyCount(alertState) + getPmCount(alertState); + return getAtMeCount(alertState) + + getReplyCount(alertState) + + getPmCount(alertState) + + getSystemCount(alertState); }; diff --git a/src/services/api.js b/src/services/api.js index 3649cd2..d5e6f68 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -287,10 +287,28 @@ export default { }, fetchAlerts: () => { - return callApi('message/heart'); + // Specify `sdkVersion` to get `systemInfo` instead of `friendInfo`. + // + // API source code: + // + // if($_GET['sdkVersion']>='2.4.2'){ + // // 获得系统消息 + // $res['body']['systemInfo'] = $this->_getSystemInfo($uid); + // }else{ + // // 获取好友通知 + // $res['body']['friendInfo'] = $this->_getNotifyInfo($uid, 'friend'); + // } + return callApi('message/heart&sdkVersion=2.4.2'); }, fetchUser: ({ userId }) => { return callApi(`user/userinfo&userId=${userId}`); + }, + + fetchFriendList: ({ + page = DEFAULT_PAGE, + pageSize = DEFAULT_PAGESIZE + }) => { + return callApi(`forum/atuserlist&page=${page}&pageSize=${pageSize}`); } }; diff --git a/src/styles/components/_FriendItem.js b/src/styles/components/_FriendItem.js new file mode 100644 index 0000000..3309c56 --- /dev/null +++ b/src/styles/components/_FriendItem.js @@ -0,0 +1,31 @@ +import { StyleSheet } from 'react-native'; +import colors from '../common/_colors'; + +export default StyleSheet.create({ + container: { + backgroundColor: colors.white, + borderBottomWidth: 1, + borderBottomColor: colors.underlay + }, + item: { + marginHorizontal: 20, + marginVertical: 10, + }, + row: { + flexDirection: 'row', + }, + avatar: { + height: 45, + width: 45, + borderRadius: 10, + }, + content: { + flex: 1, + marginLeft: 20, + justifyContent: 'center', + }, + name: { + fontSize: 16, + color: colors.significantField, + }, +}); diff --git a/src/styles/components/_FriendList.js b/src/styles/components/_FriendList.js new file mode 100644 index 0000000..e69de29 diff --git a/src/styles/components/_LoadingSpinner.js b/src/styles/components/_LoadingSpinner.js new file mode 100644 index 0000000..631b121 --- /dev/null +++ b/src/styles/components/_LoadingSpinner.js @@ -0,0 +1,12 @@ +import { StyleSheet } from 'react-native'; +import colors from '../common/_colors'; + +export default StyleSheet.create({ + container: { + padding: 10 + }, + text: { + marginTop: 10, + color: colors.mainField + }, +}); diff --git a/src/styles/components/_ProgressImage.js b/src/styles/components/_ProgressImage.js index 82ea828..1b20a21 100644 --- a/src/styles/components/_ProgressImage.js +++ b/src/styles/components/_ProgressImage.js @@ -2,6 +2,7 @@ import { StyleSheet, Dimensions } from 'react-native'; +import colors from '../common/_colors'; const window = Dimensions.get('window'); const IMAGE_HEIGHT = 250; @@ -13,7 +14,14 @@ export default StyleSheet.create({ indicator: { position: 'absolute', top: IMAGE_HEIGHT / 2, - // `20` is width for ActivityIndicator. - left: window.width / 2 - 20 + // window.width / 2 - (width of ActivityIndicator / 2 + margin of image) + left: window.width / 2 - (20 / 2 + 10) + }, + text: { + position: 'absolute', + top: IMAGE_HEIGHT / 2 + 60, + // window.width / 2 - (width of `图片加载失败或图片已失效` / 2 + margin of image) + left: window.width / 2 - (168 / 2 + 10), + color: colors.mainField } }); diff --git a/src/utils/contentParser.js b/src/utils/contentParser.js index c62a76e..b2d3fc1 100644 --- a/src/utils/contentParser.js +++ b/src/utils/contentParser.js @@ -20,7 +20,7 @@ export function parseContentWithEmoji(content, includeEmoji = true) { return contentEmojiArray.filter(item => item.trim()).map((item, index) => { // Handle custom emojis. - if (/https?:\/\/.+(?:jpg|png|gif)/.test(item)) { + if (/^https?:\/\/.+(?:jpg|png|gif)$/.test(item)) { // Exclude custom emoji because copy something like [mobcent_phiz=..] // is useless as paste content. if (!includeEmoji) { return ''; } diff --git a/src/utils/request.js b/src/utils/request.js index c1d42b9..f97f779 100644 --- a/src/utils/request.js +++ b/src/utils/request.js @@ -15,6 +15,24 @@ function checkStatus(response) { throw error; } +function handleError(error) { + if (error) { + if (error.message === 'Network request failed') { + MessageBar.show({ + message: '请检查网络是否通畅', + type: 'warning' + }); + } else if (error.response && error.response.status >= 500) { + MessageBar.show({ + message: '服务器开小差啦,请查看网页版能否登陆', + type: 'warning' + }); + } + } + + return { error }; +} + export default function request(url, options) { return AsyncStorage.getItem('authrization') .then(authrization => { @@ -27,15 +45,6 @@ export default function request(url, options) { .then(checkStatus) .then(parseJSON) .then(data => ({ data })) - .catch(error => { - if (error && error.message === 'Network request failed') { - MessageBar.show({ - message: '同学,网络出错啦!', - type: 'warning' - }); - } - - return { error }; - }); + .catch(handleError); }); }