diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/lib/link-meta/bsky.ts | 13 | ||||
-rw-r--r-- | src/lib/strings/helpers.ts | 24 | ||||
-rw-r--r-- | src/lib/strings/rich-text-helpers.ts | 4 | ||||
-rw-r--r-- | src/state/queries/list.ts | 15 | ||||
-rw-r--r-- | src/view/com/composer/text-input/TextInput.tsx | 1 | ||||
-rw-r--r-- | src/view/com/composer/useExternalLinkFetch.ts | 8 | ||||
-rw-r--r-- | src/view/com/lightbox/Lightbox.tsx | 30 | ||||
-rw-r--r-- | src/view/com/modals/CreateOrEditList.tsx | 113 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThread.tsx | 3 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThreadItem.tsx | 6 | ||||
-rw-r--r-- | src/view/com/util/forms/PostDropdownBtn.tsx | 2 | ||||
-rw-r--r-- | src/view/com/util/text/RichText.tsx | 16 | ||||
-rw-r--r-- | src/view/com/util/text/Text.tsx | 16 | ||||
-rw-r--r-- | src/view/screens/Search/Search.tsx | 146 |
14 files changed, 337 insertions, 60 deletions
diff --git a/src/lib/link-meta/bsky.ts b/src/lib/link-meta/bsky.ts index 322b02332..c1fbb34b3 100644 --- a/src/lib/link-meta/bsky.ts +++ b/src/lib/link-meta/bsky.ts @@ -5,6 +5,7 @@ import {LikelyType, LinkMeta} from './link-meta' import {convertBskyAppUrlIfNeeded, makeRecordUri} from '../strings/url-helpers' import {ComposerOptsQuote} from 'state/shell/composer' import {useGetPost} from '#/state/queries/post' +import {useFetchDid} from '#/state/queries/handle' // TODO // import {Home} from 'view/screens/Home' @@ -120,11 +121,13 @@ export async function getPostAsQuote( export async function getFeedAsEmbed( agent: BskyAgent, + fetchDid: ReturnType<typeof useFetchDid>, url: string, ): Promise<apilib.ExternalEmbedDraft> { url = convertBskyAppUrlIfNeeded(url) - const [_0, user, _1, rkey] = url.split('/').filter(Boolean) - const feed = makeRecordUri(user, 'app.bsky.feed.generator', rkey) + const [_0, handleOrDid, _1, rkey] = url.split('/').filter(Boolean) + const did = await fetchDid(handleOrDid) + const feed = makeRecordUri(did, 'app.bsky.feed.generator', rkey) const res = await agent.app.bsky.feed.getFeedGenerator({feed}) return { isLoading: false, @@ -146,11 +149,13 @@ export async function getFeedAsEmbed( export async function getListAsEmbed( agent: BskyAgent, + fetchDid: ReturnType<typeof useFetchDid>, url: string, ): Promise<apilib.ExternalEmbedDraft> { url = convertBskyAppUrlIfNeeded(url) - const [_0, user, _1, rkey] = url.split('/').filter(Boolean) - const list = makeRecordUri(user, 'app.bsky.graph.list', rkey) + const [_0, handleOrDid, _1, rkey] = url.split('/').filter(Boolean) + const did = await fetchDid(handleOrDid) + const list = makeRecordUri(did, 'app.bsky.graph.list', rkey) const res = await agent.app.bsky.graph.getList({list}) return { isLoading: false, diff --git a/src/lib/strings/helpers.ts b/src/lib/strings/helpers.ts index 381ae32f3..e2abe9019 100644 --- a/src/lib/strings/helpers.ts +++ b/src/lib/strings/helpers.ts @@ -37,3 +37,27 @@ export function countLines(str: string | undefined): number { if (!str) return 0 return str.match(/\n/g)?.length ?? 0 } + +// Augments search query with additional syntax like `from:me` +export function augmentSearchQuery(query: string, {did}: {did?: string}) { + // Don't do anything if there's no DID + if (!did) { + return query + } + + // We don't want to replace substrings that are being "quoted" because those + // are exact string matches, so what we'll do here is to split them apart + + // Even-indexed strings are unquoted, odd-indexed strings are quoted + const splits = query.split(/("(?:[^"\\]|\\.)*")/g) + + return splits + .map((str, idx) => { + if (idx % 2 === 0) { + return str.replaceAll(/(^|\s)from:me(\s|$)/g, `$1${did}$2`) + } + + return str + }) + .join('') +} diff --git a/src/lib/strings/rich-text-helpers.ts b/src/lib/strings/rich-text-helpers.ts index 08971ca03..662004599 100644 --- a/src/lib/strings/rich-text-helpers.ts +++ b/src/lib/strings/rich-text-helpers.ts @@ -1,7 +1,7 @@ import {AppBskyRichtextFacet, RichText} from '@atproto/api' import {linkRequiresWarning} from './url-helpers' -export function richTextToString(rt: RichText): string { +export function richTextToString(rt: RichText, loose: boolean): string { const {text, facets} = rt if (!facets?.length) { @@ -19,7 +19,7 @@ export function richTextToString(rt: RichText): string { const requiresWarning = linkRequiresWarning(href, text) - result += !requiresWarning ? href : `[${text}](${href})` + result += !requiresWarning ? href : loose ? `[${text}](${href})` : text } else { result += segment.text } diff --git a/src/state/queries/list.ts b/src/state/queries/list.ts index 013a69076..845658a27 100644 --- a/src/state/queries/list.ts +++ b/src/state/queries/list.ts @@ -3,6 +3,7 @@ import { AppBskyGraphGetList, AppBskyGraphList, AppBskyGraphDefs, + Facet, } from '@atproto/api' import {Image as RNImage} from 'react-native-image-crop-picker' import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' @@ -38,6 +39,7 @@ export interface ListCreateMutateParams { purpose: string name: string description: string + descriptionFacets: Facet[] | undefined avatar: RNImage | null | undefined } export function useListCreateMutation() { @@ -45,7 +47,13 @@ export function useListCreateMutation() { const queryClient = useQueryClient() return useMutation<{uri: string; cid: string}, Error, ListCreateMutateParams>( { - async mutationFn({purpose, name, description, avatar}) { + async mutationFn({ + purpose, + name, + description, + descriptionFacets, + avatar, + }) { if (!currentAccount) { throw new Error('Not logged in') } @@ -59,6 +67,7 @@ export function useListCreateMutation() { purpose, name, description, + descriptionFacets, avatar: undefined, createdAt: new Date().toISOString(), } @@ -93,6 +102,7 @@ export interface ListMetadataMutateParams { uri: string name: string description: string + descriptionFacets: Facet[] | undefined avatar: RNImage | null | undefined } export function useListMetadataMutation() { @@ -103,7 +113,7 @@ export function useListMetadataMutation() { Error, ListMetadataMutateParams >({ - async mutationFn({uri, name, description, avatar}) { + async mutationFn({uri, name, description, descriptionFacets, avatar}) { const {hostname, rkey} = new AtUri(uri) if (!currentAccount) { throw new Error('Not logged in') @@ -121,6 +131,7 @@ export function useListMetadataMutation() { // update the fields record.name = name record.description = description + record.descriptionFacets = descriptionFacets if (avatar) { const blobRes = await uploadBlob(getAgent(), avatar.path, avatar.mime) record.avatar = blobRes.data.blob diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index 57bfd0a88..3d0d5ab8d 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -217,6 +217,7 @@ export const TextInput = forwardRef(function TextInputImpl( autoFocus={true} allowFontScaling multiline + scrollEnabled={false} numberOfLines={4} style={[ pal.text, diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts index ef3958c9d..fc7218d5d 100644 --- a/src/view/com/composer/useExternalLinkFetch.ts +++ b/src/view/com/composer/useExternalLinkFetch.ts @@ -18,6 +18,7 @@ import {POST_IMG_MAX} from 'lib/constants' import {logger} from '#/logger' import {getAgent} from '#/state/session' import {useGetPost} from '#/state/queries/post' +import {useFetchDid} from '#/state/queries/handle' export function useExternalLinkFetch({ setQuote, @@ -28,6 +29,7 @@ export function useExternalLinkFetch({ undefined, ) const getPost = useGetPost() + const fetchDid = useFetchDid() useEffect(() => { let aborted = false @@ -55,7 +57,7 @@ export function useExternalLinkFetch({ }, ) } else if (isBskyCustomFeedUrl(extLink.uri)) { - getFeedAsEmbed(getAgent(), extLink.uri).then( + getFeedAsEmbed(getAgent(), fetchDid, extLink.uri).then( ({embed, meta}) => { if (aborted) { return @@ -73,7 +75,7 @@ export function useExternalLinkFetch({ }, ) } else if (isBskyListUrl(extLink.uri)) { - getListAsEmbed(getAgent(), extLink.uri).then( + getListAsEmbed(getAgent(), fetchDid, extLink.uri).then( ({embed, meta}) => { if (aborted) { return @@ -133,7 +135,7 @@ export function useExternalLinkFetch({ }) } return cleanup - }, [extLink, setQuote, getPost]) + }, [extLink, setQuote, getPost, fetchDid]) return {extLink, setExtLink} } diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx index ee096b0d2..38f2c89c9 100644 --- a/src/view/com/lightbox/Lightbox.tsx +++ b/src/view/com/lightbox/Lightbox.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {StyleSheet, View, Pressable} from 'react-native' +import {LayoutAnimation, StyleSheet, View} from 'react-native' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import ImageView from './ImageViewing' import {shareImageModal, saveImageToMediaLibrary} from 'lib/media/manip' @@ -105,19 +105,21 @@ function LightboxFooter({imageIndex}: {imageIndex: number}) { return ( <View style={[styles.footer]}> {altText ? ( - <Pressable - onPress={() => setAltExpanded(!isAltExpanded)} - onLongPress={() => {}} - accessibilityRole="button"> - <View> - <Text - selectable - style={[s.gray3, styles.footerText]} - numberOfLines={isAltExpanded ? undefined : 3}> - {altText} - </Text> - </View> - </Pressable> + <View accessibilityRole="button" style={styles.footerText}> + <Text + style={[s.gray3]} + numberOfLines={isAltExpanded ? undefined : 3} + selectable + onPress={() => { + LayoutAnimation.configureNext({ + duration: 300, + update: {type: 'spring', springDamping: 0.7}, + }) + setAltExpanded(prev => !prev) + }}> + {altText} + </Text> + </View> ) : null} <View style={styles.footerBtns}> <Button diff --git a/src/view/com/modals/CreateOrEditList.tsx b/src/view/com/modals/CreateOrEditList.tsx index 77a1debec..0e11fcffd 100644 --- a/src/view/com/modals/CreateOrEditList.tsx +++ b/src/view/com/modals/CreateOrEditList.tsx @@ -8,7 +8,11 @@ import { TouchableOpacity, View, } from 'react-native' -import {AppBskyGraphDefs} from '@atproto/api' +import { + AppBskyGraphDefs, + AppBskyRichtextFacet, + RichText as RichTextAPI, +} from '@atproto/api' import LinearGradient from 'react-native-linear-gradient' import {Image as RNImage} from 'react-native-image-crop-picker' import {Text} from '../util/text/Text' @@ -30,6 +34,9 @@ import { useListCreateMutation, useListMetadataMutation, } from '#/state/queries/list' +import {richTextToString} from '#/lib/strings/rich-text-helpers' +import {shortenLinks} from '#/lib/strings/rich-text-manip' +import {getAgent} from '#/state/session' const MAX_NAME = 64 // todo const MAX_DESCRIPTION = 300 // todo @@ -68,12 +75,42 @@ export function Component({ const [isProcessing, setProcessing] = useState<boolean>(false) const [name, setName] = useState<string>(list?.name || '') - const [description, setDescription] = useState<string>( - list?.description || '', - ) + + const [descriptionRt, setDescriptionRt] = useState<RichTextAPI>(() => { + const text = list?.description + const facets = list?.descriptionFacets + + if (!text || !facets) { + return new RichTextAPI({text: text || ''}) + } + + // We want to be working with a blank state here, so let's get the + // serialized version and turn it back into a RichText + const serialized = richTextToString(new RichTextAPI({text, facets}), false) + + const richText = new RichTextAPI({text: serialized}) + richText.detectFacetsWithoutResolution() + + return richText + }) + const graphemeLength = useMemo(() => { + return shortenLinks(descriptionRt).graphemeLength + }, [descriptionRt]) + const isDescriptionOver = graphemeLength > MAX_DESCRIPTION + const [avatar, setAvatar] = useState<string | undefined>(list?.avatar) const [newAvatar, setNewAvatar] = useState<RNImage | undefined | null>() + const onDescriptionChange = useCallback( + (newText: string) => { + const richText = new RichTextAPI({text: newText}) + richText.detectFacetsWithoutResolution() + + setDescriptionRt(richText) + }, + [setDescriptionRt], + ) + const onPressCancel = useCallback(() => { closeModal() }, [closeModal]) @@ -113,11 +150,31 @@ export function Component({ setError('') } try { + let richText = new RichTextAPI( + {text: descriptionRt.text.trimEnd()}, + {cleanNewlines: true}, + ) + + await richText.detectFacets(getAgent()) + richText = shortenLinks(richText) + + // filter out any mention facets that didn't map to a user + richText.facets = richText.facets?.filter(facet => { + const mention = facet.features.find(feature => + AppBskyRichtextFacet.isMention(feature), + ) + if (mention && !mention.did) { + return false + } + return true + }) + if (list) { await listMetadataMutation.mutateAsync({ uri: list.uri, name: nameTrimmed, - description: description.trim(), + description: richText.text, + descriptionFacets: richText.facets, avatar: newAvatar, }) Toast.show( @@ -130,7 +187,8 @@ export function Component({ const res = await listCreateMutation.mutateAsync({ purpose: activePurpose, name, - description, + description: richText.text, + descriptionFacets: richText.facets, avatar: newAvatar, }) Toast.show( @@ -163,7 +221,7 @@ export function Component({ activePurpose, isCurateList, name, - description, + descriptionRt, newAvatar, list, listMetadataMutation, @@ -212,9 +270,11 @@ export function Component({ </View> <View style={styles.form}> <View> - <Text style={[styles.label, pal.text]} nativeID="list-name"> - <Trans>List Name</Trans> - </Text> + <View style={styles.labelWrapper}> + <Text style={[styles.label, pal.text]} nativeID="list-name"> + <Trans>List Name</Trans> + </Text> + </View> <TextInput testID="editNameInput" style={[styles.textInput, pal.border, pal.text]} @@ -233,9 +293,17 @@ export function Component({ /> </View> <View style={s.pb10}> - <Text style={[styles.label, pal.text]} nativeID="list-description"> - <Trans>Description</Trans> - </Text> + <View style={styles.labelWrapper}> + <Text + style={[styles.label, pal.text]} + nativeID="list-description"> + <Trans>Description</Trans> + </Text> + <Text + style={[!isDescriptionOver ? pal.textLight : s.red3, s.f13]}> + {graphemeLength}/{MAX_DESCRIPTION} + </Text> + </View> <TextInput testID="editDescriptionInput" style={[styles.textArea, pal.border, pal.text]} @@ -247,8 +315,8 @@ export function Component({ placeholderTextColor={colors.gray4} keyboardAppearance={theme.colorScheme} multiline - value={description} - onChangeText={v => setDescription(enforceLen(v, MAX_DESCRIPTION))} + value={descriptionRt.text} + onChangeText={onDescriptionChange} accessible={true} accessibilityLabel={_(msg`Description`)} accessibilityHint="" @@ -262,7 +330,8 @@ export function Component({ ) : ( <TouchableOpacity testID="saveBtn" - style={s.mt10} + style={[s.mt10, isDescriptionOver && s.dimmed]} + disabled={isDescriptionOver} onPress={onPressSave} accessibilityRole="button" accessibilityLabel={_(msg`Save`)} @@ -271,7 +340,7 @@ export function Component({ colors={[gradients.blueLight.start, gradients.blueLight.end]} start={{x: 0, y: 0}} end={{x: 1, y: 1}} - style={[styles.btn]}> + style={styles.btn}> <Text style={[s.white, s.bold]}> <Trans context="action">Save</Trans> </Text> @@ -305,12 +374,18 @@ const styles = StyleSheet.create({ fontSize: 24, marginBottom: 18, }, - label: { - fontWeight: 'bold', + labelWrapper: { + flexDirection: 'row', + gap: 8, + alignItems: 'center', + justifyContent: 'space-between', paddingHorizontal: 4, paddingBottom: 4, marginTop: 20, }, + label: { + fontWeight: 'bold', + }, form: { paddingHorizontal: 6, }, diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index 6ffede9a5..072ef7e33 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -40,7 +40,7 @@ import { usePreferencesQuery, } from '#/state/queries/preferences' import {useSession} from '#/state/session' -import {isNative} from '#/platform/detection' +import {isAndroid, isNative} from '#/platform/detection' import {logger} from '#/logger' import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' @@ -400,6 +400,7 @@ function PostThreadLoaded({ style={s.hContentRegion} // @ts-ignore our .web version only -prf desktopFixedHeight + removeClippedSubviews={isAndroid ? false : undefined} /> ) } diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index c811cd12b..a27ee0a58 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -248,10 +248,9 @@ let PostThreadItemLoaded = ({ </View> )} - <Link + <View testID={`postThreadItem-by-${post.author.handle}`} style={[styles.outer, styles.outerHighlighted, pal.border, pal.view]} - noFeedback accessible={false}> <PostSandboxWarning /> <View style={styles.layout}> @@ -370,6 +369,7 @@ let PostThreadItemLoaded = ({ richText={richText} lineHeight={1.3} style={s.flex1} + selectable /> </View> ) : undefined} @@ -445,7 +445,7 @@ let PostThreadItemLoaded = ({ /> </View> </View> - </Link> + </View> <WhoCanReply post={post} /> </> ) diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 8e31c9e63..b21caf2e7 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -104,7 +104,7 @@ let PostDropdownBtn = ({ }, [rootUri, toggleThreadMute, _]) const onCopyPostText = React.useCallback(() => { - const str = richTextToString(richText) + const str = richTextToString(richText, true) Clipboard.setString(str) Toast.show(_(msg`Copied to clipboard`)) diff --git a/src/view/com/util/text/RichText.tsx b/src/view/com/util/text/RichText.tsx index da473d929..e910127fe 100644 --- a/src/view/com/util/text/RichText.tsx +++ b/src/view/com/util/text/RichText.tsx @@ -17,6 +17,7 @@ export function RichText({ lineHeight = 1.2, style, numberOfLines, + selectable, noLinks, }: { testID?: string @@ -25,6 +26,7 @@ export function RichText({ lineHeight?: number style?: StyleProp<TextStyle> numberOfLines?: number + selectable?: boolean noLinks?: boolean }) { const theme = useTheme() @@ -44,7 +46,11 @@ export function RichText({ } return ( // @ts-ignore web only -prf - <Text testID={testID} style={[style, pal.text]} dataSet={WORD_WRAP}> + <Text + testID={testID} + style={[style, pal.text]} + dataSet={WORD_WRAP} + selectable={selectable}> {text} </Text> ) @@ -56,7 +62,8 @@ export function RichText({ style={[style, pal.text, lineHeightStyle]} numberOfLines={numberOfLines} // @ts-ignore web only -prf - dataSet={WORD_WRAP}> + dataSet={WORD_WRAP} + selectable={selectable}> {text} </Text> ) @@ -85,6 +92,7 @@ export function RichText({ href={`/profile/${mention.did}`} style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]} dataSet={WORD_WRAP} + selectable={selectable} />, ) } else if (link && AppBskyRichtextFacet.validateLink(link).success) { @@ -100,6 +108,7 @@ export function RichText({ style={[style, lineHeightStyle, pal.link, {pointerEvents: 'auto'}]} dataSet={WORD_WRAP} warnOnMismatchingLabel + selectable={selectable} />, ) } @@ -115,7 +124,8 @@ export function RichText({ style={[style, pal.text, lineHeightStyle]} numberOfLines={numberOfLines} // @ts-ignore web only -prf - dataSet={WORD_WRAP}> + dataSet={WORD_WRAP} + selectable={selectable}> {els} </Text> ) diff --git a/src/view/com/util/text/Text.tsx b/src/view/com/util/text/Text.tsx index ea97d59fe..ccb51bfca 100644 --- a/src/view/com/util/text/Text.tsx +++ b/src/view/com/util/text/Text.tsx @@ -2,12 +2,15 @@ import React from 'react' import {Text as RNText, TextProps} from 'react-native' import {s, lh} from 'lib/styles' import {useTheme, TypographyVariant} from 'lib/ThemeContext' +import {isIOS} from 'platform/detection' +import {UITextView} from 'react-native-ui-text-view' export type CustomTextProps = TextProps & { type?: TypographyVariant lineHeight?: number title?: string dataSet?: Record<string, string | number> + selectable?: boolean } export function Text({ @@ -17,16 +20,29 @@ export function Text({ style, title, dataSet, + selectable, ...props }: React.PropsWithChildren<CustomTextProps>) { const theme = useTheme() const typography = theme.typography[type] const lineHeightStyle = lineHeight ? lh(theme, type, lineHeight) : undefined + + if (selectable && isIOS) { + return ( + <UITextView + style={[s.black, typography, lineHeightStyle, style]} + {...props}> + {children} + </UITextView> + ) + } + return ( <RNText style={[s.black, typography, lineHeightStyle, style]} // @ts-ignore web only -esb dataSet={Object.assign({tooltip: title}, dataSet || {})} + selectable={selectable} {...props}> {children} </RNText> diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx index bfa8e1b28..df64cc5aa 100644 --- a/src/view/screens/Search/Search.tsx +++ b/src/view/screens/Search/Search.tsx @@ -51,6 +51,8 @@ import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell' import {isNative, isWeb} from '#/platform/detection' import {listenSoftReset} from '#/state/events' import {s} from '#/lib/styles' +import AsyncStorage from '@react-native-async-storage/async-storage' +import {augmentSearchQuery} from '#/lib/strings/helpers' function Loader() { const pal = usePalette('default') @@ -317,9 +319,13 @@ export function SearchScreenInner({ const pal = usePalette('default') const setMinimalShellMode = useSetMinimalShellMode() const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() - const {hasSession} = useSession() + const {hasSession, currentAccount} = useSession() const {isDesktop} = useWebMediaQueries() + const augmentedQuery = React.useMemo(() => { + return augmentSearchQuery(query || '', {did: currentAccount?.did}) + }, [query, currentAccount]) + const onPageSelected = React.useCallback( (index: number) => { setMinimalShellMode(false) @@ -342,7 +348,7 @@ export function SearchScreenInner({ )} initialPage={0}> <View> - <SearchScreenPostResults query={query} /> + <SearchScreenPostResults query={augmentedQuery} /> </View> <View> <SearchScreenUserResults query={query} /> @@ -464,11 +470,28 @@ export function SearchScreen( const [inputIsFocused, setInputIsFocused] = React.useState(false) const [showAutocompleteResults, setShowAutocompleteResults] = React.useState(false) + const [searchHistory, setSearchHistory] = React.useState<string[]>([]) + + React.useEffect(() => { + const loadSearchHistory = async () => { + try { + const history = await AsyncStorage.getItem('searchHistory') + if (history !== null) { + setSearchHistory(JSON.parse(history)) + } + } catch (e: any) { + logger.error('Failed to load search history', e) + } + } + + loadSearchHistory() + }, []) const onPressMenu = React.useCallback(() => { track('ViewHeader:MenuButtonClicked') setDrawerOpen(true) }, [track, setDrawerOpen]) + const onPressCancelSearch = React.useCallback(() => { scrollToTopWeb() textInput.current?.blur() @@ -477,22 +500,26 @@ export function SearchScreen( if (searchDebounceTimeout.current) clearTimeout(searchDebounceTimeout.current) }, [textInput]) + const onPressClearQuery = React.useCallback(() => { scrollToTopWeb() setQuery('') setShowAutocompleteResults(false) }, [setQuery]) + const onChangeText = React.useCallback( async (text: string) => { scrollToTopWeb() + setQuery(text) if (text.length > 0) { setIsFetching(true) setShowAutocompleteResults(true) - if (searchDebounceTimeout.current) + if (searchDebounceTimeout.current) { clearTimeout(searchDebounceTimeout.current) + } searchDebounceTimeout.current = setTimeout(async () => { const results = await search({query: text, limit: 30}) @@ -503,8 +530,9 @@ export function SearchScreen( } }, 300) } else { - if (searchDebounceTimeout.current) + if (searchDebounceTimeout.current) { clearTimeout(searchDebounceTimeout.current) + } setSearchResults([]) setIsFetching(false) setShowAutocompleteResults(false) @@ -512,10 +540,36 @@ export function SearchScreen( }, [setQuery, search, setSearchResults], ) + + const updateSearchHistory = React.useCallback( + async (newQuery: string) => { + newQuery = newQuery.trim() + if (newQuery && !searchHistory.includes(newQuery)) { + let newHistory = [newQuery, ...searchHistory] + + if (newHistory.length > 5) { + newHistory = newHistory.slice(0, 5) + } + + setSearchHistory(newHistory) + try { + await AsyncStorage.setItem( + 'searchHistory', + JSON.stringify(newHistory), + ) + } catch (e: any) { + logger.error('Failed to save search history', e) + } + } + }, + [searchHistory, setSearchHistory], + ) + const onSubmit = React.useCallback(() => { scrollToTopWeb() setShowAutocompleteResults(false) - }, [setShowAutocompleteResults]) + updateSearchHistory(query) + }, [query, setShowAutocompleteResults, updateSearchHistory]) const onSoftReset = React.useCallback(() => { scrollToTopWeb() @@ -534,6 +588,21 @@ export function SearchScreen( }, [onSoftReset, setMinimalShellMode]), ) + const handleHistoryItemClick = (item: React.SetStateAction<string>) => { + setQuery(item) + onSubmit() + } + + const handleRemoveHistoryItem = (itemToRemove: string) => { + const updatedHistory = searchHistory.filter(item => item !== itemToRemove) + setSearchHistory(updatedHistory) + AsyncStorage.setItem('searchHistory', JSON.stringify(updatedHistory)).catch( + e => { + logger.error('Failed to update search history', e) + }, + ) + } + return ( <View style={isWeb ? null : {flex: 1}}> <CenteredView @@ -581,7 +650,12 @@ export function SearchScreen( style={[pal.text, styles.headerSearchInput]} keyboardAppearance={theme.colorScheme} onFocus={() => setInputIsFocused(true)} - onBlur={() => setInputIsFocused(false)} + onBlur={() => { + // HACK + // give 100ms to not stop click handlers in the search history + // -prf + setTimeout(() => setInputIsFocused(false), 100) + }} onChangeText={onChangeText} onSubmitEditing={onSubmit} autoFocus={false} @@ -623,9 +697,9 @@ export function SearchScreen( ) : undefined} </CenteredView> - {showAutocompleteResults && moderationOpts ? ( + {showAutocompleteResults ? ( <> - {isFetching ? ( + {isFetching || !moderationOpts ? ( <Loader /> ) : ( <ScrollView @@ -664,6 +738,42 @@ export function SearchScreen( </ScrollView> )} </> + ) : !query && inputIsFocused ? ( + <CenteredView + sideBorders={isTabletOrDesktop} + // @ts-ignore web only -prf + style={{ + height: isWeb ? '100vh' : undefined, + }}> + <View style={styles.searchHistoryContainer}> + {searchHistory.length > 0 && ( + <View style={styles.searchHistoryContent}> + <Text style={[pal.text, styles.searchHistoryTitle]}> + Recent Searches + </Text> + {searchHistory.map((historyItem, index) => ( + <View key={index} style={styles.historyItemContainer}> + <Pressable + accessibilityRole="button" + onPress={() => handleHistoryItemClick(historyItem)} + style={styles.historyItem}> + <Text style={pal.text}>{historyItem}</Text> + </Pressable> + <Pressable + accessibilityRole="button" + onPress={() => handleRemoveHistoryItem(historyItem)}> + <FontAwesomeIcon + icon="xmark" + size={16} + style={pal.textLight as FontAwesomeIconStyle} + /> + </Pressable> + </View> + ))} + </View> + )} + </View> + </CenteredView> ) : ( <SearchScreenInner query={query} /> )} @@ -725,4 +835,24 @@ const styles = StyleSheet.create({ top: isWeb ? HEADER_HEIGHT : 0, zIndex: 1, }, + searchHistoryContainer: { + width: '100%', + paddingHorizontal: 12, + }, + searchHistoryContent: { + padding: 10, + borderRadius: 8, + }, + searchHistoryTitle: { + fontWeight: 'bold', + }, + historyItem: { + paddingVertical: 8, + }, + historyItemContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 8, + }, }) |