Skip to content

Commit

Permalink
api: Add getSingleMessage binding for GET messages/{message_id}
Browse files Browse the repository at this point in the history
We'll use this for zulip#5306; see the plan in discussion:
  https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/.23M5306.20Follow.20.2Fnear.2F.20links.20through.20topic.20moves.2Frenames/near/1407930

In particular, we want the stream and topic for a stream message
that might not be in our data structures. We'll use this endpoint to
fetch that information.

topic edit modal [nfc]: Add TopicModalProvider context component.

Contains visibility context and handler callback context.
Sets up context for modal handler to be called inside topic action sheets.

topic edit modal [nfc]: Provide topic modal Context hook to children.

The useTopicModalHandler is called normally in TopicItem and TitleStream.
In order to deliver the callbacks to the action sheets in MessageList, the context hook
is called in ChatScreen and a bit of prop-drilling is performed.

topic edit modal: Add translation for action sheet button.

topic edit modal: Add modal and functionality to perform topic name updates.

Fixes zulip#5365

topid edit modal [nfc]: Revise Flow types for relevant components.

topic edit modal: Modify webview unit tests to accommodate feature update.
  • Loading branch information
chrisbobbe authored and Leslie Ngo committed Sep 28, 2022
1 parent 04d2c95 commit 5cc72d4
Show file tree
Hide file tree
Showing 15 changed files with 430 additions and 26 deletions.
9 changes: 6 additions & 3 deletions src/ZulipMobile.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import CompatibilityChecker from './boot/CompatibilityChecker';
import AppEventHandlers from './boot/AppEventHandlers';
import { initializeSentry } from './sentry';
import ZulipSafeAreaProvider from './boot/ZulipSafeAreaProvider';
import TopicModalProvider from './boot/TopicModalProvider';

initializeSentry();

Expand Down Expand Up @@ -55,9 +56,11 @@ export default function ZulipMobile(): Node {
<AppEventHandlers>
<TranslationProvider>
<ThemeProvider>
<ActionSheetProvider>
<ZulipNavigationContainer />
</ActionSheetProvider>
<TopicModalProvider>
<ActionSheetProvider>
<ZulipNavigationContainer />
</ActionSheetProvider>
</TopicModalProvider>
</ThemeProvider>
</TranslationProvider>
</AppEventHandlers>
Expand Down
21 changes: 21 additions & 0 deletions src/action-sheets/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ type TopicArgs = {
zulipFeatureLevel: number,
dispatch: Dispatch,
_: GetText,
startEditTopic: (
streamId: number,
topic: string,
streamsById: Map<number, Stream>,
_: GetText,
) => Promise<void>,
...
};

Expand Down Expand Up @@ -251,6 +257,14 @@ const toggleResolveTopic = async ({ auth, streamId, topic, _, streams, zulipFeat
});
};

const editTopic = {
title: 'Edit topic',
errorMessage: 'Failed to resolve topic',
action: ({ streamId, topic, streams, _, startEditTopic }) => {
startEditTopic(streamId, topic, streams, _);
},
};

