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
49 changes: 47 additions & 2 deletions e2e/branding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,25 @@ function stickerPixels(bytes: Buffer): number {
);
}

// How many pixels differ between two downloads, inside the photo's circle
// (r < 120) or on the flag ring around it (r 130 -> 148).
function changedPixels(a: Buffer, b: Buffer, where: 'photo' | 'ring'): number {
const before = PNG.sync.read(a);
return countPixels(
b,
(dx, dy, radius) =>
where === 'photo' ? radius < 120 : radius > 130 && radius < 148,
(r, g, bl, x, y) => {
const p = pixelAt(before, x, y);
return Math.abs(p.r - r) + Math.abs(p.g - g) + Math.abs(p.b - bl) > 60;
},
);
}

function countPixels(
bytes: Buffer,
inRegion: (dx: number, dy: number, radius: number) => boolean,
matches: (r: number, g: number, b: number) => boolean,
matches: (r: number, g: number, b: number, x: number, y: number) => boolean,
): number {
const image = PNG.sync.read(bytes);
// Normalise to the 300-unit frame the overlays are drawn in, so the check
Expand All @@ -52,7 +67,7 @@ function countPixels(
const dy = (y - image.height / 2) * unit;
if (!inRegion(dx, dy, Math.hypot(dx, dy))) continue;
const { r, g, b, a } = pixelAt(image, x, y);
if (a > 200 && matches(r, g, b)) count++;
if (a > 200 && matches(r, g, b, x, y)) count++;
}
}
return count;
Expand Down Expand Up @@ -136,6 +151,35 @@ test.describe('Co-brand link', () => {
expect(stickerPixels(await downloadPicture(page))).toBe(0);
});

test('lets the photo be zoomed and moved, keeping the ring even', async ({
page,
}) => {
await page.goto('/?b=members');
await page.setInputFiles('#fileInput', PHOTO);
// Tag off, so only the photo and the flag are in the picture.
await page.getByRole('checkbox', { name: /name tag/ }).uncheck();
const before = await downloadPicture(page);

await page.getByRole('slider', { name: 'Zoom' }).fill('0.6');
const zoomed = await downloadPicture(page);
expect(changedPixels(before, zoomed, 'photo')).toBeGreaterThan(1000);
expect(changedPixels(before, zoomed, 'ring')).toBe(0);

const photo = page.locator('#userImage').locator('..');
// Downloading scrolled the page down to the buttons.
await photo.scrollIntoViewIfNeeded();
const box = (await photo.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2 - 40, {
steps: 5,
});
await page.mouse.up();
const moved = await downloadPicture(page);
expect(changedPixels(zoomed, moved, 'photo')).toBeGreaterThan(1000);
expect(changedPixels(zoomed, moved, 'ring')).toBe(0);
});

test('is not offered without a valid link', async ({ page }) => {
for (const url of ['/', '/?b=nope']) {
await page.goto(url);
Expand All @@ -146,6 +190,7 @@ test.describe('Co-brand link', () => {
await expect(
page.getByRole('checkbox', { name: /name tag/ }),
).toHaveCount(0);
await expect(page.getByRole('slider', { name: 'Zoom' })).toHaveCount(0);
}
});
});
149 changes: 149 additions & 0 deletions src/app/framed-photo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
'use client';
import Image from 'next/image';
import { useRef, useState } from 'react';

/**
* The photo inside the frame's circle (85% of the frame, inset 7.5%, so the
* flag ring around it stays even). `fit` moves and zooms the photo within
* that circle, so a tight crop or a tall photo can be placed clear of an
* overlay like the member name tag:
* - zoom 1 fills the circle (like object-fit: cover), below 1 shows more of
* the photo, above 1 crops in;
* - x / y move the photo's centre, in % of the circle.
* Wherever a zoomed-out photo leaves the circle uncovered, a blurred copy of
* it shows instead of the flag.
*/
export interface PhotoFit {
zoom: number;
x: number;
y: number;
}

export const DEFAULT_FIT: PhotoFit = { zoom: 1, x: 0, y: 0 };
export const MIN_ZOOM = 0.5;
export const MAX_ZOOM = 2;

// The photo's centre can go as far as the circle's edge, no further.
const clamp = (n: number) => Math.max(-50, Math.min(50, n));

