diff --git a/src/ZulipMobile.js b/src/ZulipMobile.js
index 3de98c302f0..12eaaeb2860 100644
--- a/src/ZulipMobile.js
+++ b/src/ZulipMobile.js
@@ -16,6 +16,7 @@ import CompatibilityChecker from './boot/CompatibilityChecker';
import AppEventHandlers from './boot/AppEventHandlers';
import { initializeSentry } from './sentry';
import ZulipSafeAreaProvider from './boot/ZulipSafeAreaProvider';
+import TopicEditModalProvider from './boot/TopicEditModalProvider';
initializeSentry();
@@ -55,9 +56,11 @@ export default function ZulipMobile(): Node {
-
-
-
+
+
+
+
+
diff --git a/src/action-sheets/index.js b/src/action-sheets/index.js
index c8bc17b9e3f..39ddd7a5c75 100644
--- a/src/action-sheets/index.js
+++ b/src/action-sheets/index.js
@@ -77,6 +77,7 @@ type TopicArgs = {
zulipFeatureLevel: number,
dispatch: Dispatch,
_: GetText,
+ startEditTopic: (streamId: number, topic: string) => void,
...
};
@@ -169,6 +170,14 @@ const deleteMessage = {
},
};
+const editTopic = {
+ title: 'Edit topic',
+ errorMessage: 'Failed to edit topic',
+ action: ({ streamId, topic, startEditTopic }) => {
+ startEditTopic(streamId, topic);
+ },
+};
+
const markTopicAsRead = {
title: 'Mark topic as read',
errorMessage: 'Failed to mark topic as read',
@@ -502,9 +511,18 @@ export const constructTopicActionButtons = (args: {|
const buttons = [];
const unreadCount = getUnreadCountForTopic(unread, streamId, topic);
+ const isAdmin = roleIsAtLeast(ownUserRole, Role.Admin);
if (unreadCount > 0) {
buttons.push(markTopicAsRead);
}
+ // At present, the permissions for editing the topic of a message are highly complex.
+ // Until we move to a better set of policy options, we'll only display the edit topic
+ // button to admins.
+ // Issue: https://github.com/zulip/zulip/issues/21739
+ // Relevant comment: https://github.com/zulip/zulip-mobile/issues/5365#issuecomment-1197093294
+ if (isAdmin) {
+ buttons.push(editTopic);
+ }
if (isTopicMuted(streamId, topic, mute)) {
buttons.push(unmuteTopic);
} else {
@@ -515,7 +533,7 @@ export const constructTopicActionButtons = (args: {|
} else {
buttons.push(unresolveTopic);
}
- if (roleIsAtLeast(ownUserRole, Role.Admin)) {
+ if (isAdmin) {
buttons.push(deleteTopic);
}
const sub = subscriptions.get(streamId);
@@ -666,6 +684,7 @@ export const showTopicActionSheet = (args: {|
showActionSheetWithOptions: ShowActionSheetWithOptions,
callbacks: {|
dispatch: Dispatch,
+ startEditTopic: (streamId: number, topic: string) => void,
_: GetText,
|},
backgroundData: $ReadOnly<{
diff --git a/src/boot/TopicEditModalProvider.js b/src/boot/TopicEditModalProvider.js
new file mode 100644
index 00000000000..6a2b9685c82
--- /dev/null
+++ b/src/boot/TopicEditModalProvider.js
@@ -0,0 +1,57 @@
+/* @flow strict-local */
+import React, { createContext, useState, useCallback, useContext } from 'react';
+import type { Context, Node } from 'react';
+
+import TopicEditModal from '../topics/TopicEditModal';
+
+type Props = $ReadOnly<{|
+ children: Node,
+|}>;
+
+type TopicEditModalProviderState = {
+ streamId: number,
+ oldTopic: string,
+} | null;
+
+type StartEditTopicContext = (streamId: number, oldTopic: string) => void;
+
+const TopicEditModalContext: Context = createContext(() => {
+ throw new Error(
+ 'Tried to open the edit-topic UI from a component without TopicEditModalProvider above it in the tree.',
+ );
+});
+
+export const useStartEditTopic = (): StartEditTopicContext => useContext(TopicEditModalContext);
+
+export default function TopicEditModalProvider(props: Props): Node {
+ const { children } = props;
+
+ const [topicModalProviderState, setTopicModalProviderState] =
+ useState(null);
+
+ const startEditTopic = useCallback(
+ (streamIdArg, oldTopicArg) => {
+ if (!topicModalProviderState) {
+ setTopicModalProviderState({
+ streamId: streamIdArg,
+ oldTopic: oldTopicArg,
+ });
+ }
+ },
+ [topicModalProviderState],
+ );
+
+ const closeEditTopicModal = () => {
+ setTopicModalProviderState(null);
+ };
+
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/src/chat/ChatScreen.js b/src/chat/ChatScreen.js
index 9342c1f4f53..316034f3a45 100644
--- a/src/chat/ChatScreen.js
+++ b/src/chat/ChatScreen.js
@@ -30,6 +30,7 @@ import { showErrorAlert } from '../utils/info';
import { TranslationContext } from '../boot/TranslationProvider';
import * as api from '../api';
import { useConditionalEffect } from '../reactUtils';
+import { useStartEditTopic } from '../boot/TopicEditModalProvider';
type Props = $ReadOnly<{|
navigation: AppNavigationProp<'chat'>,
@@ -133,6 +134,7 @@ export default function ChatScreen(props: Props): Node {
(value: EditMessage | null) => navigation.setParams({ editMessage: value }),
[navigation],
);
+ const startEditTopic = useStartEditTopic();
const isNarrowValid = useSelector(state => getIsNarrowValid(state, narrow));
const draft = useSelector(state => getDraftForNarrow(state, narrow));
@@ -221,6 +223,7 @@ export default function ChatScreen(props: Props): Node {
}
showMessagePlaceholders={showMessagePlaceholders}
startEditMessage={setEditMessage}
+ startEditTopic={startEditTopic}
/>
);
}
diff --git a/src/common/ZulipTextButton.js b/src/common/ZulipTextButton.js
index 9436f8d5ba0..a391c0d022f 100644
--- a/src/common/ZulipTextButton.js
+++ b/src/common/ZulipTextButton.js
@@ -78,13 +78,13 @@ type Props = $ReadOnly<{|
/**
* True just if the button is in the disabled state.
- * https://material.io/design/interaction/states.html#disabled
+ *
+ * https://material.io/design/interaction/states.html#disabled
*/
disabled?: boolean,
/**
- * Whether `onPress` is used even when
- * `disabled` is true.
+ * Whether `onPress` is used even when `disabled` is true.
*/
isPressHandledWhenDisabled?: boolean,
diff --git a/src/search/SearchMessagesCard.js b/src/search/SearchMessagesCard.js
index da94b7d4386..ede07efd0d4 100644
--- a/src/search/SearchMessagesCard.js
+++ b/src/search/SearchMessagesCard.js
@@ -9,6 +9,7 @@ import { createStyleSheet } from '../styles';
import LoadingIndicator from '../common/LoadingIndicator';
import SearchEmptyState from '../common/SearchEmptyState';
import MessageList from '../webview/MessageList';
+import { useStartEditTopic } from '../boot/TopicEditModalProvider';
const styles = createStyleSheet({
results: {
@@ -24,6 +25,7 @@ type Props = $ReadOnly<{|
export default function SearchMessagesCard(props: Props): Node {
const { narrow, isFetching, messages } = props;
+ const startEditTopic = useStartEditTopic();
if (isFetching) {
// Display loading indicator only if there are no messages to
@@ -55,6 +57,7 @@ export default function SearchMessagesCard(props: Props): Node {
// TODO: handle editing a message from the search results,
// or make this prop optional
startEditMessage={() => undefined}
+ startEditTopic={startEditTopic}
/>
);
diff --git a/src/streams/TopicItem.js b/src/streams/TopicItem.js
index d60e6b4f5fd..2d58dc2e3b6 100644
--- a/src/streams/TopicItem.js
+++ b/src/streams/TopicItem.js
@@ -25,6 +25,7 @@ import {
import { getMute } from '../mute/muteModel';
import { getUnread } from '../unread/unreadModel';
import { getOwnUserRole } from '../permissionSelectors';
+import { useStartEditTopic } from '../boot/TopicEditModalProvider';
const componentStyles = createStyleSheet({
selectedRow: {
@@ -70,6 +71,7 @@ export default function TopicItem(props: Props): Node {
useActionSheet().showActionSheetWithOptions;
const _ = useContext(TranslationContext);
const dispatch = useDispatch();
+ const startEditTopic = useStartEditTopic();
const backgroundData = useSelector(state => ({
auth: getAuth(state),
mute: getMute(state),
@@ -88,7 +90,7 @@ export default function TopicItem(props: Props): Node {
onLongPress={() => {
showTopicActionSheet({
showActionSheetWithOptions,
- callbacks: { dispatch, _ },
+ callbacks: { dispatch, startEditTopic, _ },
backgroundData,
streamId,
topic: name,
diff --git a/src/title/TitleStream.js b/src/title/TitleStream.js
index a6f4190dbfc..db6ec4997c6 100644
--- a/src/title/TitleStream.js
+++ b/src/title/TitleStream.js
@@ -27,6 +27,7 @@ import { showStreamActionSheet, showTopicActionSheet } from '../action-sheets';
import type { ShowActionSheetWithOptions } from '../action-sheets';
import { getUnread } from '../unread/unreadModel';
import { getOwnUserRole } from '../permissionSelectors';
+import { useStartEditTopic } from '../boot/TopicEditModalProvider';
type Props = $ReadOnly<{|
narrow: Narrow,
@@ -51,6 +52,7 @@ export default function TitleStream(props: Props): Node {
const { narrow, color } = props;
const dispatch = useDispatch();
const stream = useSelector(state => getStreamInNarrow(state, narrow));
+ const startEditTopic = useStartEditTopic();
const backgroundData = useSelector(state => ({
auth: getAuth(state),
mute: getMute(state),
@@ -75,7 +77,7 @@ export default function TitleStream(props: Props): Node {
? () => {
showTopicActionSheet({
showActionSheetWithOptions,
- callbacks: { dispatch, _ },
+ callbacks: { dispatch, startEditTopic, _ },
backgroundData,
streamId: stream.stream_id,
topic: topicOfNarrow(narrow),
diff --git a/src/topics/TopicEditModal.js b/src/topics/TopicEditModal.js
new file mode 100644
index 00000000000..9ff440ffb4e
--- /dev/null
+++ b/src/topics/TopicEditModal.js
@@ -0,0 +1,184 @@
+/* @flow strict-local */
+import React, { useState, useContext, useEffect, useMemo } from 'react';
+import { Modal, View } from 'react-native';
+import type { Node } from 'react';
+
+import { useSelector } from '../react-redux';
+import styles, { ThemeContext, createStyleSheet } from '../styles';
+import * as api from '../api';
+import { fetchSomeMessageIdForConversation } from '../message/fetchActions';
+import ZulipTextIntl from '../common/ZulipTextIntl';
+import ZulipTextButton from '../common/ZulipTextButton';
+import Input from '../common/Input';
+import { getAuth, getZulipFeatureLevel, getStreamsById, getRealm } from '../selectors';
+import { TranslationContext } from '../boot/TranslationProvider';
+import { showErrorAlert } from '../utils/info';
+import { ensureUnreachable } from '../generics';
+
+type Props = $ReadOnly<{|
+ topicModalProviderState: {
+ oldTopic: string,
+ streamId: number,
+ } | null,
+ closeEditTopicModal: () => void,
+|}>;
+
+export default function TopicEditModal(props: Props): Node {
+ const { topicModalProviderState, closeEditTopicModal } = props;
+
+ const auth = useSelector(getAuth);
+ const zulipFeatureLevel = useSelector(getZulipFeatureLevel);
+ const streamsById = useSelector(getStreamsById);
+ const mandatoryTopics = useSelector(state => getRealm(state).mandatoryTopics);
+ const _ = useContext(TranslationContext);
+ const { backgroundColor } = useContext(ThemeContext);
+
+ const { oldTopic, streamId } = topicModalProviderState ?? {};
+
+ const [newTopicInputValue, setNewTopicInputValue] = useState(oldTopic);
+
+ // This resets the input value when we enter a new topic-editing session.
+ useEffect(() => {
+ setNewTopicInputValue(oldTopic);
+ }, [oldTopic]);
+
+ const validationErrors = useMemo(() => {
+ const result = [];
+ if (newTopicInputValue !== null && typeof newTopicInputValue === 'string') {
+ const trimmedInput = newTopicInputValue.trim();
+ if (mandatoryTopics && trimmedInput === '') {
+ result.push('mandatory-topic-empty');
+ }
+ if (trimmedInput === oldTopic) {
+ result.push('user-did-not-edit');
+ }
+ // Max topic length:
+ // https://zulip.com/api/update-message#parameter-topic
+ if (trimmedInput.length > 60) {
+ result.push('topic-too-long');
+ }
+ }
+ return result;
+ }, [mandatoryTopics, newTopicInputValue, oldTopic]);
+
+ const handleSubmit = async () => {
+ if (validationErrors.length > 0) {
+ const errorMessages = validationErrors
+ .map(error => {
+ switch (error) {
+ case 'mandatory-topic-empty':
+ return _('Topic is required in this stream.');
+ case 'user-did-not-edit':
+ return _("You haven't made any changes.");
+ case 'topic-too-long':
+ return _('Topic is too long (Max 60 characters).');
+ default:
+ ensureUnreachable(error);
+ throw new Error();
+ }
+ })
+ .join('\n\n');
+ showErrorAlert(errorMessages);
+ return;
+ }
+ if (!topicModalProviderState) {
+ throw new Error(_('Topic, streamId, or input value null.'));
+ }
+ try {
+ const messageId = await fetchSomeMessageIdForConversation(
+ auth,
+ streamId,
+ oldTopic,
+ streamsById,
+ zulipFeatureLevel,
+ );
+ if (messageId == null) {
+ throw new Error(
+ _('No messages in topic: {streamAndTopic}', {
+ streamAndTopic: `#${streamsById.get(streamId)?.name ?? 'unknown'} > ${oldTopic}`,
+ }),
+ );
+ }
+ await api.updateMessage(auth, messageId, {
+ propagate_mode: 'change_all',
+ subject: newTopicInputValue.trim(),
+ ...(zulipFeatureLevel >= 9 && {
+ send_notification_to_old_thread: true,
+ send_notification_to_new_thread: true,
+ }),
+ });
+ } catch (error) {
+ showErrorAlert(_('Failed to edit topic.'));
+ } finally {
+ closeEditTopicModal();
+ }
+ };
+
+ const modalStyles = createStyleSheet({
+ backdrop: {
+ position: 'absolute',
+ // backgroundColor and opacity aim to mimic the the darkened background
+ // of toggled action sheets.
+ backgroundColor: 'black',
+ opacity: 0.25,
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ },
+ wrapper: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ modal: {
+ // these values are borrowed from Popup.js
+ justifyContent: 'flex-start',
+ backgroundColor,
+ padding: 15,
+ shadowOpacity: 0.5,
+ elevation: 8,
+ shadowRadius: 16,
+ borderRadius: 5,
+ width: 280,
+ },
+ buttonContainer: {
+ flexDirection: 'row',
+ justifyContent: 'flex-end',
+ },
+ titleText: {
+ fontSize: 18,
+ lineHeight: 21,
+ marginBottom: 10,
+ fontWeight: 'bold',
+ },
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/webview/MessageList.js b/src/webview/MessageList.js
index 8e6e7353560..97f5a4ebd91 100644
--- a/src/webview/MessageList.js
+++ b/src/webview/MessageList.js
@@ -45,6 +45,7 @@ type OuterProps = $ReadOnly<{|
initialScrollMessageId: number | null,
showMessagePlaceholders: boolean,
startEditMessage: (editMessage: EditMessage) => void,
+ startEditTopic: (streamId: number, topic: string) => void,
|}>;
type SelectorProps = {|
diff --git a/src/webview/__tests__/generateInboundEvents-test.js b/src/webview/__tests__/generateInboundEvents-test.js
index e870d420522..f6db1fb1b9e 100644
--- a/src/webview/__tests__/generateInboundEvents-test.js
+++ b/src/webview/__tests__/generateInboundEvents-test.js
@@ -29,6 +29,7 @@ describe('generateInboundEvents', () => {
narrow: HOME_NARROW,
showMessagePlaceholders: false,
startEditMessage: jest.fn(),
+ startEditTopic: jest.fn(),
dispatch: jest.fn(),
...baseSelectorProps,
showActionSheetWithOptions: jest.fn(),
diff --git a/src/webview/handleOutboundEvents.js b/src/webview/handleOutboundEvents.js
index c74a1592b8d..8c798f416c8 100644
--- a/src/webview/handleOutboundEvents.js
+++ b/src/webview/handleOutboundEvents.js
@@ -169,6 +169,7 @@ type Props = $ReadOnly<{
doNotMarkMessagesAsRead: boolean,
showActionSheetWithOptions: ShowActionSheetWithOptions,
startEditMessage: (editMessage: EditMessage) => void,
+ startEditTopic: (streamId: number, topic: string) => void,
...
}>;
@@ -222,12 +223,19 @@ const handleLongPress = (
if (!message) {
return;
}
- const { dispatch, showActionSheetWithOptions, backgroundData, narrow, startEditMessage } = props;
+ const {
+ dispatch,
+ showActionSheetWithOptions,
+ backgroundData,
+ narrow,
+ startEditMessage,
+ startEditTopic,
+ } = props;
if (target === 'header') {
if (message.type === 'stream') {
showTopicActionSheet({
showActionSheetWithOptions,
- callbacks: { dispatch, _ },
+ callbacks: { dispatch, startEditTopic, _ },
backgroundData,
streamId: message.stream_id,
topic: message.subject,
diff --git a/static/translations/messages_en.json b/static/translations/messages_en.json
index 7e508fe32f5..cb011014876 100644
--- a/static/translations/messages_en.json
+++ b/static/translations/messages_en.json
@@ -337,5 +337,11 @@
"Copy link to stream": "Copy link to stream",
"Failed to copy stream link": "Failed to copy stream link",
"A stream with this name already exists.": "A stream with this name already exists.",
- "Streams": "Streams"
+ "Streams": "Streams",
+ "Edit topic": "Edit topic",
+ "Submit": "Submit",
+ "Topic is required in this stream.": "Topic is required in this stream.",
+ "You haven't made any changes.": "You haven't made any changes.",
+ "Topic is too long (Max 60 characters).": "Topic is too long (Max 60 characters).",
+ "Failed to edit topic.": "Failed to edit topic."
}