const resolveTopic = {
title: 'Resolve topic',
errorMessage: 'Failed to resolve topic',
Expand Down Expand Up @@ -505,6 +519,7 @@ export const constructTopicActionButtons = (args: {|
if (unreadCount > 0) {
buttons.push(markTopicAsRead);
}
buttons.push(editTopic);
if (isTopicMuted(streamId, topic, mute)) {
buttons.push(unmuteTopic);
} else {
Expand Down Expand Up @@ -666,6 +681,12 @@ export const showTopicActionSheet = (args: {|
showActionSheetWithOptions: ShowActionSheetWithOptions,
callbacks: {|
dispatch: Dispatch,
startEditTopic: (
streamId: number,
topic: string,
streamsById: Map<number, Stream>,
_: GetText,
) => Promise<void>,
_: GetText,
|},
backgroundData: $ReadOnly<{
Expand Down
2 changes: 2 additions & 0 deletions src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -78,6 +79,7 @@ export {
deleteTopic,
getRawMessageContent,
getMessages,
getSingleMessage,
getMessageHistory,
messagesFlags,
sendMessage,
Expand Down
55 changes: 55 additions & 0 deletions src/api/messages/getSingleMessage.js
Original file line number Diff line number Diff line change
@@ -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<ApiResponseSuccess>,
-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<Message | void> => {
const { message_id } = args;
const response: ServerApiResponseSingleMessage = await apiGet(auth, `messages/${message_id}`, {
apply_markdown: true,
});

return (
response.message
&& transformFetchedMessage<Message>(
response.message,
identityOfAuth(auth),
zulipFeatureLevel,
allowEditHistory,
)
);
};
92 changes: 92 additions & 0 deletions src/boot/TopicModalProvider.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/* @flow strict-local */
import React, { createContext, useState, useMemo, useCallback, useContext } from 'react';
import type { Context, Node } from 'react';
import { useSelector } from '../react-redux';
import TopicEditModal from '../topics/TopicEditModal';
import type { Stream, GetText } from '../types';
import { fetchSomeMessageIdForConversation } from '../message/fetchActions';
import { getAuth, getZulipFeatureLevel } from '../selectors';

type Props = $ReadOnly<{|
children: Node,
|}>;

type TopicModalContext = $ReadOnly<{|
startEditTopic: (
streamId: number,
topic: string,
streamsById: Map<number, Stream>,
_: GetText,
) => Promise<void>,
closeEditTopicModal: () => void,
|}>;

// $FlowIssue[incompatible-type]
const TopicModal: Context<TopicModalContext> = createContext(undefined);

export const useTopicModalHandler = (): TopicModalContext => useContext(TopicModal);

export default function TopicModalProvider(props: Props): Node {
const { children } = props;
const auth = useSelector(getAuth);
const zulipFeatureLevel = useSelector(getZulipFeatureLevel);
const [topicModalState, setTopicModalState] = useState({
visible: false,
topic: '',
fetchArgs: {
auth: null,
messageId: null,
zulipFeatureLevel: null,
},
});

const startEditTopic = useCallback(
async (streamId, topic, streamsById, _) => {
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}`,
}),
);
}
setTopicModalState({
visible: true,
topic,
fetchArgs: { auth, messageId, zulipFeatureLevel },
});
},
[auth, zulipFeatureLevel],
);

const closeEditTopicModal = useCallback(() => {
setTopicModalState({
visible: false,
topic: null,
fetchArgs: { auth: null, messageId: null, zulipFeatureLevel: null },
});
}, []);

const topicModalHandler = useMemo(
() => ({
startEditTopic,
closeEditTopicModal,
}),
[startEditTopic, closeEditTopicModal],
);

return (
<TopicModal.Provider value={topicModalHandler}>
{topicModalState.visible && (
<TopicEditModal topicModalState={topicModalState} topicModalHandler={topicModalHandler} />
)}
{children}
</TopicModal.Provider>
);
}
3 changes: 3 additions & 0 deletions src/chat/ChatScreen.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { showErrorAlert } from '../utils/info';
import { TranslationContext } from '../boot/TranslationProvider';
import * as api from '../api';
import { useConditionalEffect } from '../reactUtils';
import { useTopicModalHandler } from '../boot/TopicModalProvider';

type Props = $ReadOnly<{|
navigation: AppNavigationProp<'chat'>,
Expand Down Expand Up @@ -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 } = useTopicModalHandler();

const { narrow, editMessage } = route.params;
const setEditMessage = useCallback(
Expand Down Expand Up @@ -221,6 +223,7 @@ export default function ChatScreen(props: Props): Node {
}
showMessagePlaceholders={showMessagePlaceholders}
startEditMessage={setEditMessage}
startEditTopic={startEditTopic}
/>
);
}
Expand Down
34 changes: 34 additions & 0 deletions src/search/MessageListWrapper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/* @flow strict-local */
import React from 'react';
import type { Node } from 'react';
import MessageList from '../webview/MessageList';
import { useTopicModalHandler } from '../boot/TopicModalProvider';
import type { Message, Narrow } from '../types';

type Props = $ReadOnly<{|
messages: $ReadOnlyArray<Message>,
narrow: Narrow,
|}>;

/* We can't call Context hooks from SearchMessagesCard because it's a class component. This wrapper allows the startEditTopic callback to be passed to this particular MessageList child without breaking Rules of Hooks. */

export default function MessageListWrapper({ messages, narrow }: Props): Node {
const { startEditTopic } = useTopicModalHandler();

return (
<MessageList
initialScrollMessageId={
// This access is OK only because of the `.length === 0` check
// above.
messages[messages.length - 1].id
}
messages={messages}
narrow={narrow}
showMessagePlaceholders={false}
// TODO: handle editing a message from the search results,
// or make this prop optional
startEditMessage={() => undefined}
startEditTopic={startEditTopic}
/>
);
}
19 changes: 3 additions & 16 deletions src/search/SearchMessagesCard.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { Message, Narrow } from '../types';
import { createStyleSheet } from '../styles';
import LoadingIndicator from '../common/LoadingIndicator';
import SearchEmptyState from '../common/SearchEmptyState';
import MessageList from '../webview/MessageList';
import MessageListWrapper from './MessageListWrapper';

const styles = createStyleSheet({
results: {
Expand All @@ -24,8 +24,7 @@ type Props = $ReadOnly<{|

export default class SearchMessagesCard extends PureComponent<Props> {
render(): Node {
const { isFetching, messages } = this.props;

const { isFetching, messages, narrow } = this.props;
if (isFetching) {
// Display loading indicator only if there are no messages to
// display from a previous search.
Expand All @@ -44,19 +43,7 @@ export default class SearchMessagesCard extends PureComponent<Props> {

return (
<View style={styles.results}>
<MessageList
initialScrollMessageId={
// This access is OK only because of the `.length === 0` check
// above.
messages[messages.length - 1].id
}
messages={messages}
narrow={this.props.narrow}
showMessagePlaceholders={false}
// TODO: handle editing a message from the search results,
// or make this prop optional
startEditMessage={() => undefined}
/>
<MessageListWrapper messages={messages} narrow={narrow} />
</View>
);
}
Expand Down
4 changes: 3 additions & 1 deletion src/streams/TopicItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import { getMute } from '../mute/muteModel';
import { getUnread } from '../unread/unreadModel';
import { getOwnUserRole } from '../permissionSelectors';
import { useTopicModalHandler } from '../boot/TopicModalProvider';

const componentStyles = createStyleSheet({
selectedRow: {
Expand Down Expand Up @@ -70,6 +71,7 @@ export default function TopicItem(props: Props): Node {
useActionSheet().showActionSheetWithOptions;
const _ = useContext(TranslationContext);
const dispatch = useDispatch();
const { startEditTopic } = useTopicModalHandler();
const backgroundData = useSelector(state => ({
auth: getAuth(state),
mute: getMute(state),
Expand All @@ -88,7 +90,7 @@ export default function TopicItem(props: Props): Node {
onLongPress={() => {
showTopicActionSheet({
showActionSheetWithOptions,
callbacks: { dispatch, _ },
callbacks: { dispatch, startEditTopic, _ },
backgroundData,
streamId,
topic: name,
Expand Down
4 changes: 3 additions & 1 deletion src/title/TitleStream.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 { useTopicModalHandler } from '../boot/TopicModalProvider';

type Props = $ReadOnly<{|
narrow: Narrow,
Expand Down Expand Up @@ -67,6 +68,7 @@ export default function TitleStream(props: Props): Node {
const showActionSheetWithOptions: ShowActionSheetWithOptions =
useActionSheet().showActionSheetWithOptions;
const _ = useContext(TranslationContext);
const { startEditTopic } = useTopicModalHandler();

return (
<TouchableWithoutFeedback
Expand All @@ -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),
Expand Down
Loading

0 comments on commit 5cc72d4

Please sign in to comment.