Skip to content

Commit d648c5a

Browse files
Leslie NgoLeslie Ngo
Leslie Ngo
authored and
Leslie Ngo
committed
topic edit modal: Add new edit-topic UI.
Fixes: zulip#5365
1 parent 8e3a05b commit d648c5a

File tree

12 files changed

+260
-9
lines changed

12 files changed

+260
-9
lines changed

src/ZulipMobile.js

+6-3
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import CompatibilityChecker from './boot/CompatibilityChecker';
1616
import AppEventHandlers from './boot/AppEventHandlers';
1717
import { initializeSentry } from './sentry';
1818
import ZulipSafeAreaProvider from './boot/ZulipSafeAreaProvider';
19+
import TopicEditModalProvider from './boot/TopicEditModalProvider';
1920

2021
initializeSentry();
2122

@@ -55,9 +56,11 @@ export default function ZulipMobile(): Node {
5556
<AppEventHandlers>
5657
<TranslationProvider>
5758
<ThemeProvider>
58-
<ActionSheetProvider>
59-
<ZulipNavigationContainer />
60-
</ActionSheetProvider>
59+
<TopicEditModalProvider>
60+
<ActionSheetProvider>
61+
<ZulipNavigationContainer />
62+
</ActionSheetProvider>
63+
</TopicEditModalProvider>
6164
</ThemeProvider>
6265
</TranslationProvider>
6366
</AppEventHandlers>

src/action-sheets/index.js

+18-1
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ type TopicArgs = {
7777
zulipFeatureLevel: number,
7878
dispatch: Dispatch,
7979
_: GetText,
80+
startEditTopic: (streamId: number, topic: string) => Promise<void>,
8081
...
8182
};
8283

@@ -169,6 +170,14 @@ const deleteMessage = {
169170
},
170171
};
171172

