Skip to content

Commit 5cc72d4

Browse files
chrisbobbeLeslie Ngo
authored and
Leslie Ngo
committed
api: Add getSingleMessage binding for GET messages/{message_id}
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.
1 parent 04d2c95 commit 5cc72d4

File tree

15 files changed

+430
-26
lines changed

15 files changed

+430
-26
lines changed

src/ZulipMobile.js

Lines changed: 6 additions & 3 deletions
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 TopicModalProvider from './boot/TopicModalProvider';
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+
<TopicModalProvider>
60+
<ActionSheetProvider>
61+
<ZulipNavigationContainer />
62+
</ActionSheetProvider>
63+
</TopicModalProvider>
6164
</ThemeProvider>
6265
</TranslationProvider>
6366
</AppEventHandlers>

src/action-sheets/index.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ type TopicArgs = {
7777
zulipFeatureLevel: number,
7878
dispatch: Dispatch,
7979
_: GetText,
80+
startEditTopic: (
81+
streamId: number,
82+
topic: string,
83+
streamsById: Map<number, Stream>,
84+
_: GetText,
85+
) => Promise<void>,
8086
...
8187
};
8288

@@ -251,6 +257,14 @@ const toggleResolveTopic = async ({ auth, streamId, topic, _, streams, zulipFeat
251257
});
252258
};
253259