// The photo shrunk to 10px, then stretched back up to 200px with the
// canvas's bilinear smoothing: a soft blur that, unlike CSS
// `filter: blur()`, rasterises the same in every engine. (Stretching the
// 10px image with CSS instead comes out blocky in WebKit.) Undefined if the
// canvas can't read the photo (a cross-origin source), which leaves the flag
// showing instead.
function blurOf(img: HTMLImageElement): string | undefined {
const tiny = document.createElement('canvas');
tiny.width = tiny.height = 10;
const side = Math.min(img.naturalWidth, img.naturalHeight);
tiny
.getContext('2d')
?.drawImage(
img,
(img.naturalWidth - side) / 2,
(img.naturalHeight - side) / 2,
side,
side,
0,
0,
10,
10,
);
const blur = document.createElement('canvas');
blur.width = blur.height = 200;
const ctx = blur.getContext('2d');
if (!ctx) return undefined;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(tiny, 0, 0, 200, 200);
try {
return blur.toDataURL();
} catch {
return undefined;
}
}

export default function FramedPhoto({
src,
fit,
onFitChange,
id,
onLoad,
}: {
src: string;
fit: PhotoFit;
/** Makes the photo draggable, reporting where it was dragged to. */
onFitChange?: (fit: PhotoFit) => void;
id?: string;
onLoad?: () => void;
}) {
const [aspect, setAspect] = useState(1);
const [blur, setBlur] = useState<string>();
const drag = useRef<{ x: number; y: number }>(undefined);

// Sized to cover the circle at zoom 1, keeping the photo's proportions.
const width = 100 * Math.max(aspect, 1) * fit.zoom;
const height = (100 / Math.min(aspect, 1)) * fit.zoom;

return (
<div
style={{
position: 'absolute',
width: '85%',
height: '85%',
left: '7.5%',
top: '7.5%',
overflow: 'hidden',
touchAction: onFitChange ? 'none' : undefined,
}}
className={`rounded-full ${onFitChange ? 'cursor-move' : ''}`}
onPointerDown={(e) => {
if (!onFitChange) return;
e.currentTarget.setPointerCapture(e.pointerId);
drag.current = { x: e.clientX, y: e.clientY };
}}
onPointerMove={(e) => {
if (!onFitChange || !drag.current) return;
const unit = 100 / e.currentTarget.clientWidth;
onFitChange({
...fit,
x: clamp(fit.x + (e.clientX - drag.current.x) * unit),
y: clamp(fit.y + (e.clientY - drag.current.y) * unit),
});
drag.current = { x: e.clientX, y: e.clientY };
}}
onPointerUp={() => (drag.current = undefined)}
onPointerCancel={() => (drag.current = undefined)}
>
{blur && (
// eslint-disable-next-line @next/next/no-img-element
<img
alt=""
src={blur}
style={{ position: 'absolute', width: '100%', height: '100%' }}
/>
)}
<Image
id={id}
alt="profile-image"
src={src}
width={100}
height={100}
draggable={false}
onLoad={(e) => {
const img = e.currentTarget;
setAspect(img.naturalWidth / img.naturalHeight || 1);
setBlur(blurOf(img));
onLoad?.();
}}
style={{
position: 'absolute',
maxWidth: 'none',
width: `${width}%`,
height: `${height}%`,
left: `${50 + fit.x - width / 2}%`,
top: `${50 + fit.y - height / 2}%`,
}}
/>
</div>
);
}
46 changes: 34 additions & 12 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@
import { BRANDS, type Brand, type BrandText } from './brands';
import FaqList from './faq';
import MyLinkCard from './my-link-card';
import FramedPhoto, {
DEFAULT_FIT,
MAX_ZOOM,
MIN_ZOOM,
type PhotoFit,
} from './framed-photo';
import SharePanel from './share-panel';
import SurveyPanel from './survey-panel';

Expand All @@ -54,6 +60,9 @@
) : (
showBranding && <BrandingRing />
);
// Where the photo sits in the circle (see `./framed-photo`), so a tight crop
// can clear the brand's overlay. Only adjustable on a co-brand link.
const [fit, setFit] = useState<PhotoFit>(DEFAULT_FIT);
const [variant, setVariant] = useState<ShareVariant>('classic');
// Link-first only: the link this browser already has, shown at the top on
// a return visit so the person sees what it has done since.
Expand Down Expand Up @@ -206,6 +215,7 @@
method: filePostfix ?? 'unknown',
branding: showBranding && !activeBrand ? 'on' : 'off',
cobrand: activeBrand?.id ?? 'off',
adjusted: fit === DEFAULT_FIT ? 'off' : 'on',
referrer: referrerProp(),
variant,
});
Expand All @@ -217,6 +227,7 @@
trackEvent(FunnelEvent.StartOver, { method: filePostfix ?? 'unknown' });
setUserImageUrl(undefined);
setHasDownloaded(false);
setFit(DEFAULT_FIT);
};