173+
const editTopic = {
174+
title: 'Edit topic',
175+
errorMessage: 'Failed to resolve topic',
176+
action: ({ streamId, topic, startEditTopic }) => {
177+
startEditTopic(streamId, topic);
178+
},
179+
};
180+
172181
const markTopicAsRead = {
173182
title: 'Mark topic as read',
174183
errorMessage: 'Failed to mark topic as read',
@@ -502,9 +511,16 @@ export const constructTopicActionButtons = (args: {|
502511

503512
const buttons = [];
504513
const unreadCount = getUnreadCountForTopic(unread, streamId, topic);
514+
const isAdmin = roleIsAtLeast(ownUserRole, Role.Admin);
505515
if (unreadCount > 0) {
506516
buttons.push(markTopicAsRead);
507517
}
518+
/* At present, the permissions for editing the topic of a message are highly complex.
519+
Until we move to a better set of policy options, we'll only display the edit topic
520+
button to admins. Further information: #21739, #M5365 */
521+
if (isAdmin) {
522+
buttons.push(editTopic);
523+
}
508524
if (isTopicMuted(streamId, topic, mute)) {
509525
buttons.push(unmuteTopic);
510526
} else {
@@ -515,7 +531,7 @@ export const constructTopicActionButtons = (args: {|
515531
} else {
516532
buttons.push(unresolveTopic);
517533
}
518-
if (roleIsAtLeast(ownUserRole, Role.Admin)) {
534+
if (isAdmin) {
519535
buttons.push(deleteTopic);
520536
}
521537
const sub = subscriptions.get(streamId);
@@ -666,6 +682,7 @@ export const showTopicActionSheet = (args: {|
666682
showActionSheetWithOptions: ShowActionSheetWithOptions,
667683
callbacks: {|
668684
dispatch: Dispatch,
685+
startEditTopic: (streamId: number, topic: string) => Promise<void>,
669686
_: GetText,
670687
|},
671688
backgroundData: $ReadOnly<{

src/boot/TopicEditModalProvider.js

+73
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/* @flow strict-local */
2+
import React, { createContext, useState, useCallback, useContext } from 'react';
3+
import type { Context, Node } from 'react';
4+
import { useSelector } from '../react-redux';
5+
import TopicEditModal from '../topics/TopicEditModal';
6+
import { getAuth, getZulipFeatureLevel, getStreamsById } from '../selectors';
7+
import { TranslationContext } from './TranslationProvider';
8+
9+
type Props = $ReadOnly<{|
10+
children: Node,
11+
|}>;
12+
13+
type StartEditTopicContext = (
14+
streamId: number,
15+
topic: string,
16+
) => Promise<void>;
17+
18+
/* $FlowIssue[incompatible-type] We can't provide an initial 'value' prop. We would
19+
need to provide the 'startEditTopic' callback that's defined after this createContext
20+
call, which is impossible. */
21+
const TopicModal: Context<StartEditTopicContext> = createContext(undefined);
22+
23+
export const useStartEditTopic = ():StartEditTopicContext => useContext(TopicModal);
24+
25+
export default function TopicEditModalProvider(props: Props): Node {
26+
const { children } = props;
27+
const auth = useSelector(getAuth);
28+
const zulipFeatureLevel = useSelector(getZulipFeatureLevel);
29+
const streamsById = useSelector(getStreamsById);
30+
const _ = useContext(TranslationContext);
31+
32+
const [topicModalProviderState, setTopicModalProviderState] = useState({
33+
visible: false,
34+
streamId: -1,
35+
topic: '',
36+
});
37+
38+
const { visible } = topicModalProviderState;
39+
40+
const startEditTopic = useCallback(
41+
async (streamId: number, topic: string) => {
42+
if (visible) {
43+
return;
44+
}
45+
setTopicModalProviderState({
46+
visible: true,
47+
streamId,
48+
topic,
49+
});
50+
}, [visible]);
51+
52+
const closeEditTopicModal = useCallback(() => {
53+
setTopicModalProviderState({
54+
visible: false,
55+
streamId: -1,
56+
topic: '',
57+
});
58+
}, []);
59+
60+
return (
61+
<TopicModal.Provider value={startEditTopic}>
62+
<TopicEditModal
63+
topicModalProviderState={topicModalProviderState}
64+
closeEditTopicModal={closeEditTopicModal}
65+
auth={auth}
66+
zulipFeatureLevel={zulipFeatureLevel}
67+
streamsById={streamsById}
68+
_={_}
69+
/>
70+
{children}
71+
</TopicModal.Provider>
72+
);
73+
}

src/chat/ChatScreen.js

+3
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { showErrorAlert } from '../utils/info';
3030
import { TranslationContext } from '../boot/TranslationProvider';
3131
import * as api from '../api';
3232
import { useConditionalEffect } from '../reactUtils';
33+
import { useStartEditTopic } from '../boot/TopicEditModalProvider';
3334

3435
type Props = $ReadOnly<{|
3536
navigation: AppNavigationProp<'chat'>,
@@ -133,6 +134,7 @@ export default function ChatScreen(props: Props): Node {
133134
(value: EditMessage | null) => navigation.setParams({ editMessage: value }),
134135
[navigation],
135136
);
137+
const startEditTopic = useStartEditTopic();
136138

137139
const isNarrowValid = useSelector(state => getIsNarrowValid(state, narrow));
138140
const draft = useSelector(state => getDraftForNarrow(state, narrow));
@@ -221,6 +223,7 @@ export default function ChatScreen(props: Props): Node {
221223
}
222224
showMessagePlaceholders={showMessagePlaceholders}
223225
startEditMessage={setEditMessage}
226+
startEditTopic={startEditTopic}
224227
/>
225228
);
226229
}

src/search/SearchMessagesCard.js

+3
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { createStyleSheet } from '../styles';
99
import LoadingIndicator from '../common/LoadingIndicator';
1010
import SearchEmptyState from '../common/SearchEmptyState';
1111
import MessageList from '../webview/MessageList';
12+
import { useStartEditTopic } from '../boot/TopicEditModalProvider';
1213

1314
const styles = createStyleSheet({
1415
results: {
@@ -24,6 +25,7 @@ type Props = $ReadOnly<{|
2425

2526
export default function SearchMessagesCard(props: Props): Node {
2627
const { narrow, isFetching, messages } = props;
28+
const startEditTopic = useStartEditTopic();
2729

2830
if (isFetching) {
2931
// Display loading indicator only if there are no messages to
@@ -55,6 +57,7 @@ export default function SearchMessagesCard(props: Props): Node {
5557
// TODO: handle editing a message from the search results,
5658
// or make this prop optional
5759
startEditMessage={() => undefined}
60+
startEditTopic={startEditTopic}
5861
/>
5962
</View>
6063
);

src/streams/TopicItem.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
import { getMute } from '../mute/muteModel';
2626
import { getUnread } from '../unread/unreadModel';
2727
import { getOwnUserRole } from '../permissionSelectors';
28+
import { useStartEditTopic } from '../boot/TopicEditModalProvider';
2829

2930
const componentStyles = createStyleSheet({
3031
selectedRow: {
@@ -70,6 +71,7 @@ export default function TopicItem(props: Props): Node {
7071
useActionSheet().showActionSheetWithOptions;
7172
const _ = useContext(TranslationContext);
7273
const dispatch = useDispatch();
74+
const startEditTopic = useStartEditTopic();
7375
const backgroundData = useSelector(state => ({
7476
auth: getAuth(state),
7577
mute: getMute(state),
@@ -88,7 +90,7 @@ export default function TopicItem(props: Props): Node {
8890
onLongPress={() => {
8991
showTopicActionSheet({
9092
showActionSheetWithOptions,
91-
callbacks: { dispatch, _ },
93+
callbacks: { dispatch, startEditTopic, _ },
9294
backgroundData,
9395
streamId,
9496
topic: name,

src/title/TitleStream.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { showStreamActionSheet, showTopicActionSheet } from '../action-sheets';
2727
import type { ShowActionSheetWithOptions } from '../action-sheets';
2828
import { getUnread } from '../unread/unreadModel';
2929
import { getOwnUserRole } from '../permissionSelectors';
30+
import { useStartEditTopic } from '../boot/TopicEditModalProvider';
3031

3132
type Props = $ReadOnly<{|
3233
narrow: Narrow,
@@ -51,6 +52,7 @@ export default function TitleStream(props: Props): Node {
5152
const { narrow, color } = props;
5253
const dispatch = useDispatch();
5354
const stream = useSelector(state => getStreamInNarrow(state, narrow));
55+
const startEditTopic = useStartEditTopic();
5456
const backgroundData = useSelector(state => ({
5557
auth: getAuth(state),
5658
mute: getMute(state),
@@ -75,7 +77,7 @@ export default function TitleStream(props: Props): Node {
7577
? () => {
7678
showTopicActionSheet({
7779
showActionSheetWithOptions,
78-
callbacks: { dispatch, _ },
80+
callbacks: { dispatch, startEditTopic, _ },
7981
backgroundData,
8082
streamId: stream.stream_id,
8183
topic: topicOfNarrow(narrow),

src/topics/TopicEditModal.js

+135
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// @flow strict-local
2+
import React, { useState, useContext, useEffect } from 'react';
3+
import { Modal, View } from 'react-native';
4+
import type { Node } from 'react';
5+
import styles, { ThemeContext, BRAND_COLOR, createStyleSheet } from '../styles';
6+
import { updateMessage } from '../api';
7+
import type { Auth, GetText, Stream } from '../types';
8+
import { fetchSomeMessageIdForConversation } from '../message/fetchActions';
9+
import ZulipTextIntl from '../common/ZulipTextIntl';
10+
import ZulipTextButton from '../common/ZulipTextButton';
11+
import Input from '../common/Input';
12+
13+
type Props = $ReadOnly<{|
14+
topicModalProviderState: {
15+
visible: boolean,
16+
topic: string,
17+
streamId: number,
18+
},
19+
auth: Auth,
20+
zulipFeatureLevel: number,
21+
streamsById: Map<number, Stream>,
22+
_: GetText,
23+
closeEditTopicModal: () => void,
24+
|}>;
25+
26+
export default function TopicEditModal(props: Props): Node {
27+
const {
28+
topicModalProviderState,
29+
closeEditTopicModal,
30+
auth,
31+
zulipFeatureLevel,
32+
streamsById,
33+
_,
34+
} = props;
35+
36+
const { visible, topic, streamId } = topicModalProviderState;
37+
38+
const [topicName, onChangeTopicName] = useState();
39+
40+
useEffect(() => {
41+
onChangeTopicName(topic);
42+
}, [topic]);
43+
44+
const { backgroundColor } = useContext(ThemeContext);
45+
46+
const modalStyles = createStyleSheet({
47+
wrapper: {
48+
flex: 1,
49+
justifyContent: 'center',
50+
alignItems: 'center',
51+
},
52+
modal: {
53+
justifyContent: 'flex-start',
54+
backgroundColor,
55+
padding: 15,
56+
shadowOpacity: 0.5,
57+
shadowColor: 'gray',
58+
shadowOffset: {
59+
height: 5,
60+
width: 5,
61+
},
62+
shadowRadius: 5,
63+
borderRadius: 5,
64+
width: '90%',
65+
},
66+
buttonContainer: {
67+
flexDirection: 'row',
68+
justifyContent: 'flex-end',
69+
},
70+
titleText: {
71+
fontSize: 18,
72+
lineHeight: 21,
73+
color: BRAND_COLOR,
74+
marginBottom: 10,
75+
fontWeight: 'bold'
76+
}
77+
});
78+
79+
const handleSubmit = async () => {
80+
if (topicName === '') {
81+
return;
82+
}
83+
const messageId = await fetchSomeMessageIdForConversation(
84+
auth,
85+
streamId,
86+
topic,
87+
streamsById,
88+
zulipFeatureLevel,
89+
);
90+
if (messageId == null) {
91+
throw new Error(
92+
_('No messages in topic: {streamAndTopic}', {
93+
streamAndTopic: `#${streamsById.get(streamId)?.name ?? 'unknown'} > ${topic}`,
94+
}),
95+
);
96+
}
97+
await updateMessage(auth, messageId, {
98+
propagate_mode: 'change_all',
99+
subject: topicName,
100+
...(zulipFeatureLevel >= 9 && {
101+
send_notification_to_old_thread: true,
102+
send_notification_to_new_thread: true,
103+
}),
104+
});
105+
closeEditTopicModal();
106+
};
107+
return (
108+
<Modal transparent visible={visible} animationType="slide" onRequestClose={closeEditTopicModal} supportedOrientations={['portrait', 'landscape', 'landscape-left', 'landscape-right']}>
109+
<View style={modalStyles.wrapper}>
110+
<View style={modalStyles.modal}>
111+
<ZulipTextIntl style={modalStyles.titleText} text="Edit topic" />
112+
<Input
113+
style={styles.marginBottom}
114+
defaultValue={topicName}
115+
placeholder="Please enter a new topic name."
116+
onChangeText={onChangeTopicName}
117+
maxLength={60}
118+
autoFocus
119+
selectTextOnFocus
120+
/>
121+
<View style={modalStyles.buttonContainer}>
122+
<ZulipTextButton
123+
label="Cancel"
124+
onPress={closeEditTopicModal}
125+
/>
126+
<ZulipTextButton
127+
label="Submit"
128+
onPress={handleSubmit}
129+
/>
130+
</View>
131+
</View>
132+
</View>
133+
</Modal>
134+
);
135+
}

src/webview/MessageList.js

+1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ type OuterProps = $ReadOnly<{|
4545
initialScrollMessageId: number | null,
4646
showMessagePlaceholders: boolean,
4747
startEditMessage: (editMessage: EditMessage) => void,
48+
startEditTopic: (streamId: number, topic: string) => Promise<void>,
4849
|}>;
4950

5051
type SelectorProps = {|

0 commit comments

Comments
 (0)