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": { 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/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/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. */} + + + = ({ query }) => { +const GlobalSearchInputOverlayContent: React.FC<{ + query: string + onScrollBeginDrag?: () => void +}> = ({ query, onScrollBeginDrag }) => { const space = useSpace() const { data, @@ -65,6 +87,7 @@ const GlobalSearchInputOverlayContent: React.FC<{ query: string }> = ({ query }) { refetch({ term: query, skipSearchQuery: false }) }} @@ -74,6 +97,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 [uploadingSource, setUploadingSource] = useState<"camera" | "library" | null>(null) const insets = useSafeAreaInsets() 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() @@ -115,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) => { @@ -133,6 +164,64 @@ export const GlobalSearchInputOverlay: React.FC<{ } }) + // 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 | 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) + // 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 }, + }) + } catch (error) { + console.error("Failed to upload image to S3", error) + } finally { + setUploadingSource(null) + } + } + + const handleTakePhoto = async () => { + try { + // `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 + } + } + + const handleAddImage = async () => { + try { + // 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 + } + } + if (!shouldRender) { return null } @@ -140,11 +229,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). */} + + + + + + + {/* 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. */} + {!!showImageSearch && ( + + + + 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 dd4963a9e36..4d4c195659b 100644 --- a/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx +++ b/src/app/Components/GlobalSearchInput/__tests__/GlobalSearchInput.tests.tsx @@ -1,23 +1,53 @@ import { OwnerType } from "@artsy/cohesion" -import { fireEvent, screen } from "@testing-library/react-native" +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" 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(), })) +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", () => { @@ -26,6 +56,12 @@ describe("GlobalSearchInput", () => { 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() @@ -38,4 +74,138 @@ describe("GlobalSearchInput", () => { }) ) }) + + describe("when the search overlay is active", () => { + const renderOverlay = () => + renderWithWrappers( + <> + + + + ) + + 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({ + key: "some-key", + bucket: "some-bucket", + }) + renderOverlay() + + fireEvent.press(await screen.findByText("Take a photo")) + + 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" }, + }) + }) + + 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", + }) + renderOverlay() + + fireEvent.press(await screen.findByText("Add an image")) + + 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" }, + }) + }) + + 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() + + // expanded before typing: buttons visible + await screen.findByText("Take a photo") + + fireEvent.changeText( + screen.getByLabelText("Search artists, artworks, galleries etc."), + "banksy" + ) + + // 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 () => { + 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/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 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/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) 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, diff --git a/src/app/Scenes/ImageSearchResults/ImageSearchResults.tsx b/src/app/Scenes/ImageSearchResults/ImageSearchResults.tsx new file mode 100644 index 00000000000..55062de5155 --- /dev/null +++ b/src/app/Scenes/ImageSearchResults/ImageSearchResults.tsx @@ -0,0 +1,137 @@ +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, usePaginationFragment } from "react-relay" + +const SCREEN_TITLE = "Visual Search" + +interface ImageSearchResultsProps { + s3Bucket: string + s3Key: string +} + +export const ImageSearchResults: React.FC = ({ s3Bucket, s3Key }) => { + 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 ( + + + + + + loadNext(pageSize)} + /> + + + ) +} + +const ArtworksGrid: React.FC<{ + artworks: ExtractNodeType[] + hasNext: boolean + isLoadingNext: boolean + loadMore: (pageSize: number) => void +}> = ({ artworks, hasNext, isLoadingNext, loadMore }) => { + const { scrollHandler } = Screen.useListenForScreenScroll() + + return ( + + We couldn’t find any matches for that image. Try another photo. + + } + hasMore={hasNext} + isLoading={isLoadingNext} + loadMore={loadMore} + onScroll={scrollHandler} + /> + ) +} + +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 + slug + image(includeAll: false) { + aspectRatio + blurhash + } + ...ArtworkGridItem_artwork @arguments(includeAllImages: false) + } + } + } + } +` + +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 ( + }> + + + ) +} + +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() + }) +}) 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} /> 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 diff --git a/src/app/utils/__tests__/requestPhotos.tests.ts b/src/app/utils/__tests__/requestPhotos.tests.ts index 8f062998176..93cb0d48834 100644 --- a/src/app/utils/__tests__/requestPhotos.tests.ts +++ b/src/app/utils/__tests__/requestPhotos.tests.ts @@ -1,10 +1,11 @@ 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 { openCropper, openPicker } from "react-native-image-crop-picker" jest.mock("react-native-image-crop-picker", () => ({ openPicker: jest.fn(), + openCropper: jest.fn(), })) describe("requestPhotos", () => { @@ -19,6 +20,30 @@ describe("requestPhotos", () => { requestPhotos() expect(mockRequestPhotos).toHaveBeenCalled() }) + + 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, + }) + LegacyNativeModules.ARPHPhotoPickerModule.requestPhotos = jest + .fn() + .mockResolvedValue([{ path: "file:///original.jpg" }]) + ;(openCropper as jest.Mock).mockResolvedValue({ path: "file:///cropped.jpg" }) + + const promise = requestPhotos(false, { cropping: true }) + await jest.advanceTimersByTimeAsync(600) + const result = await promise + + expect(openCropper).toHaveBeenCalledWith({ + path: "file:///original.jpg", + mediaType: "photo", + freeStyleCropEnabled: true, + }) + expect(result).toEqual([{ path: "file:///cropped.jpg" }]) + jest.useRealTimers() + }) }) describe("on Android", () => { @@ -30,5 +55,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/__tests__/uploadImageToS3.tests.ts b/src/app/utils/__tests__/uploadImageToS3.tests.ts new file mode 100644 index 00000000000..7e0913e383e --- /dev/null +++ b/src/app/utils/__tests__/uploadImageToS3.tests.ts @@ -0,0 +1,48 @@ +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:///tmp/my-photo.jpg") + + expect(uploadFileToS3Mock).toHaveBeenCalledWith( + 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/requestPhotos.ts b/src/app/utils/requestPhotos.ts index 9a568f9aee4..7f6579a33b4 100644 --- a/src/app/utils/requestPhotos.ts +++ b/src/app/utils/requestPhotos.ts @@ -5,13 +5,49 @@ 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 +} + +// 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, + { 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 diff --git a/src/app/utils/uploadImageToS3.ts b/src/app/utils/uploadImageToS3.ts new file mode 100644 index 00000000000..086804115ea --- /dev/null +++ b/src/app/utils/uploadImageToS3.ts @@ -0,0 +1,43 @@ +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 + + // 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 } +} 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(), }))