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" }