Commit 152b3220024a49f51e890826e63050a72b729943
ux: fix #59, thread replies don't disappear
Andre Staltz committed on 6/18/2018, 1:42:38 PMParent: fc9f4578a3d9fa17c75d7e4fdba5b6d11a182df5
Files changed
src/app/components/FullThread.ts | changed |
src/app/screens/thread/index.ts | changed |
src/app/screens/thread/intent.ts | changed |
src/app/screens/thread/model.ts | changed |
src/app/screens/thread/view.ts | changed |
src/app/components/FullThread.ts | ||
---|---|---|
@@ -17,35 +17,32 @@ | ||
17 | 17 | * along with this program. If not, see <http://www.gnu.org/licenses/>. |
18 | 18 | */ |
19 | 19 | |
20 | 20 | import {Stream, Subscription, Listener} from 'xstream'; |
21 | -import {PureComponent, ReactElement} from 'react'; | |
21 | +import {Component, ReactElement} from 'react'; | |
22 | 22 | import {h} from '@cycle/native-screen'; |
23 | 23 | import {FeedId} from 'ssb-typescript'; |
24 | -import {ThreadAndExtras, MsgAndExtras, GetReadable} from '../drivers/ssb'; | |
24 | +import {ThreadAndExtras, MsgAndExtras} from '../drivers/ssb'; | |
25 | 25 | import Message from './messages/Message'; |
26 | 26 | import PlaceholderMessage from './messages/PlaceholderMessage'; |
27 | -const pull = require('pull-stream'); | |
28 | 27 | |
29 | 28 | export type Props = { |
30 | 29 | thread: ThreadAndExtras; |
31 | - getPublicationsReadable?: GetReadable<MsgAndExtras> | null; | |
32 | 30 | publication$?: Stream<any> | null; |
33 | 31 | selfFeedId: FeedId; |
34 | 32 | onPressLike?: (ev: {msgKey: string; like: boolean}) => void; |
35 | 33 | onPressAuthor?: (ev: {authorFeedId: FeedId}) => void; |
36 | 34 | }; |
37 | 35 | |
38 | 36 | type State = { |
39 | 37 | showPlaceholder: boolean; |
40 | - thread: ThreadAndExtras; | |
41 | 38 | }; |
42 | 39 | |
43 | -export default class FullThread extends PureComponent<Props, State> { | |
40 | +export default class FullThread extends Component<Props, State> { | |
44 | 41 | constructor(props: Props) { |
45 | 42 | super(props); |
46 | 43 | this.renderMessage = this.renderMessage.bind(this); |
47 | - this.state = {showPlaceholder: false, thread: this.props.thread}; | |
44 | + this.state = {showPlaceholder: false}; | |
48 | 45 | } |
49 | 46 | |
50 | 47 | private subscription?: Subscription; |
51 | 48 | |
@@ -56,40 +53,38 @@ | ||
56 | 53 | this.subscription = publication$.subscribe(listener as Listener<any>); |
57 | 54 | } |
58 | 55 | } |
59 | 56 | |
60 | - public componentWillReceiveProps(nextProps: Props) { | |
61 | - this.setState(prev => ({...prev, thread: nextProps.thread})); | |
57 | + public shouldComponentUpdate(nextProps: Props, nextState: State) { | |
58 | + const prevProps = this.props; | |
59 | + if (nextProps.selfFeedId !== prevProps.selfFeedId) return true; | |
60 | + if (nextProps.onPressAuthor !== prevProps.onPressAuthor) return true; | |
61 | + if (nextProps.onPressLike !== prevProps.onPressLike) return true; | |
62 | + if (nextProps.publication$ !== prevProps.publication$) return true; | |
63 | + const prevMessages = prevProps.thread.messages; | |
64 | + const nextMessages = nextProps.thread.messages; | |
65 | + if (nextMessages.length !== prevMessages.length) return true; | |
66 | + if (nextState.showPlaceholder !== this.state.showPlaceholder) return true; | |
67 | + return false; | |
62 | 68 | } |
63 | 69 | |
70 | + public componentDidUpdate(prevProps: Props, prevState: State) { | |
71 | + const prevMessages = prevProps.thread.messages; | |
72 | + const nextMessages = this.props.thread.messages; | |
73 | + if (nextMessages.length > prevMessages.length) { | |
74 | + this.setState({showPlaceholder: false}); | |
75 | + } | |
76 | + } | |
77 | + | |
64 | 78 | public componentWillUnmount() { |
65 | 79 | if (this.subscription) { |
66 | 80 | this.subscription.unsubscribe(); |
67 | 81 | this.subscription = void 0; |
68 | 82 | } |
69 | 83 | } |
70 | 84 | |
71 | 85 | private onPublication() { |
72 | - const {getPublicationsReadable} = this.props; | |
73 | - if (!getPublicationsReadable) return; | |
74 | - const readable = getPublicationsReadable({live: true, old: false}); | |
75 | - if (!readable) return; | |
76 | - const that = this; | |
77 | - | |
78 | - this.setState(() => ({showPlaceholder: true})); | |
79 | - pull( | |
80 | - readable, | |
81 | - pull.take(1), | |
82 | - pull.drain((msg: MsgAndExtras) => { | |
83 | - that.setState((prev: State) => ({ | |
84 | - showPlaceholder: false, | |
85 | - thread: { | |
86 | - messages: prev.thread.messages.concat([msg]), | |
87 | - full: prev.thread.full, | |
88 | - }, | |
89 | - })); | |
90 | - }), | |
91 | - ); | |
86 | + this.setState({showPlaceholder: true}); | |
92 | 87 | } |
93 | 88 | |
94 | 89 | private renderMessage(msg: MsgAndExtras) { |
95 | 90 | const {selfFeedId, onPressLike, onPressAuthor} = this.props; |
@@ -102,9 +97,9 @@ | ||
102 | 97 | }); |
103 | 98 | } |
104 | 99 | |
105 | 100 | public render() { |
106 | - const {thread} = this.state; | |
101 | + const thread = this.props.thread; | |
107 | 102 | if (!thread.messages || thread.messages.length <= 0) return []; |
108 | 103 | const children: Array<ReactElement<any>> = thread.messages.map( |
109 | 104 | this.renderMessage, |
110 | 105 | ); |
src/app/screens/thread/index.ts | ||
---|---|---|
@@ -51,12 +51,12 @@ | ||
51 | 51 | title: 'Thread', |
52 | 52 | }); |
53 | 53 | |
54 | 54 | export function thread(sources: Sources): Sinks { |
55 | - const actions = intent(sources.screen, sources.onion.state$); | |
55 | + const actions = intent(sources.screen, sources.ssb, sources.onion.state$); | |
56 | 56 | const reducer$ = model(sources.onion.state$, actions, sources.ssb); |
57 | 57 | const command$ = navigation(actions); |
58 | - const vdom$ = view(sources.onion.state$, sources.ssb, actions); | |
58 | + const vdom$ = view(sources.onion.state$, actions); | |
59 | 59 | const newContent$ = ssb(actions); |
60 | 60 | const dismiss$ = actions.publishMsg$.mapTo('dismiss' as 'dismiss'); |
61 | 61 | |
62 | 62 | return { |
src/app/screens/thread/intent.ts | ||
---|---|---|
@@ -19,24 +19,32 @@ | ||
19 | 19 | |
20 | 20 | import {Stream} from 'xstream'; |
21 | 21 | import sample from 'xstream-sample'; |
22 | 22 | import {ScreensSource} from 'cycle-native-navigation'; |
23 | +import {isReplyPostMsg} from 'ssb-typescript/utils'; | |
23 | 24 | import {FeedId} from 'ssb-typescript'; |
24 | 25 | import {Screens} from '../..'; |
25 | 26 | import {State} from './model'; |
27 | +import {SSBSource} from '../../drivers/ssb'; | |
26 | 28 | |
27 | 29 | export type ProfileNavEvent = {authorFeedId: FeedId}; |
28 | 30 | |
29 | 31 | export type LikeEvent = {msgKey: string; like: boolean}; |
30 | 32 | |
31 | -export default function intent(source: ScreensSource, state$: Stream<State>) { | |
33 | +export default function intent( | |
34 | + source: ScreensSource, | |
35 | + ssbSource: SSBSource, | |
36 | + state$: Stream<State>, | |
37 | +) { | |
32 | 38 | return { |
33 | 39 | publishMsg$: source |
34 | 40 | .select('replyButton') |
35 | 41 | .events('press') |
36 | 42 | .compose(sample(state$)) |
37 | 43 | .filter(state => !!state.replyText && !!state.rootMsgId), |
38 | 44 | |
45 | + willReply$: ssbSource.publishHook$.filter(isReplyPostMsg), | |
46 | + | |
39 | 47 | appear$: source.willAppear(Screens.Thread).mapTo(null), |
40 | 48 | |
41 | 49 | disappear$: source.didDisappear(Screens.Thread).mapTo(null), |
42 | 50 |
src/app/screens/thread/model.ts | ||
---|---|---|
@@ -19,8 +19,9 @@ | ||
19 | 19 | |
20 | 20 | import xs, {Stream} from 'xstream'; |
21 | 21 | import sample from 'xstream-sample'; |
22 | 22 | import dropRepeats from 'xstream/extra/dropRepeats'; |
23 | +import xsFromPullStream from 'xstream-from-pull-stream'; | |
23 | 24 | import {Reducer} from 'cycle-onionify'; |
24 | 25 | import {FeedId, MsgId} from 'ssb-typescript'; |
25 | 26 | import { |
26 | 27 | ThreadAndExtras, |
@@ -69,8 +70,9 @@ | ||
69 | 70 | } |
70 | 71 | |
71 | 72 | export type AppearingActions = { |
72 | 73 | publishMsg$: Stream<any>; |
74 | + willReply$: Stream<any>; | |
73 | 75 | appear$: Stream<null>; |
74 | 76 | disappear$: Stream<null>; |
75 | 77 | updateReplyText$: Stream<string>; |
76 | 78 | }; |
@@ -122,17 +124,34 @@ | ||
122 | 124 | ), |
123 | 125 | ) |
124 | 126 | .flatten(); |
125 | 127 | |
126 | - const updateSelfRepliesReducer$ = ssbSource.selfReplies$.map( | |
127 | - getReadable => | |
128 | - function updateSelfRepliesReducer(prev?: State): State { | |
129 | - if (!prev) { | |
130 | - throw new Error('Thread/model reducer expects existing state'); | |
131 | - } | |
132 | - return {...prev, getSelfRepliesReadable: getReadable}; | |
133 | - }, | |
134 | - ); | |
128 | + const addSelfRepliesReducer$ = actions.willReply$ | |
129 | + .map(() => | |
130 | + ssbSource.selfReplies$ | |
131 | + .map(getReadable => | |
132 | + xsFromPullStream<MsgAndExtras>( | |
133 | + getReadable({live: true, old: false}), | |
134 | + ).take(1), | |
135 | + ) | |
136 | + .flatten(), | |
137 | + ) | |
138 | + .flatten() | |
139 | + .map( | |
140 | + newMsg => | |
141 | + function addSelfRepliesReducer(prev?: State): State { | |
142 | + if (!prev) { | |
143 | + throw new Error('Thread/model reducer expects existing state'); | |
144 | + } | |
145 | + return { | |
146 | + ...prev, | |
147 | + thread: { | |
148 | + messages: prev.thread.messages.concat([newMsg]), | |
149 | + full: true, | |
150 | + }, | |
151 | + }; | |
152 | + }, | |
153 | + ); | |
135 | 154 | |
136 | 155 | const clearReplyReducer$ = actions.disappear$.mapTo( |
137 | 156 | function clearReplyReducer(prev?: State): State { |
138 | 157 | if (!prev) { |
@@ -145,8 +164,8 @@ | ||
145 | 164 | return xs.merge( |
146 | 165 | setThreadReducer$, |
147 | 166 | updateReplyTextReducer$, |
148 | 167 | publishReplyReducers$, |
149 | - updateSelfRepliesReducer$, | |
168 | + addSelfRepliesReducer$, | |
150 | 169 | clearReplyReducer$, |
151 | 170 | ); |
152 | 171 | } |
src/app/screens/thread/view.ts | ||
---|---|---|
@@ -17,14 +17,14 @@ | ||
17 | 17 | * along with this program. If not, see <http://www.gnu.org/licenses/>. |
18 | 18 | */ |
19 | 19 | |
20 | 20 | import {Stream} from 'xstream'; |
21 | +import dropRepeats from 'xstream/extra/dropRepeats'; | |
21 | 22 | import {h} from '@cycle/native-screen'; |
22 | 23 | import * as Progress from 'react-native-progress'; |
23 | 24 | import {View, TextInput, ScrollView, TouchableOpacity} from 'react-native'; |
24 | 25 | import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; |
25 | 26 | import {propifyMethods} from 'react-propify-methods'; |
26 | -import {isReplyPostMsg} from 'ssb-typescript/utils'; | |
27 | 27 | import {Screens} from '../..'; |
28 | 28 | import {Palette} from '../../global-styles/palette'; |
29 | 29 | import {Dimensions} from '../../global-styles/dimens'; |
30 | 30 | import {SSBSource} from '../../drivers/ssb'; |
@@ -87,37 +87,39 @@ | ||
87 | 87 | const ReactiveScrollView = propifyMethods(ScrollView, 'scrollToEnd' as any); |
88 | 88 | |
89 | 89 | type Actions = { |
90 | 90 | publishMsg$: Stream<any>; |
91 | + willReply$: Stream<any>; | |
91 | 92 | }; |
92 | 93 | |
93 | -export default function view( | |
94 | - state$: Stream<State>, | |
95 | - ssbSource: SSBSource, | |
96 | - actions: Actions, | |
97 | -) { | |
98 | - return state$.map((state: State) => { | |
94 | +function statesAreEqual(s1: State, s2: State): boolean { | |
95 | + if (s1.replyText !== s2.replyText) return false; | |
96 | + if (s1.replyEditable !== s2.replyEditable) return false; | |
97 | + if (s1.startedAsReply !== s2.startedAsReply) return false; | |
98 | + if (s1.thread.messages.length !== s2.thread.messages.length) return false; | |
99 | + if (s1.thread.full !== s2.thread.full) return false; | |
100 | + if (s1.getSelfRepliesReadable !== s2.getSelfRepliesReadable) return false; | |
101 | + if (s1.rootMsgId !== s2.rootMsgId) return false; | |
102 | + if (s1.selfFeedId !== s2.selfFeedId) return false; | |
103 | + return true; | |
104 | +} | |
105 | + | |
106 | +export default function view(state$: Stream<State>, actions: Actions) { | |
107 | + const scrollToEnd$ = actions.publishMsg$.mapTo({animated: false}); | |
108 | + return state$.compose(dropRepeats(statesAreEqual)).map((state: State) => { | |
99 | 109 | return { |
100 | 110 | screen: Screens.Thread, |
101 | 111 | vdom: h(View, {style: styles.container}, [ |
102 | - h( | |
103 | - ReactiveScrollView, | |
104 | - { | |
105 | - style: styles.scrollView, | |
106 | - scrollToEnd$: actions.publishMsg$.mapTo({animated: false}), | |
107 | - }, | |
108 | - [ | |
109 | - state.thread.messages.length === 0 | |
110 | - ? Loading | |
111 | - : h(FullThread, { | |
112 | - selector: 'thread', | |
113 | - thread: state.thread, | |
114 | - selfFeedId: state.selfFeedId, | |
115 | - publication$: ssbSource.publishHook$.filter(isReplyPostMsg), | |
116 | - getPublicationsReadable: state.getSelfRepliesReadable, | |
117 | - }), | |
118 | - ], | |
119 | - ), | |
112 | + h(ReactiveScrollView, {style: styles.scrollView, scrollToEnd$}, [ | |
113 | + state.thread.messages.length === 0 | |
114 | + ? Loading | |
115 | + : h(FullThread, { | |
116 | + selector: 'thread', | |
117 | + thread: state.thread, | |
118 | + selfFeedId: state.selfFeedId, | |
119 | + publication$: actions.willReply$, | |
120 | + }), | |
121 | + ]), | |
120 | 122 | ReplyInput(state), |
121 | 123 | ]), |
122 | 124 | }; |
123 | 125 | }); |
Built with git-ssb-web