Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
"@pegada/tsconfig": "workspace:*",
"@posthog/cli": "^0.8.1",
"@types/color": "^3.0.6",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.7",
"@types/react": "^19.2.14",
"@types/react-dom": "~19.2.3",
Expand Down
56 changes: 56 additions & 0 deletions apps/mobile/src/components/Image.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { renderToStaticMarkup } from "react-dom/server";
import { Image as ExpoImage } from "expo-image";

import { Image } from "./Image";

jest.mock("expo-image", () => ({ Image: jest.fn(() => null) }));

const expoImage = ExpoImage as unknown as jest.Mock;

const blurhash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj";
const uri = "https://images.pegada.app/luna.webp";

beforeEach(() => {
expoImage.mockClear();
});

test("hands every image prop to Expo Image, on a single node", () => {
const onDisplay = () => undefined;
const onLoad = () => undefined;

renderToStaticMarkup(
<Image
source={{ uri, blurhash }}
contentFit="contain"
contentPosition="top"
transition={180}
recyclingKey="luna-photo-1"
priority="high"
onDisplay={onDisplay}
onLoad={onLoad}
/>,
);

expect(expoImage).toHaveBeenCalledTimes(1);
expect(expoImage.mock.calls[0]?.[0]).toMatchObject({
source: { uri },
placeholder: { blurhash },
contentFit: "contain",
placeholderContentFit: "contain",
contentPosition: "top",
transition: 180,
recyclingKey: "luna-photo-1",
priority: "high",
cachePolicy: "memory-disk",
onDisplay,
onLoad,
});
});

test("keeps the style on the image itself instead of a wrapper", () => {
const style = { width: 80, height: 100, borderRadius: 12 };

renderToStaticMarkup(<Image source={{ uri }} style={style} />);

expect(expoImage.mock.calls[0]?.[0]).toMatchObject({ style });
});
54 changes: 24 additions & 30 deletions apps/mobile/src/components/Image.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,29 @@
import { forwardRef } from "react";
import { View } from "react-native";
import { Image as ExpoImage, ImageProps } from "expo-image";
import styled from "styled-components/native";
import { forwardRef, type ComponentRef } from "react";
import { Image as ExpoImage, type ImageProps } from "expo-image";

interface LocalImageProps extends Omit<ImageProps, "source"> {
source?: {
blurhash?: string | null | undefined;
uri?: string;
};
}
import { resolveImagePresentationProps } from "./imageProps";

const AbsoluteImage = styled(ExpoImage)`
position: absolute;
width: 100%;
height: 100%;
`;
export type LocalImageProps = ImageProps;

const ImageWrapper = styled.View`
overflow: hidden;
`;
/**
* Pegada's cached image with automatic blurhash placeholder support.
*
* This intentionally renders a single Expo Image. Keeping an outer View here
* would require manually separating every current and future Image prop from
* View props, which is how options such as contentFit and onDisplay were lost.
*/
export const Image = forwardRef<ComponentRef<typeof ExpoImage>, LocalImageProps>(
({ source, placeholder, contentFit, placeholderContentFit, cachePolicy, ...props }, ref) => {
const presentationProps = resolveImagePresentationProps({
source,
placeholder,
contentFit,
placeholderContentFit,
cachePolicy,
});

export const Image = forwardRef<View, LocalImageProps>(({ source, ...props }, ref) => {
const blurhash = source?.blurhash;
return <ExpoImage ref={ref} {...props} {...presentationProps} />;
},
);

return (
<ImageWrapper {...props} ref={ref}>
{blurhash ? <AbsoluteImage source={{ blurhash }} /> : null}
<AbsoluteImage
source={blurhash ? { ...source, blurhash: undefined } : source}
cachePolicy="memory-disk"
/>
</ImageWrapper>
);
});
Image.displayName = "Image";
102 changes: 102 additions & 0 deletions apps/mobile/src/components/imageProps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type { ComponentProps } from "react";

import type { Image } from "./Image";
import { resolveImagePresentationProps } from "./imageProps";

const blurhash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj";
const uri = "https://images.pegada.app/luna.webp";