return (
Expand Down Expand Up @@ -291,10 +302,11 @@
className="object-cover rounded-full cursor-wait"
/>
) : (
<Image
<FramedPhoto
id="userImage"
alt="profile-image"
src={userImageUrl ?? '/user.jpg'}
fit={fit}
onFitChange={userImageUrl && brand ? setFit : undefined}
onLoad={() => {
// Only the user's selected photo counts, not the placeholder.
if (userImageUrl) {
Expand All @@ -303,21 +315,30 @@
});
}
}}
width={100}
height={100}
style={{
position: 'absolute',
width: '85%',
height: '85%',
left: '7.5%',
top: '7.5%',
}}
className="object-cover rounded-full cursor-pointer"
/>
)}
{!loader && overlay}
</div>
</div>
{userImageUrl && brand && (
<div className="mt-4 text-sm text-gray-600">
<label className="flex items-center justify-center gap-3">
Zoom
<input
type="range"
min={MIN_ZOOM}
max={MAX_ZOOM}
step={0.01}
value={fit.zoom}
onChange={(e) =>
setFit({ ...fit, zoom: Number(e.target.value) })
}
className="w-40 accent-gray-900"
/>
</label>
<p className="mt-1">Drag the photo to move it.</p>
</div>
)}
</div>
<div>
{userImageUrl ? (
Expand All @@ -328,6 +349,7 @@
method={filePostfix ?? 'unknown'}
generateProfileImage={generateFinalImage}
overlay={overlay}
fit={fit}
variant={variant}
/>
) : (
Expand Down Expand Up @@ -488,7 +510,7 @@
rel="noopener noreferrer"
className="inline-block"
>
<img

Check warning on line 513 in src/app/page.tsx

View workflow job for this annotation

GitHub Actions / Lint, type-check, build & tests

Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element
src="/img/logo.svg"
alt="Tech For Palestine Logo"
width={320}
Expand Down
19 changes: 5 additions & 14 deletions src/app/share-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
} from 'react-icons/fa6';

import MyLinkCard from './my-link-card';
import FramedPhoto, { type PhotoFit } from './framed-photo';

// Official brand colours make the row instantly recognisable.
const CHANNEL_STYLES = {
Expand Down Expand Up @@ -58,6 +59,8 @@ interface SharePanelProps {
/** The frame's overlay (short URL or co-brand), mirrored onto the story
* card's picture so what gets shared matches the download. */
overlay: ReactNode;
/** Where the photo sits in the frame, mirrored the same way. */
fit: PhotoFit;
/** Which share panel to show; see `@/lib/share-variant`. */
variant?: ShareVariant;
}
Expand All @@ -67,6 +70,7 @@ export default function SharePanel({
method,
generateProfileImage,
overlay,
fit,
variant = 'classic',
}: SharePanelProps) {
// Link-first: this browser's personal link, made on the spot (anonymous)
Expand Down Expand Up @@ -360,20 +364,7 @@ export default function SharePanel({
className="rounded-full"
unoptimized
/>
<Image
alt=""
src={userImageUrl}
width={100}
height={100}
style={{
position: 'absolute',
width: '85%',
height: '85%',
left: '7.5%',
top: '7.5%',
}}
className="object-cover rounded-full"
/>
<FramedPhoto src={userImageUrl} fit={fit} />
{overlay}
</div>
<p className="text-white text-3xl font-semibold mt-6">
Expand Down
3 changes: 2 additions & 1 deletion src/lib/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ declare global {
* PreviewShown: that photo actually renders on screen.
* Downloaded: the final framed image is downloaded (carries `branding`:
* whether the short URL was baked into the ring; `cobrand`: the
* co-brand overlay that replaced it, or `off` (see `@/lib/brands`); `referrer` as above,
* co-brand overlay that replaced it, or `off` (see `@/lib/brands`); `adjusted`:
* whether the photo was moved or zoomed in the frame; `referrer` as above,
* which is what the promoter leaderboard counts, and `variant`).
*/
export const FunnelEvent = {
Expand Down
Loading