260+
const editTopic = {
261+
title: 'Edit topic',
262+
errorMessage: 'Failed to resolve topic',
263+
action: ({ streamId, topic, streams, _, startEditTopic }) => {
264+
startEditTopic(streamId, topic, streams, _);
265+
},
266+
};
267+
254268
const resolveTopic = {
255269
title: 'Resolve topic',
256270
errorMessage: 'Failed to resolve topic',
@@ -505,6 +519,7 @@ export const constructTopicActionButtons = (args: {|
505519
if (unreadCount > 0) {
506520
buttons.push(markTopicAsRead);
507521
}
522+
buttons.push(editTopic);
508523
if (isTopicMuted(streamId, topic, mute)) {
509524
buttons.push(unmuteTopic);
510525
} else {
@@ -666,6 +681,12 @@ export const showTopicActionSheet = (args: {|
666681
showActionSheetWithOptions: ShowActionSheetWithOptions,
667682
callbacks: {|
668683
dispatch: Dispatch,
684+
startEditTopic: (
685+
streamId: number,
686+
topic: string,
687+
streamsById: Map<number, Stream>,
688+
_: GetText,
689+
) => Promise<void>,
669690
_: GetText,
670691
|},
671692
backgroundData: $ReadOnly<{

src/api/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import deleteMessage from './messages/deleteMessage';
3030
import deleteTopic from './messages/deleteTopic';
3131
import getRawMessageContent from './messages/getRawMessageContent';
3232
import getMessages from './messages/getMessages';
33+
import getSingleMessage from './messages/getSingleMessage';
3334
import getMessageHistory from './messages/getMessageHistory';
3435
import messagesFlags from './messages/messagesFlags';
3536
import sendMessage from './messages/sendMessage';
@@ -78,6 +79,7 @@ export {
7879
deleteTopic,
7980
getRawMessageContent,
8081
getMessages,
82+
getSingleMessage,
8183
getMessageHistory,
8284
messagesFlags,
8385
sendMessage,

src/api/messages/getSingleMessage.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/* @flow strict-local */
2+
3+
import type { Auth, ApiResponseSuccess } from '../transportTypes';
4+
import type { Message } from '../apiTypes';
5+
import { transformFetchedMessage, type FetchedMessage } from '../rawModelTypes';
6+
import { apiGet } from '../apiFetch';
7+
import { identityOfAuth } from '../../account/accountMisc';
8+
9+
// The actual response from the server. We convert the message to a proper
10+
// Message before returning it to application code.
11+
type ServerApiResponseSingleMessage = {|
12+
...$Exact<ApiResponseSuccess>,
13+
-raw_content: string, // deprecated
14+
15+
// Until we narrow FetchedMessage into its FL 120+ form, FetchedMessage
16+
// will be a bit less precise than we could be here. That's because we
17+
// only get this field from servers FL 120+.
18+
// TODO(server-5.0): Make this field required, and remove FL-120 comment.
19+
+message?: FetchedMessage,
20+
|};
21+
22+
/**
23+
* See https://zulip.com/api/get-message
24+
*
25+
* Gives undefined if the `message` field is missing, which it will be for
26+
* FL <120.
27+
*/
28+
// TODO(server-5.0): Simplify FL-120 condition in jsdoc and implementation.
29+
export default async (
30+
auth: Auth,
31+
args: {|
32+
+message_id: number,
33+
|},
34+
35+
// TODO(#4659): Don't get this from callers.
36+
zulipFeatureLevel: number,
37+
38+
// TODO(#4659): Don't get this from callers?
39+
allowEditHistory: boolean,
40+
): Promise<Message | void> => {
41+
const { message_id } = args;
42+
const response: ServerApiResponseSingleMessage = await apiGet(auth, `messages/${message_id}`, {
43+
apply_markdown: true,
44+
});
45+
46+
return (
47+
response.message
48+
&& transformFetchedMessage<Message>(
49+
response.message,
50+
identityOfAuth(auth),
51+
zulipFeatureLevel,
52+
allowEditHistory,
53+
)
54+
);
55+
};

src/boot/TopicModalProvider.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/* @flow strict-local */
2+
import React, { createContext, useState, useMemo, 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 type { Stream, GetText } from '../types';
7+
import { fetchSomeMessageIdForConversation } from '../message/fetchActions';
8+
import { getAuth, getZulipFeatureLevel } from '../selectors';
9+
10+
type Props = $ReadOnly<{|
11+
children: Node,
12+
|}>;
13+
14+
type TopicModalContext = $ReadOnly<{|
15+
startEditTopic: (
16+
streamId: number,
17+
topic: string,
18+
streamsById: Map<number, Stream>,
19+
_: GetText,
20+
) => Promise<void>,
21+
closeEditTopicModal: () => void,
22+
|}>;
23+
24+
// $FlowIssue[incompatible-type]
25+
const TopicModal: Context<TopicModalContext> = createContext(undefined);
26+
27+
export const useTopicModalHandler = (): TopicModalContext => useContext(TopicModal);
28+
29+
export default function TopicModalProvider(props: Props): Node {
30+
const { children } = props;
31+
const auth = useSelector(getAuth);
32+
const zulipFeatureLevel = useSelector(getZulipFeatureLevel);
33+
const [topicModalState, setTopicModalState] = useState({
34+
visible: false,
35+
topic: '',
36+
fetchArgs: {
37+
auth: null,
38+
messageId: null,
39+
zulipFeatureLevel: null,
40+
},
41+
});
42+
43+
const startEditTopic = useCallback(
44+
async (streamId, topic, streamsById, _) => {
45+
const messageId = await fetchSomeMessageIdForConversation(
46+
auth,
47+
streamId,
48+
topic,
49+
streamsById,
50+
zulipFeatureLevel,
51+
);
52+
if (messageId == null) {
53+
throw new Error(
54+
_('No messages in topic: {streamAndTopic}', {
55+
streamAndTopic: `#${streamsById.get(streamId)?.name ?? 'unknown'} > ${topic}`,
56+
}),
57+
);
58+
}
59+
setTopicModalState({
60+
visible: true,
61+
topic,
62+
fetchArgs: { auth, messageId, zulipFeatureLevel },
63+
});
64+
},
65+
[auth, zulipFeatureLevel],
66+
);
67+
68+
const closeEditTopicModal = useCallback(() => {
69+
setTopicModalState({
70+
visible: false,
71+
topic: null,
72+
fetchArgs: { auth: null, messageId: null, zulipFeatureLevel: null },
73+
});
74+
}, []);
75+
76+
const topicModalHandler = useMemo(
77+
() => ({
78+
startEditTopic,
79+
closeEditTopicModal,
80+
}),
81+
[startEditTopic, closeEditTopicModal],
82+
);
83+
84+
return (
85+
<TopicModal.Provider value={topicModalHandler}>
86+
{topicModalState.visible && (
87+
<TopicEditModal topicModalState={topicModalState} topicModalHandler={topicModalHandler} />
88+
)}
89+
{children}
90+
</TopicModal.Provider>
91+
);
92+
}

src/chat/ChatScreen.js

Lines changed: 3 additions & 0 deletions
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 { useTopicModalHandler } from '../boot/TopicModalProvider';
3334

3435
type Props = $ReadOnly<{|
3536
navigation: AppNavigationProp<'chat'>,
@@ -127,6 +128,7 @@ const useMessagesWithFetch = args => {
127128
export default function ChatScreen(props: Props): Node {
128129
const { route, navigation } = props;
129130
const { backgroundColor } = React.useContext(ThemeContext);
131+
const { startEditTopic } = useTopicModalHandler();
130132

131133
const { narrow, editMessage } = route.params;
132134
const setEditMessage = useCallback(
@@ -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/MessageListWrapper.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/* @flow strict-local */
2+
import React from 'react';
3+
import type { Node } from 'react';
4+
import MessageList from '../webview/MessageList';
5+
import { useTopicModalHandler } from '../boot/TopicModalProvider';
6+
import type { Message, Narrow } from '../types';
7+
8+
type Props = $ReadOnly<{|
9+
messages: $ReadOnlyArray<Message>,
10+
narrow: Narrow,
11+
|}>;
12+
13+
/* 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. */
14+
15+
export default function MessageListWrapper({ messages, narrow }: Props): Node {
16+
const { startEditTopic } = useTopicModalHandler();
17+
18+
return (
19+
<MessageList
20+
initialScrollMessageId={
21+
// This access is OK only because of the `.length === 0` check
22+
// above.
23+
messages[messages.length - 1].id
24+
}
25+
messages={messages}
26+
narrow={narrow}
27+
showMessagePlaceholders={false}
28+
// TODO: handle editing a message from the search results,
29+
// or make this prop optional
30+
startEditMessage={() => undefined}
31+
startEditTopic={startEditTopic}
32+
/>
33+
);
34+
}

src/search/SearchMessagesCard.js

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type { Message, Narrow } from '../types';
88
import { createStyleSheet } from '../styles';
99
import LoadingIndicator from '../common/LoadingIndicator';
1010
import SearchEmptyState from '../common/SearchEmptyState';
11-
import MessageList from '../webview/MessageList';
11+
import MessageListWrapper from './MessageListWrapper';
1212

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

2525
export default class SearchMessagesCard extends PureComponent<Props> {
2626
render(): Node {
27-
const { isFetching, messages } = this.props;
28-
27+
const { isFetching, messages, narrow } = this.props;
2928
if (isFetching) {
3029
// Display loading indicator only if there are no messages to
3130
// display from a previous search.
@@ -44,19 +43,7 @@ export default class SearchMessagesCard extends PureComponent<Props> {
4443

4544
return (
4645
<View style={styles.results}>
47-
<MessageList
48-
initialScrollMessageId={
49-
// This access is OK only because of the `.length === 0` check
50-
// above.
51-
messages[messages.length - 1].id
52-
}
53-
messages={messages}
54-
narrow={this.props.narrow}
55-
showMessagePlaceholders={false}
56-
// TODO: handle editing a message from the search results,
57-
// or make this prop optional
58-
startEditMessage={() => undefined}
59-
/>
46+
<MessageListWrapper messages={messages} narrow={narrow} />
6047
</View>
6148
);
6249
}

src/streams/TopicItem.js

Lines changed: 3 additions & 1 deletion
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 { useTopicModalHandler } from '../boot/TopicModalProvider';
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 } = useTopicModalHandler();
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

Lines changed: 3 additions & 1 deletion
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 { useTopicModalHandler } from '../boot/TopicModalProvider';
3031

3132
type Props = $ReadOnly<{|
3233
narrow: Narrow,
@@ -67,6 +68,7 @@ export default function TitleStream(props: Props): Node {
6768
const showActionSheetWithOptions: ShowActionSheetWithOptions =
6869
useActionSheet().showActionSheetWithOptions;
6970
const _ = useContext(TranslationContext);
71+
const { startEditTopic } = useTopicModalHandler();
7072

7173
return (
7274
<TouchableWithoutFeedback
@@ -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),

0 commit comments

Comments
 (0)