diff --git a/src/action-sheets/index.js b/src/action-sheets/index.js index e0199d8d57a..b98b2df4b33 100644 --- a/src/action-sheets/index.js +++ b/src/action-sheets/index.js @@ -38,6 +38,7 @@ import { navigateToEmojiPicker, navigateToStream, fetchSomeMessageIdForConversation, + navigateToSearch, } from '../actions'; import { navigateToMessageReactionScreen, @@ -331,6 +332,14 @@ const unsubscribe = { }, }; +const searchMessage = { + title: 'Search in stream', + errorMessage: 'Failed to open search', + action: ({ streamId, streams }) => { + NavigationService.dispatch(navigateToSearch(streamId, streams.get(streamId)?.name)); + }, +}; + const pinToTop = { title: 'Pin to top', errorMessage: 'Failed to pin to top', @@ -471,6 +480,7 @@ export const constructStreamActionButtons = (args: {| buttons.push(subscribe); } buttons.push(showStreamSettings); + buttons.push(searchMessage); buttons.push(cancel); return buttons; }; diff --git a/src/common/Input.js b/src/common/Input.js index 60f51268092..28fcca97a6e 100644 --- a/src/common/Input.js +++ b/src/common/Input.js @@ -11,6 +11,7 @@ export type Props = $ReadOnly<{| ...React$ElementConfig, placeholder: LocalizableText, onChangeText?: (text: string) => void, + prefixText?: string, // We should replace the fixme with // `React$ElementRef` when we can. Currently, that @@ -47,7 +48,7 @@ const componentStyles = createStyleSheet({ * See upstream: https://reactnative.dev/docs/textinput */ export default function Input(props: Props): Node { - const { style, placeholder, textInputRef, ...restProps } = props; + const { style, placeholder, textInputRef, prefixText, ...restProps } = props; const [isFocused, setIsFocused] = useState(false); @@ -76,6 +77,7 @@ export default function Input(props: Props): Node { onFocus={handleFocus} onBlur={handleBlur} ref={textInputRef} + value={prefixText} {...restProps} /> ); diff --git a/src/common/InputWithClearButton.js b/src/common/InputWithClearButton.js index 8bc8bba0e5b..8cbfa173d43 100644 --- a/src/common/InputWithClearButton.js +++ b/src/common/InputWithClearButton.js @@ -24,9 +24,9 @@ type Props = $ReadOnly<$Diff(''); + const [text, setText] = useState(prefixText ?? ''); // We should replace the fixme with // `React$ElementRef` when we can. Currently, that diff --git a/src/common/Screen.js b/src/common/Screen.js index 95085f00d70..578d6a57a78 100644 --- a/src/common/Screen.js +++ b/src/common/Screen.js @@ -48,6 +48,7 @@ type Props = $ReadOnly<{| searchBarOnSubmit?: (e: EditingEvent) => void, shouldShowLoadingBanner?: boolean, searchPlaceholder?: LocalizableText, + searchPrefixText?: string, canGoBack?: boolean, title?: LocalizableReactText, @@ -88,6 +89,7 @@ export default function Screen(props: Props): Node { scrollEnabled = true, search = false, searchPlaceholder, + searchPrefixText, searchBarOnChange = (text: string) => {}, style, title = '', @@ -108,6 +110,7 @@ export default function Screen(props: Props): Node { searchBarOnChange={searchBarOnChange} searchBarOnSubmit={searchBarOnSubmit} placeholder={searchPlaceholder} + prefixText={searchPrefixText} /> ) : ( diff --git a/src/common/SearchInput.js b/src/common/SearchInput.js index 9f153a00ba2..59a5b587b4e 100644 --- a/src/common/SearchInput.js +++ b/src/common/SearchInput.js @@ -25,6 +25,7 @@ type Props = $ReadOnly<{| onChangeText: (text: string) => void, onSubmitEditing: (e: EditingEvent) => mixed, placeholder?: LocalizableText, + prefixText?: string, |}>; /** @@ -35,7 +36,13 @@ type Props = $ReadOnly<{| * @prop onChangeText - Event called when search query is edited. */ export default function SearchInput(props: Props): Node { - const { autoFocus = true, onChangeText, onSubmitEditing, placeholder = 'Search' } = props; + const { + autoFocus = true, + onChangeText, + onSubmitEditing, + placeholder = 'Search', + prefixText, + } = props; return ( @@ -51,6 +58,7 @@ export default function SearchInput(props: Props): Node { onChangeText={onChangeText} autoFocus={autoFocus} onSubmitEditing={onSubmitEditing} + prefixText={prefixText} /> ); diff --git a/src/nav/ModalSearchNavBar.js b/src/nav/ModalSearchNavBar.js index 24c87b1a634..f3e6c9be0f5 100644 --- a/src/nav/ModalSearchNavBar.js +++ b/src/nav/ModalSearchNavBar.js @@ -15,6 +15,7 @@ type Props = $ReadOnly<{| searchBarOnChange: (text: string) => void, searchBarOnSubmit: (e: EditingEvent) => void, placeholder?: LocalizableText, + prefixText?: string, canGoBack?: boolean, |}>; @@ -23,7 +24,14 @@ export default function ModalSearchNavBar(props: Props): Node { // // For details, see comment at ModalNavBar. - const { autoFocus, searchBarOnChange, canGoBack = true, searchBarOnSubmit, placeholder } = props; + const { + autoFocus, + searchBarOnChange, + canGoBack = true, + searchBarOnSubmit, + placeholder, + prefixText, + } = props; const { backgroundColor } = useContext(ThemeContext); return ( ); diff --git a/src/nav/navActions.js b/src/nav/navActions.js index ebfcf2ac94e..6df51aed0c9 100644 --- a/src/nav/navActions.js +++ b/src/nav/navActions.js @@ -50,7 +50,8 @@ export const replaceWithChat = (narrow: Narrow): GenericNavigationAction => export const navigateToUsersScreen = (): GenericNavigationAction => StackActions.push('users'); -export const navigateToSearch = (): GenericNavigationAction => StackActions.push('search-messages'); +export const navigateToSearch = (streamId?: number, streamName?: string): GenericNavigationAction => + StackActions.push('search-messages', { streamId, streamName }); export const navigateToEmojiPicker = ( onPressEmoji: ({| +type: EmojiType, +code: string, +name: string |}) => void, diff --git a/src/search/SearchMessagesScreen.js b/src/search/SearchMessagesScreen.js index 71232611219..de0c9dd449b 100644 --- a/src/search/SearchMessagesScreen.js +++ b/src/search/SearchMessagesScreen.js @@ -18,7 +18,7 @@ import { fetchMessages } from '../message/fetchActions'; type OuterProps = $ReadOnly<{| // These should be passed from React Navigation navigation: AppNavigationProp<'search-messages'>, - route: RouteProp<'search-messages', void>, + route: RouteProp<'search-messages', {| streamId?: number, streamName?: string |}>, |}>; type SelectorProps = $ReadOnly<{| @@ -63,9 +63,14 @@ class SearchMessagesScreenInner extends PureComponent { * Stores the fetched messages in the Redux store. Does not read any * of the component's data except `props.dispatch`. */ - fetchSearchMessages = async (query: string): Promise<$ReadOnlyArray> => { + fetchSearchMessages = async ( + query: string, + inStream: boolean, + ): Promise<$ReadOnlyArray> => { const fetchArgs = { - narrow: SEARCH_NARROW(query), + narrow: inStream + ? SEARCH_NARROW(query, this.props.route.params.streamId) + : SEARCH_NARROW(query), anchor: LAST_MESSAGE_ANCHOR, numBefore: 20, numAfter: 0, @@ -90,7 +95,17 @@ class SearchMessagesScreenInner extends PureComponent { // invalidate outstanding requests on change will require more work. handleQuerySubmit = async (e: EditingEvent) => { - const query = e.nativeEvent.text; + const searchString = e.nativeEvent.text; + let query; + if ( + this.props.route.params.streamName !== undefined + && searchString.substring(0, 7) === 'stream:' + ) { + const prefixLength = `stream:${this.props.route.params.streamName.replace(' ', '+')}`.length; + query = searchString.substring(prefixLength + 1); + } else { + query = searchString; + } const id = ++this.lastIdSent; if (query === '') { @@ -103,7 +118,10 @@ class SearchMessagesScreenInner extends PureComponent { this.setState({ isFetching: true }); try { - const messages = await this.fetchSearchMessages(query); + const messages = await this.fetchSearchMessages( + query, + searchString.substring(0, 7) === 'stream:', + ); // Update `state.messages` if this is our new latest result. if (id > this.lastIdSuccess) { @@ -133,6 +151,10 @@ class SearchMessagesScreenInner extends PureComponent { render() { const { messages, isFetching } = this.state; + const searchPrefix = + this.props.route.params.streamId != null + ? `stream:${this.props.route.params.streamName?.replace(' ', '+') ?? ''} ` + : ''; return ( { autoFocus searchBarOnSubmit={this.handleQuerySubmitWrapper} style={styles.flexed} + searchPrefixText={searchPrefix} > ); diff --git a/src/utils/narrow.js b/src/utils/narrow.js index f281d6b386c..fb2c3543dfb 100644 --- a/src/utils/narrow.js +++ b/src/utils/narrow.js @@ -36,7 +36,10 @@ export opaque type Narrow = | {| type: 'stream', streamId: number |} | {| type: 'topic', streamId: number, topic: string |} | {| type: 'pm', userIds: PmKeyRecipients |} - | {| type: 'search', query: string |} + // The search narrow can behave in two ways, + // one where there is no filters, so the user can search a message in all the streams + // the second where the user wants to search in a particular stream + | {| type: 'search', query: string, streamId?: number |} | {| type: 'all' | 'starred' | 'mentioned' | 'all-pm' |}; export const HOME_NARROW: Narrow = Object.freeze({ type: 'all' }); @@ -153,7 +156,8 @@ export const streamNarrow = (streamId: number): Narrow => export const topicNarrow = (streamId: number, topic: string): Narrow => Object.freeze({ type: 'topic', streamId, topic }); -export const SEARCH_NARROW = (query: string): Narrow => Object.freeze({ type: 'search', query }); +export const SEARCH_NARROW = (query: string, streamId?: number): Narrow => + Object.freeze({ type: 'search', query, streamId }); type NarrowCases = {| home: () => T, @@ -163,7 +167,7 @@ type NarrowCases = {| allPrivate: () => T, stream: (streamId: number) => T, topic: (streamId: number, topic: string) => T, - search: (query: string) => T, + search: (query: string, streamId?: number) => T, |}; /* prettier-ignore */ @@ -176,7 +180,7 @@ export function caseNarrow(narrow: Narrow, cases: NarrowCases): T { case 'stream': return cases.stream(narrow.streamId); case 'topic': return cases.topic(narrow.streamId, narrow.topic); case 'pm': return cases.pm(narrow.userIds); - case 'search': return cases.search(narrow.query); + case 'search': return cases.search(narrow.query, narrow.streamId); case 'all': return cases.home(); case 'starred': return cases.starred(); case 'mentioned': return cases.mentioned(); @@ -437,7 +441,13 @@ export const apiNarrowOfNarrow = ( // TODO(server-2.1): just send IDs instead return [{ operator: 'pm-with', operand: emails.join(',') }]; }, - search: query => [{ operator: 'search', operand: query }], + search: (query, streamId) => + streamId !== undefined + ? [ + { operator: 'search', operand: query }, + { operator: 'stream', operand: get('stream', streamsById, streamId).name }, + ] + : [{ operator: 'search', operand: query }], home: () => [], starred: () => [{ operator: 'is', operand: 'starred' }], mentioned: () => [{ operator: 'is', operand: 'mentioned' }], diff --git a/static/translations/messages_en.json b/static/translations/messages_en.json index 2c70cfb5731..56e92580765 100644 --- a/static/translations/messages_en.json +++ b/static/translations/messages_en.json @@ -218,6 +218,8 @@ "Failed to delete topic": "Failed to delete topic", "Stream settings": "Stream settings", "Failed to show stream settings": "Failed to show stream settings", + "Search in stream": "Search in stream", + "Failed to open search": "Failed to open search", "show": "show", "hide": "hide", "Debug": "Debug",