Skip to content

Commit 17f73d8

Browse files
agrawal-dgnprice
authored andcommitted
sharing: Add UI to receive and handle shares from other apps.
This completes most of the implementation of #117. To enable the feature as it currently exists, it's enough to uncomment the commented-out bit of the manifest, so that Zulip appears in the "sharing" UI when attempting to share something from another app. The reason we don't yet enable/advertise the feature is that there are some blocker bugs -- most notably, it crashes when you try to use it. That didn't use to happen, on a branch based on an older version from master, but it does now after rebasing. (The error message suggests it's probably related to our recent upgrades of the RN version we use.) The 'react-navigation' code was suggested by Chris Bobbe. Suggested-in-part-by: Chris Bobbe <[email protected]>
1 parent b4692e3 commit 17f73d8

File tree

11 files changed

+685
-10
lines changed

11 files changed

+685
-10
lines changed

android/app/src/main/AndroidManifest.xml

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,22 +41,14 @@
4141
<category android:name="android.intent.category.BROWSABLE" />
4242
<data android:scheme="zulip" android:host="login" />
4343
</intent-filter>
44-
</activity>
45-
46-
<!-- Disabled while the feature is experimental. See #117 and #4124.
47-
<activity
48-
android:name=".MainActivity"
49-
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
50-
android:label="@string/app_name"
51-
android:launchMode="singleTask"
52-
android:windowSoftInputMode="adjustResize">
44+
<!-- Disabled while the feature is experimental. See #117 and #4124.
5345
<intent-filter>
5446
<action android:name="android.intent.action.SEND" />
5547
<category android:name="android.intent.category.DEFAULT" />
5648
<data android:mimeType="*/*" />
5749
</intent-filter>
50+
-->
5851
</activity>
59-
-->
6052

6153
<!-- When `react-native run-android` learns from the decoy `package`
6254
attribute in our comment above that the application ID is

src/boot/AppEventHandlers.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
NotificationListener,
1616
notificationOnAppActive,
1717
} from '../notification';
18+
import { ShareReceivedListener, handleInitialShare } from '../sharing';
1819
import { appOnline, appOrientation, initSafeAreaInsets } from '../actions';
1920
import PresenceHeartbeat from '../presence/PresenceHeartbeat';
2021

@@ -113,6 +114,7 @@ class AppEventHandlers extends PureComponent<Props> {
113114
};
114115

115116
notificationListener = new NotificationListener(this.props.dispatch);
117+
shareListener = new ShareReceivedListener(this.props.dispatch);
116118

