Skip to content

Commit 37180eb

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 37180eb

File tree

12 files changed

+253
-9
lines changed

12 files changed

+253
-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

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

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

+129
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
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 { topicModalProviderState, closeEditTopicModal, auth, zulipFeatureLevel, streamsById, _ } =
28+
props;
29+
30+
const { visible, topic, streamId } = topicModalProviderState;
31+
32+
const [topicName, onChangeTopicName] = useState();
33+
34+
useEffect(() => {
35+
onChangeTopicName(topic);
36+
}, [topic]);
37+
38+
const { backgroundColor } = useContext(ThemeContext);
39+
40+
const modalStyles = createStyleSheet({
41+
wrapper: {
42+
flex: 1,
43+
justifyContent: 'center',
44+
alignItems: 'center',
45+
},
46+
modal: {
47+
justifyContent: 'flex-start',
48+
backgroundColor,
49+
padding: 15,
50+
shadowOpacity: 0.5,
51+
shadowColor: 'gray',
52+
shadowOffset: {
53+
height: 5,
54+
width: 5,
55+
},
56+
shadowRadius: 5,
57+
borderRadius: 5,
58+
width: '90%',
59+
},
60+
buttonContainer: {
61+
flexDirection: 'row',
62+
justifyContent: 'flex-end',
63+
},
64+
titleText: {
65+
fontSize: 18,
66+
lineHeight: 21,
67+
color: BRAND_COLOR,
68+
marginBottom: 10,
69+
fontWeight: 'bold',
70+
},
71+
});
72+
73+
const handleSubmit = async () => {
74+
if (topicName === '') {
75+
return;
76+
}
77+
const messageId = await fetchSomeMessageIdForConversation(
78+
auth,
79+
streamId,
80+
topic,
81+
streamsById,
82+
zulipFeatureLevel,
83+
);
84+
if (messageId == null) {
85+
throw new Error(
86+
_('No messages in topic: {streamAndTopic}', {
87+
streamAndTopic: `#${streamsById.get(streamId)?.name ?? 'unknown'} > ${topic}`,
88+
}),
89+
);
90+
}
91+
await updateMessage(auth, messageId, {
92+
propagate_mode: 'change_all',
93+
subject: topicName,
94+
...(zulipFeatureLevel >= 9 && {
95+
send_notification_to_old_thread: true,
96+
send_notification_to_new_thread: true,
97+
}),
98+
});
99+
closeEditTopicModal();
100+
};
101+
return (
102+
<Modal
103+
transparent
104+
visible={visible}
105+
animationType="slide"
106+
onRequestClose={closeEditTopicModal}
107+
supportedOrientations={['portrait', 'landscape', 'landscape-left', 'landscape-right']}
108+
>
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 label="Cancel" onPress={closeEditTopicModal} />
123+
<ZulipTextButton label="Submit" onPress={handleSubmit} />
124+
</View>
125+
</View>
126+
</View>
127+
</Modal>
128+
);
129+
}

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 = {|

src/webview/__tests__/generateInboundEvents-test.js

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ describe('generateInboundEvents', () => {
2929
narrow: HOME_NARROW,
3030
showMessagePlaceholders: false,
3131
startEditMessage: jest.fn(),
32+
startEditTopic: jest.fn(),
3233
dispatch: jest.fn(),
3334
...baseSelectorProps,
3435
showActionSheetWithOptions: jest.fn(),

0 commit comments

Comments
 (0)