// Compiles only while Image keeps forwarding Expo Image's own props. These are
// the ones the old layout wrapper silently swallowed.
const forwardedProps = {
source: { uri, blurhash },
contentFit: "contain",
contentPosition: "top",
transition: 180,
recyclingKey: "luna-photo-1",
onDisplay: () => undefined,
onLoad: () => undefined,
} satisfies ComponentProps<typeof Image>;

test("moves an API blurhash onto the placeholder and keeps the source clean", () => {
expect(resolveImagePresentationProps(forwardedProps)).toEqual({
source: { uri },
placeholder: { blurhash },
contentFit: "contain",
placeholderContentFit: "contain",
cachePolicy: "memory-disk",
});
});

test("defaults the blurhash fit to cover when the caller sets no contentFit", () => {
expect(resolveImagePresentationProps({ source: { uri, blurhash } })).toEqual({
source: { uri },
placeholder: { blurhash },
contentFit: undefined,
placeholderContentFit: "cover",
cachePolicy: "memory-disk",
});
});

test("leaves a caller-provided placeholder and cache policy alone", () => {
const placeholder = { uri: "file:///placeholder.webp" };

expect(
resolveImagePresentationProps({
source: { uri, blurhash },
placeholder,
placeholderContentFit: "scale-down",
cachePolicy: "disk",
}),
).toEqual({
source: { uri },
placeholder,
contentFit: undefined,
placeholderContentFit: "scale-down",
cachePolicy: "disk",
});
});

test("treats an explicit null placeholder and cache policy as opting out", () => {
expect(
resolveImagePresentationProps({
source: { uri, blurhash },
placeholder: null,
cachePolicy: null,
}),
).toEqual({
source: { uri },
placeholder: null,
contentFit: undefined,
placeholderContentFit: undefined,
cachePolicy: null,
});
});

test.each([42, "https://images.pegada.app/static.webp", null] as const)(
"passes a %p source straight through",
(source) => {
expect(resolveImagePresentationProps({ source })).toEqual({
source,
placeholder: undefined,
contentFit: undefined,
placeholderContentFit: undefined,
cachePolicy: "memory-disk",
});
},
);

test("passes a responsive source array straight through", () => {
const source = [
{ uri: "https://images.pegada.app/luna-small.webp", width: 320 },
{ uri: "https://images.pegada.app/luna-large.webp", width: 1280 },
];

expect(resolveImagePresentationProps({ source })).toEqual({
source,
placeholder: undefined,
contentFit: undefined,
placeholderContentFit: undefined,
cachePolicy: "memory-disk",
});
});
52 changes: 52 additions & 0 deletions apps/mobile/src/components/imageProps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { ImageProps, ImageSource } from "expo-image";

const DEFAULT_CACHE_POLICY = "memory-disk" as const;

type ImagePresentationProps = Pick<
ImageProps,
"cachePolicy" | "contentFit" | "placeholder" | "placeholderContentFit" | "source"
>;

const isBlurhashSource = (
source: ImageProps["source"],
): source is ImageSource & { blurhash: string } =>
typeof source === "object" &&
source !== null &&
!Array.isArray(source) &&
"blurhash" in source &&
typeof source.blurhash === "string" &&
source.blurhash.length > 0;

/**
* A source cannot use `uri` and `blurhash` simultaneously in Expo Image.
* Treat Pegada's API blurhash as a placeholder and leave the real source clean.
*/
export const resolveImagePresentationProps = ({
source,
placeholder,
contentFit,
placeholderContentFit,
cachePolicy,
}: ImagePresentationProps): ImagePresentationProps => {
if (!isBlurhashSource(source)) {
return {
source,
placeholder,
contentFit,
placeholderContentFit,
cachePolicy: cachePolicy === undefined ? DEFAULT_CACHE_POLICY : cachePolicy,
};
}

const { blurhash, ...imageSource } = source;
const usesSourceBlurhash = placeholder === undefined;

return {
source: imageSource,
placeholder: usesSourceBlurhash ? { blurhash } : placeholder,
contentFit,
placeholderContentFit:
placeholderContentFit ?? (usesSourceBlurhash ? (contentFit ?? "cover") : undefined),
cachePolicy: cachePolicy === undefined ? DEFAULT_CACHE_POLICY : cachePolicy,
};
};
11 changes: 7 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading