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..bb6993d09f3 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) => Promise,
...
};
@@ -251,6 +252,14 @@ const toggleResolveTopic = async ({ auth, streamId, topic, _, streams, zulipFeat
});
};
+const editTopic = {
+ title: 'Edit topic',
+ errorMessage: 'Failed to resolve topic',
+ action: ({ streamId, topic, startEditTopic }) => {
+ startEditTopic(streamId, topic);
+ },
+};
+
const resolveTopic = {
title: 'Resolve topic',
errorMessage: 'Failed to resolve topic',
@@ -502,9 +511,14 @@ export const constructTopicActionButtons = (args: {|
const buttons = [];
const unreadCount = getUnreadCountForTopic(unread, streamId, topic);
+ const isAdmin = roleIsAtLeast(ownUserRole, Role.Admin);
if (unreadCount > 0) {
buttons.push(markTopicAsRead);
}
+ // Set back to isAdmin after testing feature
+ if (true) {
+ buttons.push(editTopic);
+ }
if (isTopicMuted(streamId, topic, mute)) {
buttons.push(unmuteTopic);
} else {
@@ -515,7 +529,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 +680,7 @@ export const showTopicActionSheet = (args: {|
showActionSheetWithOptions: ShowActionSheetWithOptions,
callbacks: {|
dispatch: Dispatch,
+ startEditTopic: (streamId: number, topic: string) => Promise,
_: GetText,
|},
backgroundData: $ReadOnly<{
diff --git a/src/api/index.js b/src/api/index.js
index be8d8f7aa6a..a84055978c8 100644
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -30,6 +30,7 @@ import deleteMessage from './messages/deleteMessage';
import deleteTopic from './messages/deleteTopic';
import getRawMessageContent from './messages/getRawMessageContent';
import getMessages from './messages/getMessages';
+import getSingleMessage from './messages/getSingleMessage';
import getMessageHistory from './messages/getMessageHistory';
import messagesFlags from './messages/messagesFlags';
import sendMessage from './messages/sendMessage';
@@ -78,6 +79,7 @@ export {
deleteTopic,
getRawMessageContent,
getMessages,
+ getSingleMessage,
getMessageHistory,
messagesFlags,
sendMessage,
diff --git a/src/api/messages/getSingleMessage.js b/src/api/messages/getSingleMessage.js
new file mode 100644
index 00000000000..703652840a8
--- /dev/null
+++ b/src/api/messages/getSingleMessage.js
@@ -0,0 +1,55 @@
+/* @flow strict-local */
+
+import type { Auth, ApiResponseSuccess } from '../transportTypes';
+import type { Message } from '../apiTypes';
+import { transformFetchedMessage, type FetchedMessage } from '../rawModelTypes';
+import { apiGet } from '../apiFetch';
+import { identityOfAuth } from '../../account/accountMisc';
+
+// The actual response from the server. We convert the message to a proper
+// Message before returning it to application code.
+type ServerApiResponseSingleMessage = {|
+ ...$Exact,
+ -raw_content: string, // deprecated
+
+ // Until we narrow FetchedMessage into its FL 120+ form, FetchedMessage
+ // will be a bit less precise than we could be here. That's because we
+ // only get this field from servers FL 120+.
+ // TODO(server-5.0): Make this field required, and remove FL-120 comment.
+ +message?: FetchedMessage,
+|};
+
+/**
+ * See https://zulip.com/api/get-message
+ *
+ * Gives undefined if the `message` field is missing, which it will be for
+ * FL <120.
+ */
+// TODO(server-5.0): Simplify FL-120 condition in jsdoc and implementation.
+export default async (
+ auth: Auth,
+ args: {|
+ +message_id: number,
+ |},
+
+ // TODO(#4659): Don't get this from callers.
+ zulipFeatureLevel: number,
+
+ // TODO(#4659): Don't get this from callers?
+ allowEditHistory: boolean,
+): Promise => {
+ const { message_id } = args;
+ const response: ServerApiResponseSingleMessage = await apiGet(auth, `messages/${message_id}`, {
+ apply_markdown: true,
+ });
+
+ return (
+ response.message
+ && transformFetchedMessage(
+ response.message,
+ identityOfAuth(auth),
+ zulipFeatureLevel,
+ allowEditHistory,
+ )
+ );
+};
diff --git a/src/boot/TopicEditModalProvider.js b/src/boot/TopicEditModalProvider.js
new file mode 100644
index 00000000000..50f0e10c64b
--- /dev/null
+++ b/src/boot/TopicEditModalProvider.js
@@ -0,0 +1,70 @@
+/* @flow strict-local */
+import React, { createContext, useState, useCallback, useContext } from 'react';
+import type { Context, Node } from 'react';
+import { useSelector } from '../react-redux';
+import TopicEditModal from '../topics/TopicEditModal';
+import { getAuth, getZulipFeatureLevel, getStreamsById } from '../selectors';
+import { TranslationContext } from './TranslationProvider';
+
+type Props = $ReadOnly<{|
+ children: Node,
+|}>;
+
+type StartEditTopicContext = (
+ streamId: number,
+ topic: string,
+) => Promise;
+
+// $FlowIssue[incompatible-type]
+const TopicModal: Context = createContext(undefined);
+
+export const useStartEditTopic = ():StartEditTopicContext => useContext(TopicModal);
+
+export default function TopicEditModalProvider(props: Props): Node {
+ const { children } = props;
+ const auth = useSelector(getAuth);
+ const zulipFeatureLevel = useSelector(getZulipFeatureLevel);
+ const streamsById = useSelector(getStreamsById);
+ const _ = useContext(TranslationContext);
+
+ const [topicModalProviderState, setTopicModalProviderState] = useState({
+ visible: false,
+ streamId: -1,
+ topic: '',
+ });
+
+ const startEditTopic = useCallback(
+ async (streamId: number, topic: string) => {
+ const { visible } = topicModalProviderState;
+ if (visible) {
+ return;
+ }
+ setTopicModalProviderState({
+ visible: true,
+ streamId,
+ topic,
+ });
+ }, [topicModalProviderState]);
+
+ const closeEditTopicModal = useCallback(() => {
+ setTopicModalProviderState({
+ visible: false,
+ streamId: -1,
+ topic: '',
+ });
+ }, []);
+
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/src/chat/ChatScreen.js b/src/chat/ChatScreen.js
index 9342c1f4f53..0dd1a74f23c 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'>,
@@ -127,6 +128,7 @@ const useMessagesWithFetch = args => {
export default function ChatScreen(props: Props): Node {
const { route, navigation } = props;
const { backgroundColor } = React.useContext(ThemeContext);
+ const startEditTopic = useStartEditTopic();
const { narrow, editMessage } = route.params;
const setEditMessage = useCallback(
@@ -221,6 +223,7 @@ export default function ChatScreen(props: Props): Node {
}
showMessagePlaceholders={showMessagePlaceholders}
startEditMessage={setEditMessage}
+ startEditTopic={startEditTopic}
/>
);
}
diff --git a/src/search/SearchMessagesCard.js b/src/search/SearchMessagesCard.js
index bf34f5877cb..ede07efd0d4 100644
--- a/src/search/SearchMessagesCard.js
+++ b/src/search/SearchMessagesCard.js
@@ -1,6 +1,6 @@
/* @flow strict-local */
-import React, { PureComponent } from 'react';
+import React from 'react';
import type { Node } from 'react';
import { View } from 'react-native';
@@ -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: {
@@ -22,42 +23,42 @@ type Props = $ReadOnly<{|
isFetching: boolean,
|}>;
-export default class SearchMessagesCard extends PureComponent {
- render(): Node {
- const { isFetching, messages } = this.props;
+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
- // display from a previous search.
- if (!messages || messages.length === 0) {
- return ;
- }
- }
-
- if (!messages) {
- return null;
+ if (isFetching) {
+ // Display loading indicator only if there are no messages to
+ // display from a previous search.
+ if (!messages || messages.length === 0) {
+ return ;
}
+ }
- if (messages.length === 0) {
- return ;
- }
+ if (!messages) {
+ return null;
+ }
- return (
-
- undefined}
- />
-
- );
+ if (messages.length === 0) {
+ return ;
}
+
+ return (
+
+ 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..d1b927d5e8e 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,
@@ -67,6 +68,7 @@ export default function TitleStream(props: Props): Node {
const showActionSheetWithOptions: ShowActionSheetWithOptions =
useActionSheet().showActionSheetWithOptions;
const _ = useContext(TranslationContext);
+ const startEditTopic = useStartEditTopic();
return (
{
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..17143a869ca
--- /dev/null
+++ b/src/topics/TopicEditModal.js
@@ -0,0 +1,186 @@
+// @flow strict-local
+import React, { useState, useContext, useMemo, useEffect } from 'react';
+import { Modal, Pressable, View, TextInput, Platform } from 'react-native';
+import type { Node } from 'react';
+import { ThemeContext, BRAND_COLOR, createStyleSheet } from '../styles';
+import { updateMessage } from '../api';
+import type { Auth, GetText, Stream } from '../types';
+import { fetchSomeMessageIdForConversation } from '../message/fetchActions';
+import ZulipTextIntl from '../common/ZulipTextIntl';
+
+type Props = $ReadOnly<{|
+ topicModalProviderState: {
+ visible: boolean,
+ topic: string,
+ streamId: number,
+ },
+ auth: Auth,
+ zulipFeatureLevel: number,
+ streamsById: Map,
+ _: GetText,
+ closeEditTopicModal: () => void,
+|}>;
+
+export default function TopicEditModal(props: Props): Node {
+ const {
+ topicModalProviderState,
+ closeEditTopicModal,
+ auth,
+ zulipFeatureLevel,
+ streamsById,
+ _,
+ } = props;
+
+ const { visible, topic, streamId } = topicModalProviderState;
+
+ const [topicName, onChangeTopicName] = useState();
+
+ useEffect(() => {
+ onChangeTopicName(topic);
+ }, [topic]);
+
+ const { backgroundColor } = useContext(ThemeContext);
+
+ const inputMarginPadding = useMemo(
+ () => ({
+ paddingHorizontal: 8,
+ paddingVertical: Platform.select({
+ ios: 8,
+ android: 2,
+ }),
+ }),
+ [],
+ );
+
+ const styles = createStyleSheet({
+ wrapper: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ modalView: {
+ margin: 10,
+ alignItems: 'center',
+ backgroundColor,
+ padding: 20,
+ borderStyle: 'solid',
+ borderWidth: 1,
+ borderColor: 'gray',
+ borderRadius: 20,
+ width: '90%',
+ },
+ buttonContainer: {
+ display: 'flex',
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ width: '60%',
+ },
+ input: {
+ width: '90%',
+ borderWidth: 1,
+ borderRadius: 5,
+ marginBottom: 16,
+ ...inputMarginPadding,
+ backgroundColor: 'white',
+ borderStyle: 'solid',
+ borderColor: 'black',
+ },
+ button: {
+ position: 'relative',
+ justifyContent: 'center',
+ alignItems: 'center',
+ borderRadius: 32,
+ padding: 8,
+ },
+ titleText: {
+ fontSize: 18,
+ lineHeight: 21,
+ fontWeight: 'bold',
+ color: BRAND_COLOR,
+ marginBottom: 12,
+ },
+ text: {
+ fontSize: 14,
+ lineHeight: 21,
+ fontWeight: 'bold',
+ },
+ submitButtonText: {
+ color: 'white',
+ },
+ cancelButtonText: {
+ color: BRAND_COLOR,
+ },
+ });
+
+ const handleSubmit = async () => {
+ const messageId = await fetchSomeMessageIdForConversation(
+ auth,
+ streamId,
+ topic,
+ streamsById,
+ zulipFeatureLevel,
+ );
+ if (messageId == null) {
+ throw new Error(
+ _('No messages in topic: {streamAndTopic}', {
+ streamAndTopic: `#${streamsById.get(streamId)?.name ?? 'unknown'} > ${topic}`,
+ }),
+ );
+ }
+ await updateMessage(auth, messageId, {
+ propagate_mode: 'change_all',
+ subject: topicName,
+ ...(zulipFeatureLevel >= 9 && {
+ send_notification_to_old_thread: true,
+ send_notification_to_new_thread: true,
+ }),
+ });
+ closeEditTopicModal();
+ };
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/webview/MessageList.js b/src/webview/MessageList.js
index 8e6e7353560..68699bccd8d 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) => Promise,
|}>;
type SelectorProps = {|
@@ -153,6 +154,7 @@ class MessageListInner extends Component {
doNotMarkMessagesAsRead,
_,
} = this.props;
+
const contentHtml = messageListElementsForShownMessages
.map(element =>
messageListElementHtml({
@@ -291,7 +293,6 @@ const MessageList: ComponentType = connect(
// they should probably turn into a `connectGlobal` call.
const globalSettings = getGlobalSettings(assumeSecretlyGlobalState(state));
const debug = getDebug(assumeSecretlyGlobalState(state));
-
return {
backgroundData: getBackgroundData(state, globalSettings, debug),
fetching: getFetchingForNarrow(state, props.narrow),
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..cf8a7127bdb 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) => Promise,
...
}>;
@@ -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..65b74e2fb26 100644
--- a/static/translations/messages_en.json
+++ b/static/translations/messages_en.json
@@ -337,5 +337,7 @@
"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"
}