From aed4700d3bc3dce7ae570ecc40e41990b2729336 Mon Sep 17 00:00:00 2001 From: dariakoko Date: Fri, 10 Jul 2026 11:29:49 +0200 Subject: [PATCH 01/22] fix: fix wtyl rail tracking on back --- .../Sections/HomeViewSectionArtworks.tsx | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/app/Scenes/HomeView/Sections/HomeViewSectionArtworks.tsx b/src/app/Scenes/HomeView/Sections/HomeViewSectionArtworks.tsx index 6e706c0d337..45b9ae8c60f 100644 --- a/src/app/Scenes/HomeView/Sections/HomeViewSectionArtworks.tsx +++ b/src/app/Scenes/HomeView/Sections/HomeViewSectionArtworks.tsx @@ -34,7 +34,7 @@ import { NoFallback, withSuspense } from "app/utils/hooks/withSuspense" import { isDislikeArtworksEnabledFor } from "app/utils/isDislikeArtworksEnabledFor" import { useMemoizedRandom } from "app/utils/placeholders" import { times } from "lodash" -import { memo, useEffect } from "react" +import { memo, useEffect, useRef } from "react" import { fetchQuery, graphql, useFragment, useLazyLoadQuery } from "react-relay" interface HomeViewSectionArtworksProps extends FlexProps { @@ -67,17 +67,25 @@ export const HomeViewSectionArtworks: React.FC = ( const shouldShowInGrid = section.component?.type === "ArtworksGrid" const contextModule = section.contextModule as ContextModule + const isRailInViewport = viewableSections.includes(section.internalID) + + // Keep the latest visibility in a ref so the async `complete` callback below reads the current + // value rather than the stale one captured when the refetch was kicked off. + const isRailInViewportRef = useRef(isRailInViewport) + isRailInViewportRef.current = isRailInViewport + const { onViewableItemsChanged, viewabilityConfig, resetTracking } = useItemsImpressionsTracking({ // It is important here to tell if the rail is visible or not, because the viewability config // default behavior, doesn't take into account the fact that the rail could be not visible // on the screen because it's within a scrollable container. - isInViewport: viewableSections.includes(section.internalID) && section.trackItemImpressions, + isInViewport: isRailInViewport && section.trackItemImpressions, contextModule, }) // When the live refetch key is bumped (returning to home or pull to refresh), force a fresh - // fetch of the rail's artworks. Impression tracking is re-fired in `complete` — once the new - // data is in the store — so railViewed/itemViewed reflect the new content, not the stale rail. + // fetch of the rail's artworks. On `complete` we reset the per-item impression guard so + // itemViewed can re-fire for the fresh content, and re-fire railViewed — but only if the rail + // is actually on screen, so returning to home while the rail is scrolled off doesn't fire it. useEffect(() => { if (!isLiveRecommendationsRail || liveRefetchKey === 0) { return @@ -91,7 +99,10 @@ export const HomeViewSectionArtworks: React.FC = ( ).subscribe({ complete: () => { resetTracking() - tracking.viewedSection(contextModule, index) + + if (isRailInViewportRef.current) { + tracking.viewedSection(contextModule, index) + } }, error: (error: Error) => { console.error("Failed to refresh live artworks rail", error) From 03057424515272be12e76c946a9ae9d86cd7b916 Mon Sep 17 00:00:00 2001 From: dariakoko Date: Fri, 10 Jul 2026 11:40:22 +0200 Subject: [PATCH 02/22] chore: add test --- .../HomeViewSectionArtworks.tests.tsx | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/app/Scenes/HomeView/Sections/__tests__/HomeViewSectionArtworks.tests.tsx b/src/app/Scenes/HomeView/Sections/__tests__/HomeViewSectionArtworks.tests.tsx index 4575b6ae01f..39fa670309d 100644 --- a/src/app/Scenes/HomeView/Sections/__tests__/HomeViewSectionArtworks.tests.tsx +++ b/src/app/Scenes/HomeView/Sections/__tests__/HomeViewSectionArtworks.tests.tsx @@ -460,6 +460,68 @@ describe("HomeViewSectionArtworks", () => { }) }) + it("does not re-fire railViewed on refresh when the WTYL rail is off screen", () => { + // The refresh-driven re-fire only applies to the live "We think you'll love" (WTYL) section + // (home-view-section-recommended-artworks); no other section takes this path. + const { env } = renderWithRelay({ + HomeViewSectionArtworks: () => RECOMMENDED_SECTION, + }) + + // Rail is not in the viewport. + homeViewStoreActions.setViewableSections([]) + mockTrackEvent.mockClear() + + // Request a refresh (focus return / pull to refresh). + act(() => { + homeViewStoreActions.bumpLiveRefetchKey() + }) + + // Complete the forced refetch. + act(() => { + env.mock.resolveMostRecentOperation((operation) => + MockPayloadGenerator.generate(operation, { + HomeViewSectionArtworks: () => RECOMMENDED_SECTION, + }) + ) + }) + + // railViewed should not fire because the rail is off screen. + expect(mockTrackEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ action: "railViewed" }) + ) + }) + + it("re-fires railViewed on every refresh while the rail is in view", () => { + const { env } = renderWithRelay({ + HomeViewSectionArtworks: () => RECOMMENDED_SECTION, + }) + + homeViewStoreActions.setViewableSections(["home-view-section-recommended-artworks"]) + mockTrackEvent.mockClear() + + const refresh = () => { + act(() => { + homeViewStoreActions.bumpLiveRefetchKey() + }) + act(() => { + env.mock.resolveMostRecentOperation((operation) => + MockPayloadGenerator.generate(operation, { + HomeViewSectionArtworks: () => RECOMMENDED_SECTION, + }) + ) + }) + } + + // Two separate returns to home, both with the rail in view. + refresh() + refresh() + + const railViewedCalls = mockTrackEvent.mock.calls.filter( + (call) => (call[0] as any)?.action === "railViewed" + ) + expect(railViewedCalls).toHaveLength(2) + }) + it("re-enables itemViewed tracking after the live refresh completes", async () => { const { env, UNSAFE_root } = renderWithRelay({ HomeViewSectionArtworks: () => RECOMMENDED_SECTION, From eb77f3faa1a4cd3feaecff36fcf6610b2c8e981c Mon Sep 17 00:00:00 2001 From: dariakoko Date: Wed, 15 Jul 2026 15:54:09 +0100 Subject: [PATCH 03/22] add photo icon --- .../GlobalSearchInput/GlobalSearchInput.tsx | 51 +++++++++++++++---- .../__tests__/GlobalSearchInput.tests.tsx | 6 +++ src/app/Components/Icons/CameraIcon.tsx | 17 +++++++ 3 files changed, 64 insertions(+), 10 deletions(-) create mode 100644 src/app/Components/Icons/CameraIcon.tsx diff --git a/src/app/Components/GlobalSearchInput/GlobalSearchInput.tsx b/src/app/Components/GlobalSearchInput/GlobalSearchInput.tsx index b6c9d1cf3e5..60384896739 100644 --- a/src/app/Components/GlobalSearchInput/GlobalSearchInput.tsx +++ b/src/app/Components/GlobalSearchInput/GlobalSearchInput.tsx @@ -1,7 +1,14 @@ import { ActionType, OwnerType } from "@artsy/cohesion" -import { Flex, RoundSearchInput, Touchable } from "@artsy/palette-mobile" +import { + Flex, + RoundSearchInput, + SEARCH_INPUT_CONTAINER_HEIGHT, + Touchable, + useColor, +} from "@artsy/palette-mobile" import { GlobalSearchInputOverlay } from "app/Components/GlobalSearchInput/GlobalSearchInputOverlay" import { useDismissSearchOverlayOnTabBarPress } from "app/Components/GlobalSearchInput/utils/useDismissSearchOverlayOnTabBarPress" +import CameraIcon from "app/Components/Icons/CameraIcon" import { ICON_HIT_SLOP } from "app/Components/constants" import { useDebouncedValue } from "app/utils/hooks/useDebouncedValue" import { forwardRef, Fragment, useImperativeHandle, useState } from "react" @@ -13,6 +20,7 @@ export type GlobalSearchInput = { export const GlobalSearchInput = forwardRef( ({ ownerType }, ref) => { + const color = useColor() const [isVisible, setIsVisible] = useState(false) const debouncedIsVisible = useDebouncedValue({ value: isVisible }) @@ -48,15 +56,38 @@ export const GlobalSearchInput = forwardRef - + + + + + + {/* Camera icon on the right, mirroring RoundSearchInput's left search icon + (same `mono60` color, 24px size and 16px horizontal padding). It's only shown + on the resting search bar — the focused/typing overlay doesn't render it. */} + + + { expect(/Search Artsy/).toBeTruthy() }) + it("renders the camera icon on the resting search bar", () => { + renderWithWrappers() + + expect(screen.getByTestId("global-search-camera-icon")).toBeTruthy() + }) + it("tracks the search bar tapped event", () => { renderWithWrappers() diff --git a/src/app/Components/Icons/CameraIcon.tsx b/src/app/Components/Icons/CameraIcon.tsx new file mode 100644 index 00000000000..6d2e1f0035b --- /dev/null +++ b/src/app/Components/Icons/CameraIcon.tsx @@ -0,0 +1,17 @@ +import Svg, { Path } from "react-native-svg" + +type CameraIconProps = React.ComponentProps & { + fill?: string +} + +const CameraIcon = ({ fill = "#666", ...props }: CameraIconProps) => ( + + + +) + +export default CameraIcon From e4663a94a0040c701b0b9b68b4cff00250571b83 Mon Sep 17 00:00:00 2001 From: dariakoko Date: Thu, 16 Jul 2026 13:34:45 +0100 Subject: [PATCH 04/22] adjust buttons position --- .../GlobalSearchInputOverlay.tsx | 65 ++++++++++++++++--- .../__tests__/GlobalSearchInput.tests.tsx | 54 +++++++++++++++ 2 files changed, 109 insertions(+), 10 deletions(-) diff --git a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx index 5e8eaa2ba96..004715bd7f6 100644 --- a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx +++ b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx @@ -1,10 +1,11 @@ import { OwnerType } from "@artsy/cohesion" -import { Box, Flex, RoundSearchInput, Spacer, useSpace } from "@artsy/palette-mobile" +import { Box, Button, Flex, RoundSearchInput, Spacer, useSpace } from "@artsy/palette-mobile" import { Portal } from "@gorhom/portal" import { useNavigation } from "@react-navigation/native" import { GlobalSearchInputOverlayEmptyState } from "app/Components/GlobalSearchInput/GlobalSearchInputOverlayEmptyState" import { useSearch } from "app/Components/GlobalSearchInput/useSearch" import { DEFAULT_SCREEN_ANIMATION_DURATION } from "app/Components/constants" +import { BOTTOM_TABS_HEIGHT } from "app/Navigation/AuthenticatedRoutes/Tabs" import { RecentSearches } from "app/Scenes/Search/RecentSearches" import { SEARCH_INPUT_PLACEHOLDER, shouldStartSearching } from "app/Scenes/Search/Search" import { SearchContext } from "app/Scenes/Search/SearchContext" @@ -13,9 +14,11 @@ import { SearchPills } from "app/Scenes/Search/SearchPills" import { SearchResults } from "app/Scenes/Search/SearchResults" import { SEARCH_PILLS } from "app/Scenes/Search/constants" import { useBackHandler } from "app/utils/hooks/useBackHandler" +import { requestPhotos } from "app/utils/requestPhotos" import { Suspense, useEffect, useState } from "react" import { ScrollView, StyleSheet } from "react-native" -import { KeyboardController } from "react-native-keyboard-controller" +import ImagePicker from "react-native-image-crop-picker" +import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller" import Animated, { runOnJS, useAnimatedStyle, @@ -133,6 +136,24 @@ export const GlobalSearchInputOverlay: React.FC<{ } }) + const handleTakePhoto = async () => { + try { + await ImagePicker.openCamera({ mediaType: "photo" }) + // TODO: pass the captured photo to the image search flow + } catch (error) { + // User cancelled the camera or denied permission — no-op + } + } + + const handleAddImage = async () => { + try { + await requestPhotos(false) + // TODO: pass the selected image to the image search flow + } catch (error) { + // User cancelled the photo picker — no-op + } + } + if (!shouldRender) { return null } @@ -140,11 +161,9 @@ export const GlobalSearchInputOverlay: React.FC<{ return ( - + {/* Use paddingTop (not `top`) so the container stays full-screen height and its + bottom edge isn't pushed off-screen, which was cropping the footer below. */} + - - - + {/* Bound the content to the available space so it lays out above the footer, + which reserves the footer's height in normal flow (no overlap, no crop). */} + + + + + + + {/* KeyboardStickyView lifts the footer above the keyboard while it's open. When + the keyboard is folded/dismissed the negative `closed` offset lifts the footer + above the absolutely-positioned bottom tab bar (BOTTOM_TABS_HEIGHT + safe area), + which would otherwise cover it, so the buttons stay visible in both states. */} + + + + + + + + + + + + + diff --git a/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx b/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx index 4038d5242ff..27310c22c19 100644 --- a/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx +++ b/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx @@ -1,9 +1,17 @@ import { OwnerType } from "@artsy/cohesion" +import { PortalHost } from "@gorhom/portal" import { fireEvent, screen } from "@testing-library/react-native" import { GlobalSearchInput } from "app/Components/GlobalSearchInput/GlobalSearchInput" +import { GlobalSearchInputOverlay } from "app/Components/GlobalSearchInput/GlobalSearchInputOverlay" import { useSelectedTab } from "app/utils/hooks/useSelectedTab" +import { requestPhotos } from "app/utils/requestPhotos" import { mockTrackEvent } from "app/utils/tests/globallyMockedStuff" import { renderWithWrappers } from "app/utils/tests/renderWithWrappers" +import ImagePicker from "react-native-image-crop-picker" + +jest.mock("app/utils/requestPhotos", () => ({ + requestPhotos: jest.fn(), +})) jest.mock("app/utils/hooks/useSelectedTab", () => ({ useSelectedTab: jest.fn(), @@ -44,4 +52,50 @@ describe("GlobalSearchInput", () => { }) ) }) + + describe("when the search overlay is active", () => { + const renderOverlay = () => + renderWithWrappers( + <> + + + + ) + + it("renders the take a photo and add an image buttons", async () => { + renderOverlay() + + await screen.findByText("Take a photo") + expect(screen.getByText("Add an image")).toBeTruthy() + }) + + it("opens the camera when tapping take a photo", async () => { + renderOverlay() + + fireEvent.press(await screen.findByText("Take a photo")) + + expect(ImagePicker.openCamera).toHaveBeenCalledWith({ mediaType: "photo" }) + }) + + it("opens the photo library when tapping add an image", async () => { + renderOverlay() + + fireEvent.press(await screen.findByText("Add an image")) + + expect(requestPhotos).toHaveBeenCalledWith(false) + }) + + it("keeps the buttons visible after a query is entered", async () => { + renderOverlay() + + await screen.findByText("Take a photo") + fireEvent.changeText( + screen.getByLabelText("Search artists, artworks, galleries etc."), + "banksy" + ) + + expect(screen.getByText("Take a photo")).toBeTruthy() + expect(screen.getByText("Add an image")).toBeTruthy() + }) + }) }) From 70d35ab0a755d0a8effcf7ec2053d2d17990dfd8 Mon Sep 17 00:00:00 2001 From: dariakoko Date: Thu, 16 Jul 2026 13:52:46 +0100 Subject: [PATCH 05/22] adjust the wording --- .../GlobalSearchInputOverlay.tsx | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx index 004715bd7f6..1055ea2ff45 100644 --- a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx +++ b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx @@ -1,5 +1,5 @@ import { OwnerType } from "@artsy/cohesion" -import { Box, Button, Flex, RoundSearchInput, Spacer, useSpace } from "@artsy/palette-mobile" +import { Box, Button, Flex, RoundSearchInput, Spacer, Text, useSpace } from "@artsy/palette-mobile" import { Portal } from "@gorhom/portal" import { useNavigation } from "@react-navigation/native" import { GlobalSearchInputOverlayEmptyState } from "app/Components/GlobalSearchInput/GlobalSearchInputOverlayEmptyState" @@ -193,23 +193,32 @@ export const GlobalSearchInputOverlay: React.FC<{ {/* KeyboardStickyView lifts the footer above the keyboard while it's open. When the keyboard is folded/dismissed the negative `closed` offset lifts the footer above the absolutely-positioned bottom tab bar (BOTTOM_TABS_HEIGHT + safe area), - which would otherwise cover it, so the buttons stay visible in both states. */} + which would otherwise cover it, so the buttons stay visible in both states. + */} - - - + + + See it? Search it. + + + Take a photo or upload an image to find the piece that matches the mood. + + + + + + + + + + + - - - - - - - + From 182553922d373afd59bec0720cf48a53d522b1d5 Mon Sep 17 00:00:00 2001 From: dariakoko Date: Thu, 16 Jul 2026 15:17:22 +0100 Subject: [PATCH 06/22] upload image to s3 --- .../GlobalSearchInputOverlay.tsx | 23 +++++++++-- .../utils/__tests__/uploadImageToS3.tests.ts | 35 +++++++++++++++++ src/app/utils/uploadImageToS3.ts | 38 +++++++++++++++++++ 3 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 src/app/utils/__tests__/uploadImageToS3.tests.ts create mode 100644 src/app/utils/uploadImageToS3.ts diff --git a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx index 1055ea2ff45..ad74c5f26ed 100644 --- a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx +++ b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx @@ -15,6 +15,7 @@ import { SearchResults } from "app/Scenes/Search/SearchResults" import { SEARCH_PILLS } from "app/Scenes/Search/constants" import { useBackHandler } from "app/utils/hooks/useBackHandler" import { requestPhotos } from "app/utils/requestPhotos" +import { uploadImageToS3 } from "app/utils/uploadImageToS3" import { Suspense, useEffect, useState } from "react" import { ScrollView, StyleSheet } from "react-native" import ImagePicker from "react-native-image-crop-picker" @@ -136,10 +137,24 @@ export const GlobalSearchInputOverlay: React.FC<{ } }) + const uploadAndSearchByImage = async (imagePath?: string) => { + if (!imagePath) { + return + } + + try { + const { key, bucket } = await uploadImageToS3(imagePath) + // TODO: pass { key, bucket } to the image search query + console.warn("Uploaded image to S3", { key, bucket }) + } catch (error) { + console.error("Failed to upload image to S3", error) + } + } + const handleTakePhoto = async () => { try { - await ImagePicker.openCamera({ mediaType: "photo" }) - // TODO: pass the captured photo to the image search flow + const photo = await ImagePicker.openCamera({ mediaType: "photo" }) + await uploadAndSearchByImage(photo.path) } catch (error) { // User cancelled the camera or denied permission — no-op } @@ -147,8 +162,8 @@ export const GlobalSearchInputOverlay: React.FC<{ const handleAddImage = async () => { try { - await requestPhotos(false) - // TODO: pass the selected image to the image search flow + const [photo] = await requestPhotos(false) + await uploadAndSearchByImage(photo?.path) } catch (error) { // User cancelled the photo picker — no-op } diff --git a/src/app/utils/__tests__/uploadImageToS3.tests.ts b/src/app/utils/__tests__/uploadImageToS3.tests.ts new file mode 100644 index 00000000000..aa9ec86c979 --- /dev/null +++ b/src/app/utils/__tests__/uploadImageToS3.tests.ts @@ -0,0 +1,35 @@ +import { + getConvectionGeminiKey, + getGeminiCredentialsForEnvironment, + uploadFileToS3, +} from "app/Components/PhotoRow/utils/uploadFileToS3" +import { uploadImageToS3 } from "app/utils/uploadImageToS3" + +jest.mock("app/Components/PhotoRow/utils/uploadFileToS3", () => ({ + getConvectionGeminiKey: jest.fn(), + getGeminiCredentialsForEnvironment: jest.fn(), + uploadFileToS3: jest.fn(), +})) + +const getConvectionGeminiKeyMock = getConvectionGeminiKey as jest.Mock +const getGeminiCredentialsForEnvironmentMock = getGeminiCredentialsForEnvironment as jest.Mock +const uploadFileToS3Mock = uploadFileToS3 as jest.Mock + +describe("uploadImageToS3", () => { + beforeEach(() => { + getConvectionGeminiKeyMock.mockResolvedValue("convection-key") + getGeminiCredentialsForEnvironmentMock.mockResolvedValue({ + policyDocument: { conditions: { bucket: "test-bucket" } }, + }) + uploadFileToS3Mock.mockResolvedValue({ key: "test-key" }) + }) + + it("uploads the image and returns the s3 key and bucket", async () => { + const result = await uploadImageToS3("file:///photo.jpg") + + expect(uploadFileToS3Mock).toHaveBeenCalledWith( + expect.objectContaining({ filePath: "file:///photo.jpg", acl: "private" }) + ) + expect(result).toEqual({ key: "test-key", bucket: "test-bucket" }) + }) +}) diff --git a/src/app/utils/uploadImageToS3.ts b/src/app/utils/uploadImageToS3.ts new file mode 100644 index 00000000000..fc2b4bd45e7 --- /dev/null +++ b/src/app/utils/uploadImageToS3.ts @@ -0,0 +1,38 @@ +import { + getConvectionGeminiKey, + getGeminiCredentialsForEnvironment, + uploadFileToS3, +} from "app/Components/PhotoRow/utils/uploadFileToS3" + +export interface S3ImageUpload { + key: string + bucket: string +} + +/** + * Uploads a local image (e.g. a photo taken with the camera or picked from the gallery) + * to S3 via Gemini credentials and returns the resulting S3 `key` and `bucket`. + * + * This mirrors `getConvertedImageUrlFromS3`, but returns the raw key/bucket instead of a + * public URL — useful when the consumer needs to reference the object directly (e.g. an + * image-search mutation that takes `{ bucket, key }`). + */ +export async function uploadImageToS3(imagePath: string): Promise { + const convectionKey = await getConvectionGeminiKey() + const acl = "private" + + const assetCredentials = await getGeminiCredentialsForEnvironment({ + acl, + name: convectionKey || "", + }) + + const bucket = assetCredentials.policyDocument.conditions.bucket + + const { key } = await uploadFileToS3({ + filePath: imagePath, + acl, + assetCredentials, + }) + + return { key, bucket } +} From c88134b4c2ffbda92115f4a5e64ebe62c427e999 Mon Sep 17 00:00:00 2001 From: dariakoko Date: Thu, 16 Jul 2026 15:35:45 +0100 Subject: [PATCH 07/22] add file name to image upload s3 --- .../GlobalSearchInputOverlay.tsx | 2 +- .../utils/__tests__/uploadImageToS3.tests.ts | 17 +++++++++++++++-- src/app/utils/uploadImageToS3.ts | 5 +++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx index ad74c5f26ed..444ad14852f 100644 --- a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx +++ b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx @@ -229,7 +229,7 @@ export const GlobalSearchInputOverlay: React.FC<{ diff --git a/src/app/utils/__tests__/uploadImageToS3.tests.ts b/src/app/utils/__tests__/uploadImageToS3.tests.ts index aa9ec86c979..7e0913e383e 100644 --- a/src/app/utils/__tests__/uploadImageToS3.tests.ts +++ b/src/app/utils/__tests__/uploadImageToS3.tests.ts @@ -25,11 +25,24 @@ describe("uploadImageToS3", () => { }) it("uploads the image and returns the s3 key and bucket", async () => { - const result = await uploadImageToS3("file:///photo.jpg") + const result = await uploadImageToS3("file:///tmp/my-photo.jpg") expect(uploadFileToS3Mock).toHaveBeenCalledWith( - expect.objectContaining({ filePath: "file:///photo.jpg", acl: "private" }) + expect.objectContaining({ + filePath: "file:///tmp/my-photo.jpg", + acl: "private", + // derived from the path so the key doesn't end in `/undefined` + filename: "my-photo.jpg", + }) ) expect(result).toEqual({ key: "test-key", bucket: "test-bucket" }) }) + + it("falls back to a default filename when the path has none", async () => { + await uploadImageToS3("") + + expect(uploadFileToS3Mock).toHaveBeenCalledWith( + expect.objectContaining({ filename: "photo.jpg" }) + ) + }) }) diff --git a/src/app/utils/uploadImageToS3.ts b/src/app/utils/uploadImageToS3.ts index fc2b4bd45e7..086804115ea 100644 --- a/src/app/utils/uploadImageToS3.ts +++ b/src/app/utils/uploadImageToS3.ts @@ -28,10 +28,15 @@ export async function uploadImageToS3(imagePath: string): Promise const bucket = assetCredentials.policyDocument.conditions.bucket + // Derive a filename from the local path so the S3 key ends in a real name + // (e.g. `+/photo.jpg`) instead of `+/undefined`. + const filename = imagePath.split("/").pop() || "photo.jpg" + const { key } = await uploadFileToS3({ filePath: imagePath, acl, assetCredentials, + filename, }) return { key, bucket } From 9adf04d48faba0758e2cbdaf4cb298234ed69559 Mon Sep 17 00:00:00 2001 From: dariakoko Date: Thu, 16 Jul 2026 16:21:41 +0100 Subject: [PATCH 08/22] collapce on scroll --- .../AutosuggestResults/AutosuggestResults.tsx | 9 ++- .../GlobalSearchInputOverlay.tsx | 81 +++++++++++++------ .../__tests__/GlobalSearchInput.tests.tsx | 23 ++++++ src/app/Scenes/Search/SearchResults.tsx | 8 +- 4 files changed, 95 insertions(+), 26 deletions(-) diff --git a/src/app/Components/AutosuggestResults/AutosuggestResults.tsx b/src/app/Components/AutosuggestResults/AutosuggestResults.tsx index 74514a51d83..641fc9cb979 100644 --- a/src/app/Components/AutosuggestResults/AutosuggestResults.tsx +++ b/src/app/Components/AutosuggestResults/AutosuggestResults.tsx @@ -41,6 +41,7 @@ const AutosuggestResultsFlatList: React.FC<{ ListHeaderComponent?: React.ComponentType numColumns?: number onResultPress?: OnResultPress + onScrollBeginDrag?: () => void prependResults?: AutosuggestResult[] query: string relay: RelayPaginationProp @@ -57,6 +58,7 @@ const AutosuggestResultsFlatList: React.FC<{ ListHeaderComponent = () => , numColumns = 1, onResultPress, + onScrollBeginDrag: onScrollBeginDragProp, prependResults = [], query, relay, @@ -79,13 +81,15 @@ const AutosuggestResultsFlatList: React.FC<{ inputRef.current?.blur() // dismisses the keyboard KeyboardController.dismiss() + // let consumers react to the scroll (e.g. collapse the image-search footer) + onScrollBeginDragProp?.() if (!userHasStartedScrolling.current) { userHasStartedScrolling.current = true // fetch second page immediately loadMore() } - }, []) + }, [onScrollBeginDragProp]) const onEndReached = useCallback(() => { if (userHasStartedScrolling.current) { loadMore() @@ -344,6 +348,7 @@ export const AutosuggestResults: React.FC<{ ListHeaderComponent?: React.ComponentType numColumns?: number onResultPress?: OnResultPress + onScrollBeginDrag?: () => void prependResults?: any[] query: string showOnRetryErrorMessage?: boolean @@ -361,6 +366,7 @@ export const AutosuggestResults: React.FC<{ ListHeaderComponent, numColumns = 1, onResultPress, + onScrollBeginDrag, prependResults, query, showOnRetryErrorMessage, @@ -430,6 +436,7 @@ export const AutosuggestResults: React.FC<{ showResultType={showResultType} showQuickNavigationButtons={showQuickNavigationButtons} onResultPress={onResultPress} + onScrollBeginDrag={onScrollBeginDrag} trackResultPress={trackResultPress} ListEmptyComponent={ListEmptyComponent} ListHeaderComponent={ListHeaderComponent} diff --git a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx index 444ad14852f..5725f183b48 100644 --- a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx +++ b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx @@ -1,5 +1,15 @@ import { OwnerType } from "@artsy/cohesion" -import { Box, Button, Flex, RoundSearchInput, Spacer, Text, useSpace } from "@artsy/palette-mobile" +import { ChevronDownIcon, ChevronUpIcon } from "@artsy/icons/native" +import { + Box, + Button, + Flex, + RoundSearchInput, + Spacer, + Text, + Touchable, + useSpace, +} from "@artsy/palette-mobile" import { Portal } from "@gorhom/portal" import { useNavigation } from "@react-navigation/native" import { GlobalSearchInputOverlayEmptyState } from "app/Components/GlobalSearchInput/GlobalSearchInputOverlayEmptyState" @@ -16,7 +26,7 @@ import { SEARCH_PILLS } from "app/Scenes/Search/constants" import { useBackHandler } from "app/utils/hooks/useBackHandler" import { requestPhotos } from "app/utils/requestPhotos" import { uploadImageToS3 } from "app/utils/uploadImageToS3" -import { Suspense, useEffect, useState } from "react" +import { Suspense, useCallback, useEffect, useState } from "react" import { ScrollView, StyleSheet } from "react-native" import ImagePicker from "react-native-image-crop-picker" import { KeyboardController, KeyboardStickyView } from "react-native-keyboard-controller" @@ -37,7 +47,10 @@ export const globalSearchInputOverlayQuery = graphql` } ` -const GlobalSearchInputOverlayContent: React.FC<{ query: string }> = ({ query }) => { +const GlobalSearchInputOverlayContent: React.FC<{ + query: string + onScrollBeginDrag?: () => void +}> = ({ query, onScrollBeginDrag }) => { const space = useSpace() const { data, @@ -69,6 +82,7 @@ const GlobalSearchInputOverlayContent: React.FC<{ query: string }> = ({ query }) { refetch({ term: query, skipSearchQuery: false }) }} @@ -78,6 +92,7 @@ const GlobalSearchInputOverlayContent: React.FC<{ query: string }> = ({ query }) = ({ hideModal, ownerType, visible }) => { const [query, setQuery] = useState("") const [shouldRender, setShouldRender] = useState(false) + const [isFooterExpanded, setIsFooterExpanded] = useState(true) const insets = useSafeAreaInsets() const { goBack, canGoBack } = useNavigation() const opacity = useSharedValue(0) @@ -137,6 +153,9 @@ export const GlobalSearchInputOverlay: React.FC<{ } }) + // Collapse the footer once the user starts scrolling the results/recent searches. + const collapseFooter = useCallback(() => setIsFooterExpanded(false), []) + const uploadAndSearchByImage = async (imagePath?: string) => { if (!imagePath) { return @@ -201,7 +220,7 @@ export const GlobalSearchInputOverlay: React.FC<{ which reserves the footer's height in normal flow (no overlap, no crop). */} - + @@ -212,27 +231,41 @@ export const GlobalSearchInputOverlay: React.FC<{ */} - - See it? Search it. - - - Take a photo or upload an image to find the piece that matches the mood. - - - - - - - - - - - + + See it? Search it. + + setIsFooterExpanded((expanded) => !expanded)} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + {isFooterExpanded ? : } + + + {!!isFooterExpanded && ( + <> + + Take a photo or upload an image to find the piece that matches the mood. + + + + + + + + + + + + + + )} diff --git a/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx b/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx index 27310c22c19..264c505de6b 100644 --- a/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx +++ b/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx @@ -97,5 +97,28 @@ describe("GlobalSearchInput", () => { expect(screen.getByText("Take a photo")).toBeTruthy() expect(screen.getByText("Add an image")).toBeTruthy() }) + + it("collapses to the title only and expands again when toggling the chevron", async () => { + renderOverlay() + + // starts expanded: buttons + description visible + await screen.findByText("Take a photo") + expect( + screen.getByText("Take a photo or upload an image to find the piece that matches the mood.") + ).toBeTruthy() + + // collapse: only the title remains + fireEvent.press(screen.getByLabelText("Collapse")) + + expect(screen.getByText("See it? Search it.")).toBeTruthy() + expect(screen.queryByText("Take a photo")).toBeNull() + expect(screen.queryByText("Add an image")).toBeNull() + + // expand again via the chevron + fireEvent.press(screen.getByLabelText("Expand")) + + expect(screen.getByText("Take a photo")).toBeTruthy() + expect(screen.getByText("Add an image")).toBeTruthy() + }) }) }) diff --git a/src/app/Scenes/Search/SearchResults.tsx b/src/app/Scenes/Search/SearchResults.tsx index 527066fcf08..effd5989e50 100644 --- a/src/app/Scenes/Search/SearchResults.tsx +++ b/src/app/Scenes/Search/SearchResults.tsx @@ -16,9 +16,14 @@ interface SearchResultsProps { selectedPill: PillType query: string onRetry: () => void + onScrollBeginDrag?: () => void } -export const SearchResults: React.FC = ({ selectedPill, query }) => { +export const SearchResults: React.FC = ({ + selectedPill, + query, + onScrollBeginDrag, +}) => { const { trackEvent } = useTracking() const { queryRef } = useContext(SearchContext) const isTopPillSelected = selectedPill.key === TOP_PILL.key @@ -49,6 +54,7 @@ export const SearchResults: React.FC = ({ selectedPill, quer showResultType showQuickNavigationButtons showOnRetryErrorMessage + onScrollBeginDrag={onScrollBeginDrag} trackResultPress={handleTrackAutosuggestResultPress} /> From 226358565918a2de1ab58b8d57535e1460fc3c85 Mon Sep 17 00:00:00 2001 From: dariakoko Date: Thu, 16 Jul 2026 16:42:36 +0100 Subject: [PATCH 09/22] hide keyboard when typing --- .../GlobalSearchInputOverlay.tsx | 17 +++++++++++------ .../__tests__/GlobalSearchInput.tests.tsx | 10 +++++++--- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx index 5725f183b48..e48c540b947 100644 --- a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx +++ b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx @@ -153,7 +153,13 @@ export const GlobalSearchInputOverlay: React.FC<{ } }) - // Collapse the footer once the user starts scrolling the results/recent searches. + // Collapse the prompt to its title once the user starts typing; expand it again when the + // query is cleared. It's never hidden — the title stays with a chevron to re-expand. + useEffect(() => { + setIsFooterExpanded(!query) + }, [query]) + + // Also collapse it as soon as the user starts scrolling the results/recent searches. const collapseFooter = useCallback(() => setIsFooterExpanded(false), []) const uploadAndSearchByImage = async (imagePath?: string) => { @@ -224,11 +230,10 @@ export const GlobalSearchInputOverlay: React.FC<{ - {/* KeyboardStickyView lifts the footer above the keyboard while it's open. When - the keyboard is folded/dismissed the negative `closed` offset lifts the footer - above the absolutely-positioned bottom tab bar (BOTTOM_TABS_HEIGHT + safe area), - which would otherwise cover it, so the buttons stay visible in both states. - */} + {/* The image-search prompt is always present (never fully hidden). It shows in full + before searching, and collapses to just its title once the user starts typing — the + chevron lets them re-expand. KeyboardStickyView keeps it above the keyboard; the + negative `closed` offset lifts it above the bottom tab bar when the keyboard folds. */} diff --git a/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx b/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx index 264c505de6b..ab9f56072d6 100644 --- a/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx +++ b/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx @@ -85,17 +85,21 @@ describe("GlobalSearchInput", () => { expect(requestPhotos).toHaveBeenCalledWith(false) }) - it("keeps the buttons visible after a query is entered", async () => { + it("collapses to the title (without hiding) once the user starts typing", async () => { renderOverlay() + // expanded before typing: buttons visible await screen.findByText("Take a photo") + fireEvent.changeText( screen.getByLabelText("Search artists, artworks, galleries etc."), "banksy" ) - expect(screen.getByText("Take a photo")).toBeTruthy() - expect(screen.getByText("Add an image")).toBeTruthy() + // collapsed: buttons gone, but the title is still there (never fully hidden) + expect(screen.queryByText("Take a photo")).toBeNull() + expect(screen.queryByText("Add an image")).toBeNull() + expect(screen.getByText("See it? Search it.")).toBeTruthy() }) it("collapses to the title only and expands again when toggling the chevron", async () => { From a0489b4fb7a0d25d686c7ff75cd4ae31c2cf1365 Mon Sep 17 00:00:00 2001 From: dariakoko Date: Thu, 16 Jul 2026 17:20:57 +0100 Subject: [PATCH 10/22] add results screen and fetch --- data/schema.graphql | 128 ++++++++++++++++++ .../GlobalSearchInputOverlay.tsx | 10 +- .../__tests__/GlobalSearchInput.tests.tsx | 30 +++- src/app/Navigation/routes.tsx | 12 ++ .../ImageSearchResults/ImageSearchResults.tsx | 98 ++++++++++++++ .../__tests__/ImageSearchResults.tests.tsx | 28 ++++ 6 files changed, 301 insertions(+), 5 deletions(-) create mode 100644 src/app/Scenes/ImageSearchResults/ImageSearchResults.tsx create mode 100644 src/app/Scenes/ImageSearchResults/__tests__/ImageSearchResults.tests.tsx diff --git a/data/schema.graphql b/data/schema.graphql index 754b683c784..a9c9eed2aa4 100644 --- a/data/schema.graphql +++ b/data/schema.graphql @@ -2688,6 +2688,16 @@ type Artist implements EntityWithFilterArtworksConnectionInterface & Node & Sear ] ): [ArtistInsight!]! + """ + Instagram media for display on an artist page. + """ + instagramMedia( + """ + The number of media items to return. + """ + first: Int + ): [ArtistInstagramMedia] + """ A type-specific ID likely used as a database ID. """ @@ -3023,6 +3033,13 @@ type ArtistInsightsCount { soloShowCount: Int! } +type ArtistInstagramMedia { + caption: String + image: Image + internalID: String + permalink: String +} + type ArtistMeta { description: String! title: String! @@ -18864,9 +18881,42 @@ type FeatureFlag { variants: [FeatureFlagVariantType] } +type FeatureFlagConstraint { + caseInsensitive: Boolean + contextName: String + inverted: Boolean + operator: String + + """ + The Partners referenced by this constraint's values, resolved when contextName is `partnerId` + """ + partnerConnection(after: String, before: String, first: Int, last: Int): PartnerConnection + value: String + values: [String] +} + type FeatureFlagEnvironments { enabled: Boolean! name: String! + strategies: [FeatureFlagStrategy] +} + +type FeatureFlagSegment { + constraints: [FeatureFlagConstraint] + description: String + internalID: Int + name: String +} + +type FeatureFlagStrategy { + constraints: [FeatureFlagConstraint] + name: String + parameters: JSON + + """ + Constraints applied to this strategy via a reusable, named Unleash segment (as opposed to inline constraints) + """ + segments: [FeatureFlagSegment] } input FeatureFlagStrategyInput { @@ -27022,12 +27072,50 @@ type OfferResponse { } type OfferableActivity { + """ + Details of the collectors with eligible offerable activities. + """ + collectors: [OfferableActivityCollector] + """ Count of collectors with eligible offerable activities. """ totalCount: Int } +""" +A collector with eligible offerable activity on the artwork, and how they engaged with it. +""" +type OfferableActivityCollector { + confirmedBuyerAt( + format: String + + """ + A tz database time zone, otherwise falls back to "X-TIMEZONE" header. See http://www.iana.org/time-zones, https://en.wikipedia.org/wiki/List_of_tz_database_time_zones + """ + timezone: String + ): String + firstNameLastInitial: String + icon: Image + + """ + A globally unique ID. + """ + id: ID! + + """ + A type-specific ID likely used as a database ID. + """ + internalID: ID! + isIdentityVerified: Boolean + location: MyLocation + + """ + The way this collector engaged with the artwork (e.g. saved it and/or abandoned an order). + """ + sources: [PartnerOfferSourceEnum] +} + type OpeningHoursArray { schedules: [FormattedDaySchedules] } @@ -30982,6 +31070,26 @@ type Query { reason: "This is only for use in resolving stitched queries, not for first-class client use!" ) + """ + A connection of artworks matching an uploaded query image, using a pure vector (neural) image search. + """ + artworksByImageConnection( + after: String + before: String + first: Int + last: Int + + """ + S3 bucket of the uploaded query image. + """ + s3Bucket: String! + + """ + S3 key of the uploaded query image. + """ + s3Key: String! + ): ArtworkConnection + """ Artworks Elastic Search results """ @@ -40639,6 +40747,26 @@ type Viewer { reason: "This is only for use in resolving stitched queries, not for first-class client use!" ) + """ + A connection of artworks matching an uploaded query image, using a pure vector (neural) image search. + """ + artworksByImageConnection( + after: String + before: String + first: Int + last: Int + + """ + S3 bucket of the uploaded query image. + """ + s3Bucket: String! + + """ + S3 key of the uploaded query image. + """ + s3Key: String! + ): ArtworkConnection + """ Artworks Elastic Search results """ diff --git a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx index e48c540b947..1ced40ab967 100644 --- a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx +++ b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx @@ -23,6 +23,10 @@ import { useRecentSearches } from "app/Scenes/Search/SearchModel" import { SearchPills } from "app/Scenes/Search/SearchPills" import { SearchResults } from "app/Scenes/Search/SearchResults" import { SEARCH_PILLS } from "app/Scenes/Search/constants" +// Imperative navigation is required here — we navigate after the async image upload +// resolves, so RouterLink (declarative) can't be used. +// eslint-disable-next-line no-restricted-imports +import { navigate } from "app/system/navigation/navigate" import { useBackHandler } from "app/utils/hooks/useBackHandler" import { requestPhotos } from "app/utils/requestPhotos" import { uploadImageToS3 } from "app/utils/uploadImageToS3" @@ -169,8 +173,10 @@ export const GlobalSearchInputOverlay: React.FC<{ try { const { key, bucket } = await uploadImageToS3(imagePath) - // TODO: pass { key, bucket } to the image search query - console.warn("Uploaded image to S3", { key, bucket }) + hideModal() + navigate("/image-search-results", { + passProps: { s3Key: key, s3Bucket: bucket }, + }) } catch (error) { console.error("Failed to upload image to S3", error) } diff --git a/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx b/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx index ab9f56072d6..e8af50ceab9 100644 --- a/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx +++ b/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx @@ -1,18 +1,24 @@ import { OwnerType } from "@artsy/cohesion" import { PortalHost } from "@gorhom/portal" -import { fireEvent, screen } from "@testing-library/react-native" +import { fireEvent, screen, waitFor } from "@testing-library/react-native" import { GlobalSearchInput } from "app/Components/GlobalSearchInput/GlobalSearchInput" import { GlobalSearchInputOverlay } from "app/Components/GlobalSearchInput/GlobalSearchInputOverlay" +import { navigate } from "app/system/navigation/navigate" import { useSelectedTab } from "app/utils/hooks/useSelectedTab" import { requestPhotos } from "app/utils/requestPhotos" import { mockTrackEvent } from "app/utils/tests/globallyMockedStuff" import { renderWithWrappers } from "app/utils/tests/renderWithWrappers" +import { uploadImageToS3 } from "app/utils/uploadImageToS3" import ImagePicker from "react-native-image-crop-picker" jest.mock("app/utils/requestPhotos", () => ({ requestPhotos: jest.fn(), })) +jest.mock("app/utils/uploadImageToS3", () => ({ + uploadImageToS3: jest.fn(), +})) + jest.mock("app/utils/hooks/useSelectedTab", () => ({ useSelectedTab: jest.fn(), })) @@ -69,20 +75,38 @@ describe("GlobalSearchInput", () => { expect(screen.getByText("Add an image")).toBeTruthy() }) - it("opens the camera when tapping take a photo", async () => { + it("uploads the camera photo and opens the image-search results", async () => { + ;(ImagePicker.openCamera as jest.Mock).mockResolvedValueOnce({ path: "file:///camera.jpg" }) + ;(uploadImageToS3 as jest.Mock).mockResolvedValueOnce({ + key: "some-key", + bucket: "some-bucket", + }) renderOverlay() fireEvent.press(await screen.findByText("Take a photo")) expect(ImagePicker.openCamera).toHaveBeenCalledWith({ mediaType: "photo" }) + await waitFor(() => expect(uploadImageToS3).toHaveBeenCalledWith("file:///camera.jpg")) + expect(navigate).toHaveBeenCalledWith("/image-search-results", { + passProps: { s3Key: "some-key", s3Bucket: "some-bucket" }, + }) }) - it("opens the photo library when tapping add an image", async () => { + it("uploads the selected image and opens the image-search results", async () => { + ;(requestPhotos as jest.Mock).mockResolvedValueOnce([{ path: "file:///library.jpg" }]) + ;(uploadImageToS3 as jest.Mock).mockResolvedValueOnce({ + key: "some-key", + bucket: "some-bucket", + }) renderOverlay() fireEvent.press(await screen.findByText("Add an image")) expect(requestPhotos).toHaveBeenCalledWith(false) + await waitFor(() => expect(uploadImageToS3).toHaveBeenCalledWith("file:///library.jpg")) + expect(navigate).toHaveBeenCalledWith("/image-search-results", { + passProps: { s3Key: "some-key", s3Bucket: "some-bucket" }, + }) }) it("collapses to the title (without hiding) once the user starts typing", async () => { diff --git a/src/app/Navigation/routes.tsx b/src/app/Navigation/routes.tsx index 773d7f67b0d..52809663ff5 100644 --- a/src/app/Navigation/routes.tsx +++ b/src/app/Navigation/routes.tsx @@ -116,6 +116,7 @@ import { HOME_SECTION_SCREEN_QUERY, HomeViewSectionScreenQueryRenderer, } from "app/Scenes/HomeViewSectionScreen/HomeViewSectionScreen" +import { ImageSearchResultsScreen } from "app/Scenes/ImageSearchResults/ImageSearchResults" import { MakeOfferModalQueryRenderer, MakeOfferModalScreenQuery, @@ -592,6 +593,17 @@ export const artsyDotNetRoutes = defineRoutes([ }, }, }, + { + path: "/image-search-results", + name: "ImageSearchResults", + Component: ImageSearchResultsScreen, + options: { + hidesBottomTabs: true, + screenOptions: { + headerShown: false, + }, + }, + }, { path: "/artwork/:artworkID", name: "Artwork", diff --git a/src/app/Scenes/ImageSearchResults/ImageSearchResults.tsx b/src/app/Scenes/ImageSearchResults/ImageSearchResults.tsx new file mode 100644 index 00000000000..2d1fde9eecf --- /dev/null +++ b/src/app/Scenes/ImageSearchResults/ImageSearchResults.tsx @@ -0,0 +1,98 @@ +import { OwnerType } from "@artsy/cohesion" +import { Screen, SimpleMessage, Spacer } from "@artsy/palette-mobile" +import { ImageSearchResultsQuery } from "__generated__/ImageSearchResultsQuery.graphql" +import { PlaceholderGrid } from "app/Components/ArtworkGrids/GenericGrid" +import { MasonryInfiniteScrollArtworkGrid } from "app/Components/ArtworkGrids/MasonryInfiniteScrollArtworkGrid" +import { goBack } from "app/system/navigation/navigate" +import { extractNodes } from "app/utils/extractNodes" +import { ProvidePlaceholderContext } from "app/utils/placeholders" +import { Suspense } from "react" +import { graphql, useLazyLoadQuery } from "react-relay" + +const SCREEN_TITLE = "Visual Search" + +interface ImageSearchResultsProps { + s3Bucket: string + s3Key: string +} + +export const ImageSearchResults: React.FC = ({ s3Bucket, s3Key }) => { + const data = useLazyLoadQuery(imageSearchResultsQuery, { + s3Bucket, + s3Key, + }) + + const artworks = extractNodes(data.artworksByImageConnection) + + return ( + + + + + + + + + ) +} + +const ArtworksGrid: React.FC<{ artworks: any[] }> = ({ artworks }) => { + const { scrollHandler } = Screen.useListenForScreenScroll() + + return ( + + We couldn’t find any matches for that image. Try another photo. + + } + hasMore={false} + onScroll={scrollHandler} + /> + ) +} + +export const imageSearchResultsQuery = graphql` + query ImageSearchResultsQuery($s3Bucket: String!, $s3Key: String!) { + artworksByImageConnection(s3Bucket: $s3Bucket, s3Key: $s3Key, first: 30) { + edges { + node { + id + slug + image(includeAll: false) { + aspectRatio + blurhash + } + ...ArtworkGridItem_artwork @arguments(includeAllImages: false) + } + } + } + } +` + +export const ImageSearchResultsScreen: React.FC = (props) => { + return ( + }> + + + ) +} + +const Placeholder = () => { + return ( + + + + + + + + + + + ) +} diff --git a/src/app/Scenes/ImageSearchResults/__tests__/ImageSearchResults.tests.tsx b/src/app/Scenes/ImageSearchResults/__tests__/ImageSearchResults.tests.tsx new file mode 100644 index 00000000000..6c6a0eeef32 --- /dev/null +++ b/src/app/Scenes/ImageSearchResults/__tests__/ImageSearchResults.tests.tsx @@ -0,0 +1,28 @@ +import { screen } from "@testing-library/react-native" +import { ImageSearchResultsScreen } from "app/Scenes/ImageSearchResults/ImageSearchResults" +import { setupTestWrapper } from "app/utils/tests/setupTestWrapper" + +describe("ImageSearchResults", () => { + const { renderWithRelay } = setupTestWrapper({ + Component: () => , + }) + + it("renders the artworks matching the uploaded image", async () => { + renderWithRelay({ + ArtworkConnection: () => ({ edges: [{}] }), + Artwork: () => ({ artistNames: "Matched Artist" }), + }) + + expect(await screen.findByText("Matched Artist")).toBeTruthy() + }) + + it("shows an empty state when there are no matches", async () => { + renderWithRelay({ + ArtworkConnection: () => ({ edges: [] }), + }) + + expect( + await screen.findByText("We couldn’t find any matches for that image. Try another photo.") + ).toBeTruthy() + }) +}) From 1a066c3c19e5abc834f738f69c37fadf0a141f8e Mon Sep 17 00:00:00 2001 From: dariakoko Date: Thu, 16 Jul 2026 17:30:25 +0100 Subject: [PATCH 11/22] add pagination --- .../ImageSearchResults/ImageSearchResults.tsx | 55 ++++++++++++++++--- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/src/app/Scenes/ImageSearchResults/ImageSearchResults.tsx b/src/app/Scenes/ImageSearchResults/ImageSearchResults.tsx index 2d1fde9eecf..55062de5155 100644 --- a/src/app/Scenes/ImageSearchResults/ImageSearchResults.tsx +++ b/src/app/Scenes/ImageSearchResults/ImageSearchResults.tsx @@ -1,13 +1,19 @@ import { OwnerType } from "@artsy/cohesion" import { Screen, SimpleMessage, Spacer } from "@artsy/palette-mobile" import { ImageSearchResultsQuery } from "__generated__/ImageSearchResultsQuery.graphql" +import { + ImageSearchResults_artworks$data, + ImageSearchResults_artworks$key, +} from "__generated__/ImageSearchResults_artworks.graphql" import { PlaceholderGrid } from "app/Components/ArtworkGrids/GenericGrid" import { MasonryInfiniteScrollArtworkGrid } from "app/Components/ArtworkGrids/MasonryInfiniteScrollArtworkGrid" +import { PAGE_SIZE } from "app/Components/constants" import { goBack } from "app/system/navigation/navigate" import { extractNodes } from "app/utils/extractNodes" import { ProvidePlaceholderContext } from "app/utils/placeholders" +import { ExtractNodeType } from "app/utils/relayHelpers" import { Suspense } from "react" -import { graphql, useLazyLoadQuery } from "react-relay" +import { graphql, useLazyLoadQuery, usePaginationFragment } from "react-relay" const SCREEN_TITLE = "Visual Search" @@ -17,11 +23,17 @@ interface ImageSearchResultsProps { } export const ImageSearchResults: React.FC = ({ s3Bucket, s3Key }) => { - const data = useLazyLoadQuery(imageSearchResultsQuery, { + const queryData = useLazyLoadQuery(imageSearchResultsQuery, { s3Bucket, s3Key, + count: PAGE_SIZE, }) + const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment< + ImageSearchResultsQuery, + ImageSearchResults_artworks$key + >(artworksFragment, queryData) + const artworks = extractNodes(data.artworksByImageConnection) return ( @@ -30,13 +42,23 @@ export const ImageSearchResults: React.FC = ({ s3Bucket - + loadNext(pageSize)} + /> ) } -const ArtworksGrid: React.FC<{ artworks: any[] }> = ({ artworks }) => { +const ArtworksGrid: React.FC<{ + artworks: ExtractNodeType[] + hasNext: boolean + isLoadingNext: boolean + loadMore: (pageSize: number) => void +}> = ({ artworks, hasNext, isLoadingNext, loadMore }) => { const { scrollHandler } = Screen.useListenForScreenScroll() return ( @@ -50,15 +72,25 @@ const ArtworksGrid: React.FC<{ artworks: any[] }> = ({ artworks }) => { We couldn’t find any matches for that image. Try another photo. } - hasMore={false} + hasMore={hasNext} + isLoading={isLoadingNext} + loadMore={loadMore} onScroll={scrollHandler} /> ) } -export const imageSearchResultsQuery = graphql` - query ImageSearchResultsQuery($s3Bucket: String!, $s3Key: String!) { - artworksByImageConnection(s3Bucket: $s3Bucket, s3Key: $s3Key, first: 30) { +const artworksFragment = graphql` + fragment ImageSearchResults_artworks on Query + @refetchable(queryName: "ImageSearchResultsPaginationQuery") + @argumentDefinitions( + s3Bucket: { type: "String!" } + s3Key: { type: "String!" } + count: { type: "Int", defaultValue: 30 } + after: { type: "String" } + ) { + artworksByImageConnection(s3Bucket: $s3Bucket, s3Key: $s3Key, first: $count, after: $after) + @connection(key: "ImageSearchResults_artworksByImageConnection") { edges { node { id @@ -74,6 +106,13 @@ export const imageSearchResultsQuery = graphql` } ` +export const imageSearchResultsQuery = graphql` + query ImageSearchResultsQuery($s3Bucket: String!, $s3Key: String!, $count: Int, $after: String) { + ...ImageSearchResults_artworks + @arguments(s3Bucket: $s3Bucket, s3Key: $s3Key, count: $count, after: $after) + } +` + export const ImageSearchResultsScreen: React.FC = (props) => { return ( }> From b11c015ca36032395923c50bd4e9f0d7cf615c0e Mon Sep 17 00:00:00 2001 From: dariakoko Date: Thu, 16 Jul 2026 17:37:58 +0100 Subject: [PATCH 12/22] add loading state --- .../GlobalSearchInputOverlay.tsx | 30 +++++++++++++++---- .../__tests__/GlobalSearchInput.tests.tsx | 27 +++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx index 1ced40ab967..21776908587 100644 --- a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx +++ b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx @@ -117,6 +117,7 @@ export const GlobalSearchInputOverlay: React.FC<{ const [query, setQuery] = useState("") const [shouldRender, setShouldRender] = useState(false) const [isFooterExpanded, setIsFooterExpanded] = useState(true) + const [uploadingSource, setUploadingSource] = useState<"camera" | "library" | null>(null) const insets = useSafeAreaInsets() const { goBack, canGoBack } = useNavigation() const opacity = useSharedValue(0) @@ -166,11 +167,16 @@ export const GlobalSearchInputOverlay: React.FC<{ // Also collapse it as soon as the user starts scrolling the results/recent searches. const collapseFooter = useCallback(() => setIsFooterExpanded(false), []) - const uploadAndSearchByImage = async (imagePath?: string) => { + const uploadAndSearchByImage = async ( + imagePath: string | undefined, + source: "camera" | "library" + ) => { if (!imagePath) { return } + // Uploading to S3 can take a few seconds — show a loading state so the user has feedback. + setUploadingSource(source) try { const { key, bucket } = await uploadImageToS3(imagePath) hideModal() @@ -179,13 +185,15 @@ export const GlobalSearchInputOverlay: React.FC<{ }) } catch (error) { console.error("Failed to upload image to S3", error) + } finally { + setUploadingSource(null) } } const handleTakePhoto = async () => { try { const photo = await ImagePicker.openCamera({ mediaType: "photo" }) - await uploadAndSearchByImage(photo.path) + await uploadAndSearchByImage(photo.path, "camera") } catch (error) { // User cancelled the camera or denied permission — no-op } @@ -194,7 +202,7 @@ export const GlobalSearchInputOverlay: React.FC<{ const handleAddImage = async () => { try { const [photo] = await requestPhotos(false) - await uploadAndSearchByImage(photo?.path) + await uploadAndSearchByImage(photo?.path, "library") } catch (error) { // User cancelled the photo picker — no-op } @@ -262,7 +270,13 @@ export const GlobalSearchInputOverlay: React.FC<{ - @@ -270,7 +284,13 @@ export const GlobalSearchInputOverlay: React.FC<{ - diff --git a/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx b/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx index e8af50ceab9..b408d3e33c0 100644 --- a/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx +++ b/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx @@ -109,6 +109,33 @@ describe("GlobalSearchInput", () => { }) }) + it("shows a loading state while uploading and only navigates once it finishes", async () => { + ;(navigate as jest.Mock).mockClear() + ;(ImagePicker.openCamera as jest.Mock).mockResolvedValueOnce({ path: "file:///camera.jpg" }) + let resolveUpload: (value: { key: string; bucket: string }) => void = () => {} + ;(uploadImageToS3 as jest.Mock).mockReturnValueOnce( + new Promise((resolve) => { + resolveUpload = resolve + }) + ) + renderOverlay() + + fireEvent.press(await screen.findByText("Take a photo")) + + // upload is in flight — the button shows a loading state and we haven't navigated yet + await waitFor(() => expect(uploadImageToS3).toHaveBeenCalledWith("file:///camera.jpg")) + expect(navigate).not.toHaveBeenCalled() + + // once the upload resolves, we navigate to the results screen + resolveUpload({ key: "some-key", bucket: "some-bucket" }) + + await waitFor(() => + expect(navigate).toHaveBeenCalledWith("/image-search-results", { + passProps: { s3Key: "some-key", s3Bucket: "some-bucket" }, + }) + ) + }) + it("collapses to the title (without hiding) once the user starts typing", async () => { renderOverlay() From a11cee563115e66e32aa81cbeef166e7f67f6057 Mon Sep 17 00:00:00 2001 From: dariakoko Date: Fri, 17 Jul 2026 09:58:44 +0100 Subject: [PATCH 13/22] match versions --- app.json | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app.json b/app.json index 5fbef154f35..fe1a9dfad65 100644 --- a/app.json +++ b/app.json @@ -1,6 +1,6 @@ { "appName": "eigen", - "version": "9.13.0", + "version": "9.14.0", "slug": "eigen", "expo": { "buildCacheProvider": { @@ -14,10 +14,7 @@ } } }, - "platforms": [ - "ios", - "android" - ], + "platforms": ["ios", "android"], "runtimeVersion": "2c6a148974d40bab8d3796d7b69c5f0208b08478", "owner": "artsy_org", "extra": { From c2adde529b07c8d4e91f5267ad90d34460acf824 Mon Sep 17 00:00:00 2001 From: dariakoko Date: Fri, 17 Jul 2026 11:02:54 +0100 Subject: [PATCH 14/22] crop photo after taking it --- .../Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx | 3 ++- .../GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx index 21776908587..e5044e734e3 100644 --- a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx +++ b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx @@ -192,7 +192,8 @@ export const GlobalSearchInputOverlay: React.FC<{ const handleTakePhoto = async () => { try { - const photo = await ImagePicker.openCamera({ mediaType: "photo" }) + // `cropping` lets the user crop the shot before we search with it. + const photo = await ImagePicker.openCamera({ mediaType: "photo", cropping: true }) await uploadAndSearchByImage(photo.path, "camera") } catch (error) { // User cancelled the camera or denied permission — no-op diff --git a/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx b/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx index b408d3e33c0..422cb0363dd 100644 --- a/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx +++ b/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx @@ -85,7 +85,7 @@ describe("GlobalSearchInput", () => { fireEvent.press(await screen.findByText("Take a photo")) - expect(ImagePicker.openCamera).toHaveBeenCalledWith({ mediaType: "photo" }) + expect(ImagePicker.openCamera).toHaveBeenCalledWith({ mediaType: "photo", cropping: true }) await waitFor(() => expect(uploadImageToS3).toHaveBeenCalledWith("file:///camera.jpg")) expect(navigate).toHaveBeenCalledWith("/image-search-results", { passProps: { s3Key: "some-key", s3Bucket: "some-bucket" }, From e74b5b013fd71290a67824b9fa1ae8b0546fcbf3 Mon Sep 17 00:00:00 2001 From: dariakoko Date: Fri, 17 Jul 2026 11:07:49 +0100 Subject: [PATCH 15/22] cropp images --- .../GlobalSearchInputOverlay.tsx | 19 ++++++++++++++----- .../__tests__/GlobalSearchInput.tests.tsx | 19 +++++++++++-------- src/setupJest.tsx | 1 + 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx index e5044e734e3..dc70a20e1f9 100644 --- a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx +++ b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx @@ -28,7 +28,6 @@ import { SEARCH_PILLS } from "app/Scenes/Search/constants" // eslint-disable-next-line no-restricted-imports import { navigate } from "app/system/navigation/navigate" import { useBackHandler } from "app/utils/hooks/useBackHandler" -import { requestPhotos } from "app/utils/requestPhotos" import { uploadImageToS3 } from "app/utils/uploadImageToS3" import { Suspense, useCallback, useEffect, useState } from "react" import { ScrollView, StyleSheet } from "react-native" @@ -192,8 +191,13 @@ export const GlobalSearchInputOverlay: React.FC<{ const handleTakePhoto = async () => { try { - // `cropping` lets the user crop the shot before we search with it. - const photo = await ImagePicker.openCamera({ mediaType: "photo", cropping: true }) + // `cropping` lets the user crop the shot before we search with it; + // `freeStyleCropEnabled` allows a free-form crop rectangle instead of a locked square. + const photo = await ImagePicker.openCamera({ + mediaType: "photo", + cropping: true, + freeStyleCropEnabled: true, + }) await uploadAndSearchByImage(photo.path, "camera") } catch (error) { // User cancelled the camera or denied permission — no-op @@ -202,8 +206,13 @@ export const GlobalSearchInputOverlay: React.FC<{ const handleAddImage = async () => { try { - const [photo] = await requestPhotos(false) - await uploadAndSearchByImage(photo?.path, "library") + // Pick from the gallery with a free-form crop step before we search with the image. + const photo = await ImagePicker.openPicker({ + mediaType: "photo", + cropping: true, + freeStyleCropEnabled: true, + }) + await uploadAndSearchByImage(photo.path, "library") } catch (error) { // User cancelled the photo picker — no-op } diff --git a/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx b/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx index 422cb0363dd..bcd414cdb04 100644 --- a/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx +++ b/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx @@ -5,16 +5,11 @@ import { GlobalSearchInput } from "app/Components/GlobalSearchInput/GlobalSearch import { GlobalSearchInputOverlay } from "app/Components/GlobalSearchInput/GlobalSearchInputOverlay" import { navigate } from "app/system/navigation/navigate" import { useSelectedTab } from "app/utils/hooks/useSelectedTab" -import { requestPhotos } from "app/utils/requestPhotos" import { mockTrackEvent } from "app/utils/tests/globallyMockedStuff" import { renderWithWrappers } from "app/utils/tests/renderWithWrappers" import { uploadImageToS3 } from "app/utils/uploadImageToS3" import ImagePicker from "react-native-image-crop-picker" -jest.mock("app/utils/requestPhotos", () => ({ - requestPhotos: jest.fn(), -})) - jest.mock("app/utils/uploadImageToS3", () => ({ uploadImageToS3: jest.fn(), })) @@ -85,7 +80,11 @@ describe("GlobalSearchInput", () => { fireEvent.press(await screen.findByText("Take a photo")) - expect(ImagePicker.openCamera).toHaveBeenCalledWith({ mediaType: "photo", cropping: true }) + expect(ImagePicker.openCamera).toHaveBeenCalledWith({ + mediaType: "photo", + cropping: true, + freeStyleCropEnabled: true, + }) await waitFor(() => expect(uploadImageToS3).toHaveBeenCalledWith("file:///camera.jpg")) expect(navigate).toHaveBeenCalledWith("/image-search-results", { passProps: { s3Key: "some-key", s3Bucket: "some-bucket" }, @@ -93,7 +92,7 @@ describe("GlobalSearchInput", () => { }) it("uploads the selected image and opens the image-search results", async () => { - ;(requestPhotos as jest.Mock).mockResolvedValueOnce([{ path: "file:///library.jpg" }]) + ;(ImagePicker.openPicker as jest.Mock).mockResolvedValueOnce({ path: "file:///library.jpg" }) ;(uploadImageToS3 as jest.Mock).mockResolvedValueOnce({ key: "some-key", bucket: "some-bucket", @@ -102,7 +101,11 @@ describe("GlobalSearchInput", () => { fireEvent.press(await screen.findByText("Add an image")) - expect(requestPhotos).toHaveBeenCalledWith(false) + expect(ImagePicker.openPicker).toHaveBeenCalledWith({ + mediaType: "photo", + cropping: true, + freeStyleCropEnabled: true, + }) await waitFor(() => expect(uploadImageToS3).toHaveBeenCalledWith("file:///library.jpg")) expect(navigate).toHaveBeenCalledWith("/image-search-results", { passProps: { s3Key: "some-key", s3Bucket: "some-bucket" }, diff --git a/src/setupJest.tsx b/src/setupJest.tsx index c41d04444e7..ec83b79ad00 100644 --- a/src/setupJest.tsx +++ b/src/setupJest.tsx @@ -330,6 +330,7 @@ jest.mock("react-native/Libraries/LayoutAnimation/LayoutAnimation", () => ({ jest.mock("react-native-image-crop-picker", () => ({ openPicker: jest.fn(), openCamera: jest.fn(), + openCropper: jest.fn(), cleanSingle: jest.fn(), clean: jest.fn(), })) From bf5296ea7ce0bb068ad4f800157d0d0fd50bae1a Mon Sep 17 00:00:00 2001 From: dariakoko Date: Fri, 17 Jul 2026 11:42:13 +0100 Subject: [PATCH 16/22] adjust the image picker and cropping --- .../GlobalSearchInputOverlay.tsx | 13 +++---- .../__tests__/GlobalSearchInput.tests.tsx | 15 ++++---- .../utils/__tests__/requestPhotos.tests.ts | 38 +++++++++++++++++++ src/app/utils/requestPhotos.ts | 26 ++++++++++++- 4 files changed, 76 insertions(+), 16 deletions(-) diff --git a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx index dc70a20e1f9..9fb38806678 100644 --- a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx +++ b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx @@ -28,6 +28,7 @@ import { SEARCH_PILLS } from "app/Scenes/Search/constants" // eslint-disable-next-line no-restricted-imports import { navigate } from "app/system/navigation/navigate" import { useBackHandler } from "app/utils/hooks/useBackHandler" +import { requestPhotos } from "app/utils/requestPhotos" import { uploadImageToS3 } from "app/utils/uploadImageToS3" import { Suspense, useCallback, useEffect, useState } from "react" import { ScrollView, StyleSheet } from "react-native" @@ -161,7 +162,7 @@ export const GlobalSearchInputOverlay: React.FC<{ // query is cleared. It's never hidden — the title stays with a chevron to re-expand. useEffect(() => { setIsFooterExpanded(!query) - }, [query]) + }, [isFooterExpanded, query]) // Also collapse it as soon as the user starts scrolling the results/recent searches. const collapseFooter = useCallback(() => setIsFooterExpanded(false), []) @@ -206,13 +207,9 @@ export const GlobalSearchInputOverlay: React.FC<{ const handleAddImage = async () => { try { - // Pick from the gallery with a free-form crop step before we search with the image. - const photo = await ImagePicker.openPicker({ - mediaType: "photo", - cropping: true, - freeStyleCropEnabled: true, - }) - await uploadAndSearchByImage(photo.path, "library") + // Keep the standard photo picker, with a free-form crop step before we search. + const [photo] = await requestPhotos(false, { cropping: true }) + await uploadAndSearchByImage(photo?.path, "library") } catch (error) { // User cancelled the photo picker — no-op } diff --git a/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx b/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx index bcd414cdb04..e35e1679c01 100644 --- a/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx +++ b/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx @@ -5,11 +5,16 @@ import { GlobalSearchInput } from "app/Components/GlobalSearchInput/GlobalSearch import { GlobalSearchInputOverlay } from "app/Components/GlobalSearchInput/GlobalSearchInputOverlay" import { navigate } from "app/system/navigation/navigate" import { useSelectedTab } from "app/utils/hooks/useSelectedTab" +import { requestPhotos } from "app/utils/requestPhotos" import { mockTrackEvent } from "app/utils/tests/globallyMockedStuff" import { renderWithWrappers } from "app/utils/tests/renderWithWrappers" import { uploadImageToS3 } from "app/utils/uploadImageToS3" import ImagePicker from "react-native-image-crop-picker" +jest.mock("app/utils/requestPhotos", () => ({ + requestPhotos: jest.fn(), +})) + jest.mock("app/utils/uploadImageToS3", () => ({ uploadImageToS3: jest.fn(), })) @@ -91,8 +96,8 @@ describe("GlobalSearchInput", () => { }) }) - it("uploads the selected image and opens the image-search results", async () => { - ;(ImagePicker.openPicker as jest.Mock).mockResolvedValueOnce({ path: "file:///library.jpg" }) + it("uploads the selected (cropped) image and opens the image-search results", async () => { + ;(requestPhotos as jest.Mock).mockResolvedValueOnce([{ path: "file:///library.jpg" }]) ;(uploadImageToS3 as jest.Mock).mockResolvedValueOnce({ key: "some-key", bucket: "some-bucket", @@ -101,11 +106,7 @@ describe("GlobalSearchInput", () => { fireEvent.press(await screen.findByText("Add an image")) - expect(ImagePicker.openPicker).toHaveBeenCalledWith({ - mediaType: "photo", - cropping: true, - freeStyleCropEnabled: true, - }) + expect(requestPhotos).toHaveBeenCalledWith(false, { cropping: true }) await waitFor(() => expect(uploadImageToS3).toHaveBeenCalledWith("file:///library.jpg")) expect(navigate).toHaveBeenCalledWith("/image-search-results", { passProps: { s3Key: "some-key", s3Bucket: "some-bucket" }, diff --git a/src/app/utils/__tests__/requestPhotos.tests.ts b/src/app/utils/__tests__/requestPhotos.tests.ts index 8f062998176..9e0b852218e 100644 --- a/src/app/utils/__tests__/requestPhotos.tests.ts +++ b/src/app/utils/__tests__/requestPhotos.tests.ts @@ -19,6 +19,27 @@ describe("requestPhotos", () => { requestPhotos() expect(mockRequestPhotos).toHaveBeenCalled() }) + + it("uses the crop-capable picker (not the native module) when cropping is requested", async () => { + Platform.OS = "ios" + Object.defineProperty(Platform, "Version", { + get: () => 15, + }) + const mockRequestPhotos = jest.fn() + LegacyNativeModules.ARPHPhotoPickerModule.requestPhotos = mockRequestPhotos + ;(openPicker as jest.Mock).mockResolvedValue({ path: "file:///cropped.jpg" }) + + const result = await requestPhotos(false, { cropping: true }) + + expect(mockRequestPhotos).not.toHaveBeenCalled() + expect(openPicker).toHaveBeenCalledWith({ + mediaType: "photo", + multiple: false, + cropping: true, + freeStyleCropEnabled: true, + }) + expect(result).toEqual([{ path: "file:///cropped.jpg" }]) + }) }) describe("on Android", () => { @@ -30,5 +51,22 @@ describe("requestPhotos", () => { requestPhotos() expect(openPicker).toHaveBeenCalled() }) + + it("passes cropping options to the picker when cropping is requested", async () => { + Platform.OS = "android" + Object.defineProperty(Platform, "Version", { + get: () => 23, + }) + ;(openPicker as jest.Mock).mockResolvedValue({ path: "file:///a.jpg" }) + + await requestPhotos(false, { cropping: true }) + + expect(openPicker).toHaveBeenCalledWith({ + mediaType: "photo", + multiple: false, + cropping: true, + freeStyleCropEnabled: true, + }) + }) }) }) diff --git a/src/app/utils/requestPhotos.ts b/src/app/utils/requestPhotos.ts index 9a568f9aee4..3f1b8a1d54f 100644 --- a/src/app/utils/requestPhotos.ts +++ b/src/app/utils/requestPhotos.ts @@ -5,7 +5,31 @@ import { Platform } from "react-native" import ImagePicker, { Image } from "react-native-image-crop-picker" import { osMajorVersion } from "./platformUtil" -export async function requestPhotos(allowMultiple = true): Promise { +interface RequestPhotosOptions { + /** + * Let the user crop each selected image (free-form) before it's returned. + * Intended for single selection (`allowMultiple = false`). + */ + cropping?: boolean +} + +export async function requestPhotos( + allowMultiple = true, + { cropping = false }: RequestPhotosOptions = {} +): Promise { + // The native iOS picker can't crop, and presenting a cropper after it dismisses is unreliable. + // So when cropping is requested we use image-crop-picker's openPicker, which does pick + crop + // in a single native flow. Cropping implies single selection. + if (cropping) { + const image = await ImagePicker.openPicker({ + mediaType: "photo", + multiple: false, + cropping: true, + freeStyleCropEnabled: true, + }) + return isArray(image) ? image : [image] + } + if (Platform.OS === "ios" && osMajorVersion() >= 14) { return LegacyNativeModules.ARPHPhotoPickerModule.requestPhotos(allowMultiple) } else { From 6d29cbc0f02e8dc1e5094e1b8824585faf0f57be Mon Sep 17 00:00:00 2001 From: dariakoko Date: Fri, 17 Jul 2026 11:57:14 +0100 Subject: [PATCH 17/22] fix the show/hode the banner --- .../Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx index 9fb38806678..e6990192377 100644 --- a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx +++ b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx @@ -162,7 +162,7 @@ export const GlobalSearchInputOverlay: React.FC<{ // query is cleared. It's never hidden — the title stays with a chevron to re-expand. useEffect(() => { setIsFooterExpanded(!query) - }, [isFooterExpanded, query]) + }, [query]) // Also collapse it as soon as the user starts scrolling the results/recent searches. const collapseFooter = useCallback(() => setIsFooterExpanded(false), []) From 5ec1402087867c6eabb9e37bcc7d8260350bdd2e Mon Sep 17 00:00:00 2001 From: dariakoko Date: Fri, 17 Jul 2026 11:58:33 +0100 Subject: [PATCH 18/22] adjust image select and crop (TODO: do not use Legacy?) --- .../utils/__tests__/requestPhotos.tests.ts | 30 +++++++++----- src/app/utils/requestPhotos.ts | 40 ++++++++++++------- 2 files changed, 45 insertions(+), 25 deletions(-) diff --git a/src/app/utils/__tests__/requestPhotos.tests.ts b/src/app/utils/__tests__/requestPhotos.tests.ts index 9e0b852218e..4566d94da32 100644 --- a/src/app/utils/__tests__/requestPhotos.tests.ts +++ b/src/app/utils/__tests__/requestPhotos.tests.ts @@ -1,13 +1,24 @@ import { LegacyNativeModules } from "app/NativeModules/LegacyNativeModules" import { requestPhotos } from "app/utils/requestPhotos" -import { Platform } from "react-native" -import { openPicker } from "react-native-image-crop-picker" +import { InteractionManager, Platform } from "react-native" +import { openCropper, openPicker } from "react-native-image-crop-picker" jest.mock("react-native-image-crop-picker", () => ({ openPicker: jest.fn(), + openCropper: jest.fn(), })) describe("requestPhotos", () => { + beforeEach(() => { + // Run the deferred cropper callback synchronously in tests. + jest.spyOn(InteractionManager, "runAfterInteractions").mockImplementation((( + task?: () => void + ) => { + task?.() + return { then: () => undefined, done: () => undefined, cancel: () => undefined } + }) as any) + }) + describe("on iOS", () => { it("calls the native module on iOS 14 and above", () => { Platform.OS = "ios" @@ -20,22 +31,21 @@ describe("requestPhotos", () => { expect(mockRequestPhotos).toHaveBeenCalled() }) - it("uses the crop-capable picker (not the native module) when cropping is requested", async () => { + it("keeps the native grid picker and crops each image afterwards when cropping is requested", async () => { Platform.OS = "ios" Object.defineProperty(Platform, "Version", { get: () => 15, }) - const mockRequestPhotos = jest.fn() - LegacyNativeModules.ARPHPhotoPickerModule.requestPhotos = mockRequestPhotos - ;(openPicker as jest.Mock).mockResolvedValue({ path: "file:///cropped.jpg" }) + LegacyNativeModules.ARPHPhotoPickerModule.requestPhotos = jest + .fn() + .mockResolvedValue([{ path: "file:///original.jpg" }]) + ;(openCropper as jest.Mock).mockResolvedValue({ path: "file:///cropped.jpg" }) const result = await requestPhotos(false, { cropping: true }) - expect(mockRequestPhotos).not.toHaveBeenCalled() - expect(openPicker).toHaveBeenCalledWith({ + expect(openCropper).toHaveBeenCalledWith({ + path: "file:///original.jpg", mediaType: "photo", - multiple: false, - cropping: true, freeStyleCropEnabled: true, }) expect(result).toEqual([{ path: "file:///cropped.jpg" }]) diff --git a/src/app/utils/requestPhotos.ts b/src/app/utils/requestPhotos.ts index 3f1b8a1d54f..e3e8a323054 100644 --- a/src/app/utils/requestPhotos.ts +++ b/src/app/utils/requestPhotos.ts @@ -1,7 +1,7 @@ import { ActionSheetOptions } from "@expo/react-native-action-sheet" import { LegacyNativeModules } from "app/NativeModules/LegacyNativeModules" import { isArray } from "lodash" -import { Platform } from "react-native" +import { InteractionManager, Platform } from "react-native" import ImagePicker, { Image } from "react-native-image-crop-picker" import { osMajorVersion } from "./platformUtil" @@ -13,29 +13,39 @@ interface RequestPhotosOptions { cropping?: boolean } -export async function requestPhotos( - allowMultiple = true, - { cropping = false }: RequestPhotosOptions = {} -): Promise { - // The native iOS picker can't crop, and presenting a cropper after it dismisses is unreliable. - // So when cropping is requested we use image-crop-picker's openPicker, which does pick + crop - // in a single native flow. Cropping implies single selection. - if (cropping) { - const image = await ImagePicker.openPicker({ +const cropImage = (path: string): Promise => + // Wait for the picker to finish dismissing before presenting the cropper — presenting it + // while the picker is still on screen makes the cropper silently fail to appear. + new Promise((resolve) => { + InteractionManager.runAfterInteractions(() => resolve(undefined)) + }).then(() => + ImagePicker.openCropper({ + path, mediaType: "photo", - multiple: false, - cropping: true, freeStyleCropEnabled: true, }) - return isArray(image) ? image : [image] - } + ) +export async function requestPhotos( + allowMultiple = true, + { cropping = false }: RequestPhotosOptions = {} +): Promise { if (Platform.OS === "ios" && osMajorVersion() >= 14) { - return LegacyNativeModules.ARPHPhotoPickerModule.requestPhotos(allowMultiple) + // Keeps the native grid picker (opens straight to photos, not the albums list). + const images: Image[] = + await LegacyNativeModules.ARPHPhotoPickerModule.requestPhotos(allowMultiple) + + if (!cropping) { + return images + } + + // The native picker has no crop step, so crop each selected image afterwards. + return Promise.all(images.map((image) => cropImage(image.path))) } else { const images = await ImagePicker.openPicker({ mediaType: "photo", multiple: allowMultiple, + ...(cropping ? { cropping: true, freeStyleCropEnabled: true } : {}), }) if (isArray(images)) { return images From ee89be4226f0b54756948bd88d9624389ba542a7 Mon Sep 17 00:00:00 2001 From: dariakoko Date: Fri, 17 Jul 2026 16:39:29 +0100 Subject: [PATCH 19/22] do not hide the search --- .../Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx index e6990192377..9e45f7e6c07 100644 --- a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx +++ b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx @@ -179,7 +179,8 @@ export const GlobalSearchInputOverlay: React.FC<{ setUploadingSource(source) try { const { key, bucket } = await uploadImageToS3(imagePath) - hideModal() + // Keep the overlay open (don't hideModal) so going back from the results screen + // returns the user to the search overlay with the image-search buttons still there. navigate("/image-search-results", { passProps: { s3Key: key, s3Bucket: bucket }, }) From 3f82588ac08d73e47f8f4cc5afa6b5b1f2f8afd9 Mon Sep 17 00:00:00 2001 From: dariakoko Date: Fri, 17 Jul 2026 16:39:53 +0100 Subject: [PATCH 20/22] fix image cropping --- src/app/utils/__tests__/requestPhotos.tests.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/app/utils/__tests__/requestPhotos.tests.ts b/src/app/utils/__tests__/requestPhotos.tests.ts index 4566d94da32..93cb0d48834 100644 --- a/src/app/utils/__tests__/requestPhotos.tests.ts +++ b/src/app/utils/__tests__/requestPhotos.tests.ts @@ -1,6 +1,6 @@ import { LegacyNativeModules } from "app/NativeModules/LegacyNativeModules" import { requestPhotos } from "app/utils/requestPhotos" -import { InteractionManager, Platform } from "react-native" +import { Platform } from "react-native" import { openCropper, openPicker } from "react-native-image-crop-picker" jest.mock("react-native-image-crop-picker", () => ({ @@ -9,16 +9,6 @@ jest.mock("react-native-image-crop-picker", () => ({ })) describe("requestPhotos", () => { - beforeEach(() => { - // Run the deferred cropper callback synchronously in tests. - jest.spyOn(InteractionManager, "runAfterInteractions").mockImplementation((( - task?: () => void - ) => { - task?.() - return { then: () => undefined, done: () => undefined, cancel: () => undefined } - }) as any) - }) - describe("on iOS", () => { it("calls the native module on iOS 14 and above", () => { Platform.OS = "ios" @@ -32,6 +22,7 @@ describe("requestPhotos", () => { }) it("keeps the native grid picker and crops each image afterwards when cropping is requested", async () => { + jest.useFakeTimers() Platform.OS = "ios" Object.defineProperty(Platform, "Version", { get: () => 15, @@ -41,7 +32,9 @@ describe("requestPhotos", () => { .mockResolvedValue([{ path: "file:///original.jpg" }]) ;(openCropper as jest.Mock).mockResolvedValue({ path: "file:///cropped.jpg" }) - const result = await requestPhotos(false, { cropping: true }) + const promise = requestPhotos(false, { cropping: true }) + await jest.advanceTimersByTimeAsync(600) + const result = await promise expect(openCropper).toHaveBeenCalledWith({ path: "file:///original.jpg", @@ -49,6 +42,7 @@ describe("requestPhotos", () => { freeStyleCropEnabled: true, }) expect(result).toEqual([{ path: "file:///cropped.jpg" }]) + jest.useRealTimers() }) }) From f9d816efec830f36c1ced20fbe97486070370278 Mon Sep 17 00:00:00 2001 From: dariakoko Date: Mon, 20 Jul 2026 10:09:23 +0100 Subject: [PATCH 21/22] fix image cropper crashes --- src/app/utils/requestPhotos.ts | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/app/utils/requestPhotos.ts b/src/app/utils/requestPhotos.ts index e3e8a323054..7f6579a33b4 100644 --- a/src/app/utils/requestPhotos.ts +++ b/src/app/utils/requestPhotos.ts @@ -1,7 +1,7 @@ import { ActionSheetOptions } from "@expo/react-native-action-sheet" import { LegacyNativeModules } from "app/NativeModules/LegacyNativeModules" import { isArray } from "lodash" -import { InteractionManager, Platform } from "react-native" +import { Platform } from "react-native" import ImagePicker, { Image } from "react-native-image-crop-picker" import { osMajorVersion } from "./platformUtil" @@ -13,18 +13,20 @@ interface RequestPhotosOptions { cropping?: boolean } -const cropImage = (path: string): Promise => - // Wait for the picker to finish dismissing before presenting the cropper — presenting it - // while the picker is still on screen makes the cropper silently fail to appear. - new Promise((resolve) => { - InteractionManager.runAfterInteractions(() => resolve(undefined)) - }).then(() => - ImagePicker.openCropper({ - path, - mediaType: "photo", - freeStyleCropEnabled: true, - }) - ) +// Give the native picker time to finish dismissing before presenting the cropper. Presenting +// it too early makes the cropper silently fail. A fixed timeout is used (instead of +// InteractionManager) because navigation/animation interaction handles can otherwise leave +// `runAfterInteractions` permanently pending, which broke cropping on the 2nd+ attempt. +const CROPPER_PRESENTATION_DELAY = 600 + +const cropImage = async (path: string): Promise => { + await new Promise((resolve) => setTimeout(resolve, CROPPER_PRESENTATION_DELAY)) + return ImagePicker.openCropper({ + path, + mediaType: "photo", + freeStyleCropEnabled: true, + }) +} export async function requestPhotos( allowMultiple = true, From be30c672835e50cdf473e92d8898d47b3933849a Mon Sep 17 00:00:00 2001 From: dariakoko Date: Mon, 20 Jul 2026 13:39:20 +0100 Subject: [PATCH 22/22] add feature flag --- .../GlobalSearchInputOverlay.tsx | 100 ++++++++++-------- .../__tests__/GlobalSearchInput.tests.tsx | 30 +++++- src/app/system/flags/experiments.ts | 5 + 3 files changed, 88 insertions(+), 47 deletions(-) diff --git a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx index 9e45f7e6c07..7f404cee244 100644 --- a/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx +++ b/src/app/Components/GlobalSearchInput/GlobalSearchInputOverlay.tsx @@ -23,6 +23,7 @@ import { useRecentSearches } from "app/Scenes/Search/SearchModel" import { SearchPills } from "app/Scenes/Search/SearchPills" import { SearchResults } from "app/Scenes/Search/SearchResults" import { SEARCH_PILLS } from "app/Scenes/Search/constants" +import { useExperimentVariant } from "app/system/flags/hooks/useExperimentVariant" // Imperative navigation is required here — we navigate after the async image upload // resolves, so RouterLink (declarative) can't be used. // eslint-disable-next-line no-restricted-imports @@ -122,6 +123,10 @@ export const GlobalSearchInputOverlay: React.FC<{ const { goBack, canGoBack } = useNavigation() const opacity = useSharedValue(0) + const { variant: artsyLensVariant, trackExperiment: trackArtsyLensExperiment } = + useExperimentVariant("onyx_artsy-lens") + const showImageSearch = artsyLensVariant.enabled && artsyLensVariant.name === "variant" + useBackHandler(() => { if (!!canGoBack()) { goBack() @@ -140,6 +145,7 @@ export const GlobalSearchInputOverlay: React.FC<{ if (visible) { setShouldRender(true) opacity.value = withTiming(1, { duration: DEFAULT_SCREEN_ANIMATION_DURATION }) + trackArtsyLensExperiment() } else { KeyboardController.dismiss() opacity.value = withTiming(0, { duration: DEFAULT_SCREEN_ANIMATION_DURATION }, (finished) => { @@ -256,57 +262,59 @@ export const GlobalSearchInputOverlay: React.FC<{ before searching, and collapses to just its title once the user starts typing — the chevron lets them re-expand. KeyboardStickyView keeps it above the keyboard; the negative `closed` offset lifts it above the bottom tab bar when the keyboard folds. */} - - - - See it? Search it. + {!!showImageSearch && ( + + + + See it? Search it. - setIsFooterExpanded((expanded) => !expanded)} - hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} - > - {isFooterExpanded ? : } - - + setIsFooterExpanded((expanded) => !expanded)} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + {isFooterExpanded ? : } + + - {!!isFooterExpanded && ( - <> - - Take a photo or upload an image to find the piece that matches the mood. - - - - - + {!!isFooterExpanded && ( + <> + + Take a photo or upload an image to find the piece that matches the mood. + + + + + - + - - + + + - - - )} - - + + )} + + + )} diff --git a/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx b/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx index e35e1679c01..4d4c195659b 100644 --- a/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx +++ b/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx @@ -3,6 +3,7 @@ import { PortalHost } from "@gorhom/portal" import { fireEvent, screen, waitFor } from "@testing-library/react-native" import { GlobalSearchInput } from "app/Components/GlobalSearchInput/GlobalSearchInput" import { GlobalSearchInputOverlay } from "app/Components/GlobalSearchInput/GlobalSearchInputOverlay" +import { useExperimentVariant } from "app/system/flags/hooks/useExperimentVariant" import { navigate } from "app/system/navigation/navigate" import { useSelectedTab } from "app/utils/hooks/useSelectedTab" import { requestPhotos } from "app/utils/requestPhotos" @@ -23,15 +24,30 @@ jest.mock("app/utils/hooks/useSelectedTab", () => ({ useSelectedTab: jest.fn(), })) +jest.mock("app/system/flags/hooks/useExperimentVariant", () => ({ + useExperimentVariant: jest.fn(), +})) + jest.mock("app/Components/GlobalSearchInput/utils/useDismissSearchOverlayOnTabBarPress", () => ({ useDismissSearchOverlayOnTabBarPress: jest.fn(), })) +const mockUseExperimentVariant = useExperimentVariant as jest.Mock + +const mockArtsyLensVariant = (variantName: string, enabled = true) => { + mockUseExperimentVariant.mockReturnValue({ + variant: { name: variantName, enabled }, + trackExperiment: jest.fn(), + }) +} + describe("GlobalSearchInput", () => { const mockUseledTab = useSelectedTab as jest.Mock beforeEach(() => { mockUseledTab.mockReturnValue("home") + // Default to the treatment arm so the image-search buttons render. + mockArtsyLensVariant("variant") }) it("renders the search label properly", () => { @@ -68,13 +84,25 @@ describe("GlobalSearchInput", () => { ) - it("renders the take a photo and add an image buttons", async () => { + it("renders the take a photo and add an image buttons for the experiment variant", async () => { + mockArtsyLensVariant("variant") renderOverlay() await screen.findByText("Take a photo") expect(screen.getByText("Add an image")).toBeTruthy() }) + it("does not render the image-search buttons for the control arm", async () => { + mockArtsyLensVariant("control") + renderOverlay() + + // the overlay still renders its search input, but the image-search prompt is gone + await screen.findByLabelText("Search artists, artworks, galleries etc.") + expect(screen.queryByText("Take a photo")).toBeNull() + expect(screen.queryByText("Add an image")).toBeNull() + expect(screen.queryByText("See it? Search it.")).toBeNull() + }) + it("uploads the camera photo and opens the image-search results", async () => { ;(ImagePicker.openCamera as jest.Mock).mockResolvedValueOnce({ path: "file:///camera.jpg" }) ;(uploadImageToS3 as jest.Mock).mockResolvedValueOnce({ diff --git a/src/app/system/flags/experiments.ts b/src/app/system/flags/experiments.ts index afb5d205258..4b7edeb4949 100644 --- a/src/app/system/flags/experiments.ts +++ b/src/app/system/flags/experiments.ts @@ -16,6 +16,11 @@ export const experiments = { "onyx_artwork-recommendations-refresh-eigen": { description: "Enable live-refreshing the Home screen recommendations rail in eigen", }, + "onyx_artsy-lens": { + description: + "Enable Artsy Lens image search (take a photo / add an image) in the global search overlay", + variantSuggestions: ["control", "variant"], + }, } satisfies { [key: string]: ExperimentDescriptor } export type EXPERIMENT_NAME = keyof typeof experiments