117119
handleMemoryWarning = () => {
118120
// Release memory here
@@ -121,6 +123,7 @@ class AppEventHandlers extends PureComponent<Props> {
121123
componentDidMount() {
122124
const { dispatch } = this.props;
123125
handleInitialNotification(dispatch);
126+
handleInitialShare(dispatch);
124127

125128
this.netInfoDisconnectCallback = NetInfo.addEventListener(this.handleConnectivityChange);
126129
AppState.addEventListener('change', this.handleAppStateChange);
@@ -130,6 +133,7 @@ class AppEventHandlers extends PureComponent<Props> {
130133
);
131134
ScreenOrientation.addOrientationChangeListener(this.handleOrientationChange);
132135
this.notificationListener.start();
136+
this.shareListener.start();
133137
}
134138

135139
componentWillUnmount() {
@@ -141,6 +145,7 @@ class AppEventHandlers extends PureComponent<Props> {
141145
AppState.removeEventListener('memoryWarning', this.handleMemoryWarning);
142146
ScreenOrientation.removeOrientationChangeListeners();
143147
this.notificationListener.stop();
148+
this.shareListener.stop();
144149
}
145150

146151
render() {

src/nav/AppNavigator.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import TopicListScreen from '../topics/TopicListScreen';
3131
import EmojiPickerScreen from '../emoji/EmojiPickerScreen';
3232
import LegalScreen from '../settings/LegalScreen';
3333
import UserStatusScreen from '../user-status/UserStatusScreen';
34+
import SharingScreen from '../sharing/SharingScreen';
3435

3536
export default createStackNavigator(
3637
// $FlowFixMe react-navigation types :-/ -- see a36814e80
@@ -65,6 +66,7 @@ export default createStackNavigator(
6566
notifications: { screen: NotificationsScreen },
6667
legal: { screen: LegalScreen },
6768
'user-status': { screen: UserStatusScreen },
69+
sharing: { screen: SharingScreen },
6870
},
6971
{
7072
initialRouteName: 'main',

src/nav/navActions.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
Narrow,
1010
UserOrBot,
1111
ApiResponseServerSettings,
12+
SharedData,
1213
} from '../types';
1314
import { getSameRoutesCount } from '../selectors';
1415

@@ -103,3 +104,6 @@ export const navigateToLegal = (): NavigationAction => StackActions.push({ route
103104

104105
export const navigateToUserStatus = (): NavigationAction =>
105106
StackActions.push({ routeName: 'user-status' });
107+
108+
export const navigateToSharing = (sharedData: SharedData): NavigationAction =>
109+
StackActions.push({ routeName: 'sharing', params: { sharedData } });
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/* @flow strict-local */
2+
import React, { PureComponent } from 'react';
3+
import type { User, Dispatch } from '../types';
4+
import { connect } from '../react-redux';
5+
import { Screen } from '../common';
6+
import UserPickerCard from '../user-picker/UserPickerCard';
7+
8+
type Props = $ReadOnly<{|
9+
dispatch: Dispatch,
10+
onComplete: (User[]) => void,
11+
|}>;
12+
13+
type State = {|
14+
filter: string,
15+
|};
16+
17+
class ChooseRecipientsScreen extends PureComponent<Props, State> {
18+
state = {
19+
filter: '',
20+
};
21+
22+
handleFilterChange = (filter: string) => this.setState({ filter });
23+
24+
handleComplete = (selected: Array<User>) => {
25+
const { onComplete } = this.props;
26+
onComplete(selected);
27+
};
28+
29+
render() {
30+
const { filter } = this.state;
31+
return (
32+
<Screen
33+
search
34+
scrollEnabled={false}
35+
searchBarOnChange={this.handleFilterChange}
36+
canGoBack={false}
37+
>
38+
<UserPickerCard filter={filter} onComplete={this.handleComplete} />
39+
</Screen>
40+
);
41+
}
42+
}
43+
44+
export default connect<{||}, _, _>()(ChooseRecipientsScreen);

src/sharing/ShareToPm.js

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
/* @flow strict-local */
2+
import React from 'react';
3+
import { View, StyleSheet, Image, ScrollView, Modal, BackHandler } from 'react-native';
4+
import { type NavigationNavigatorProps } from 'react-navigation';
5+
import type { Dispatch, SharedData, User, Auth, GetText } from '../types';
6+
import { TranslationContext } from '../boot/TranslationProvider';
7+
8+
import { connect } from '../react-redux';
9+
import { ZulipButton, Input, Label } from '../common';
10+
import UserItem from '../users/UserItem';
11+
import { sendMessage, uploadFile } from '../api';
12+
import { getAuth } from '../selectors';
13+
import { showToast } from '../utils/info';
14+
import { navigateBack } from '../nav/navActions';
15+
import ChooseRecipientsScreen from './ChooseRecipientsScreen';
16+
17+
const styles = StyleSheet.create({
18+
wrapper: {
19+
flex: 1,
20+
padding: 10,
21+
},
22+
imagePreview: {
23+
margin: 10,
24+
borderRadius: 5,
25+
width: 200,
26+
height: 200,
27+
},
28+
container: {
29+
flex: 1,
30+
},
31+
actions: {
32+
flexDirection: 'row',
33+
},
34+
button: {
35+
flex: 1,
36+
margin: 8,
37+
},
38+
usersPreview: {
39+
padding: 10,
40+
},
41+
chooseButton: {
42+
marginTop: 8,
43+
marginBottom: 8,
44+
width: '50%',
45+
alignSelf: 'flex-end',
46+
},
47+
message: {
48+
height: 70,
49+
borderRadius: 5,
50+
borderWidth: 1,
51+
borderColor: 'whitesmoke',
52+
padding: 5,
53+
},
54+
});
55+
56+
type Props = $ReadOnly<{|
57+
...$Exact<NavigationNavigatorProps<{||}, {| params: {| sharedData: SharedData |} |}>>,
58+
dispatch: Dispatch,
59+
auth: Auth,
60+
|}>;
61+
62+
type State = $ReadOnly<{|
63+
selectedRecipients: User[],
64+
message: string,
65+
choosingRecipients: boolean,
66+
sending: boolean,
67+
|}>;
68+
69+
class ShareToPm extends React.Component<Props, State> {
70+
static contextType = TranslationContext;
71+
context: GetText;
72+
73+
constructor(props) {
74+
super(props);
75+
const { sharedData } = this.props.navigation.state.params;
76+
this.state = {
77+
selectedRecipients: [],
78+
message: sharedData.type === 'text' ? sharedData.sharedText : '',
79+
choosingRecipients: false,
80+
sending: false,
81+
};
82+
}
83+
84+
setSending = () => {
85+
this.setState({ sending: true });
86+
};
87+
88+
handleChooseRecipients = (selectedRecipients: Array<User>) => {
89+
this.setState({ selectedRecipients });
90+
this.setState({ choosingRecipients: false });
91+
};
92+
93+
handleSend = async () => {
94+
this.setSending();
95+
96+
const _ = this.context;
97+
const { selectedRecipients, message } = this.state;
98+
let messageToSend = message;
99+
const { auth } = this.props;
100+
const { sharedData } = this.props.navigation.state.params;
101+
const to = JSON.stringify(selectedRecipients.map(user => user.user_id));
102+
103+
try {
104+
showToast(_('Sending Message...'));
105+
106+
if (sharedData.type === 'image' || sharedData.type === 'file') {
107+
const url =
108+
sharedData.type === 'image' ? sharedData.sharedImageUrl : sharedData.sharedFileUrl;
109+
const fileName = url.split('/').pop();
110+
const response = await uploadFile(auth, url, fileName);
111+
messageToSend += `\n[${fileName}](${response.uri})`;
112+
}
113+
await sendMessage(auth, { content: messageToSend, type: 'private', to });
114+
} catch (err) {
115+
showToast(_('Failed to send message'));
116+
this.finishShare();
117+
return;
118+
}
119+
120+
showToast(_('Message sent'));
121+
this.finishShare();
122+
};
123+
124+
finishShare = () => {
125+
const { dispatch } = this.props;
126+
127+
dispatch(navigateBack());
128+
BackHandler.exitApp();
129+
};
130+
131+
handleMessageChange = message => {
132+
this.setState({ message });
133+
};
134+
135+
isSendButtonEnabled = () => {
136+
const { message, selectedRecipients } = this.state;
137+
const { sharedData } = this.props.navigation.state.params;
138+
139+
if (sharedData.type === 'text') {
140+
return message !== '' && selectedRecipients.length > 0;
141+
}
142+
143+
return selectedRecipients.length > 0;
144+
};
145+
146+
renderUsersPreview = () => {
147+
const { selectedRecipients } = this.state;
148+
149+
if (selectedRecipients.length === 0) {
150+
return <Label text="Please choose recipients to share with" />;
151+
}
152+
const preview = [];
153+
selectedRecipients.forEach((user: User) => {
154+
preview.push(
155+
<UserItem
156+
avatarUrl={user.avatar_url}
157+
email={user.email}
158+
fullName={user.full_name}
159+
onPress={() => {}}
160+
key={user.user_id}
161+
/>,
162+
);
163+
});
164+
return preview;
165+
};
166+
167+
render() {
168+
const { message, choosingRecipients, sending } = this.state;
169+
170+
if (choosingRecipients) {
171+
return (
172+
<Modal>
173+
<ChooseRecipientsScreen onComplete={this.handleChooseRecipients} />
174+
</Modal>
175+
);
176+
}
177+
178+
const { sharedData } = this.props.navigation.state.params;
179+
let sharePreview = null;
180+
if (sharedData.type === 'image') {
181+
sharePreview = (
182+
<Image
183+
source={{ uri: sharedData.sharedImageUrl }}
184+
width={200}
185+
height={200}
186+
style={styles.imagePreview}
187+
/>
188+
);
189+
}
190+
191+
return (
192+
<>
193+
<ScrollView style={styles.wrapper} keyboardShouldPersistTaps="always">
194+
<View style={styles.container}>{sharePreview}</View>
195+
<View style={styles.usersPreview}>{this.renderUsersPreview()}</View>
196+
<ZulipButton
197+
onPress={() => this.setState({ choosingRecipients: true })}
198+
style={styles.chooseButton}
199+
text="Choose recipients"
200+
/>
201+
<Input
202+
value={message}
203+
placeholder="Message"
204+
onChangeText={this.handleMessageChange}
205+
multiline
206+
/>
207+
</ScrollView>
208+
<View style={styles.actions}>
209+
<ZulipButton onPress={this.finishShare} style={styles.button} secondary text="Cancel" />
210+
<ZulipButton
211+
style={styles.button}
212+
onPress={this.handleSend}
213+
text="Send"
214+
progress={sending}
215+
disabled={!this.isSendButtonEnabled()}
216+
/>
217+
</View>
218+
</>
219+
);
220+
}
221+
}
222+
223+
export default connect(state => ({
224+
auth: getAuth(state),
225+
}))(ShareToPm);

0 commit comments

Comments
 (0)