365 lines
12 KiB
TypeScript
365 lines
12 KiB
TypeScript
/** Download + social share helpers for photo-wall visitor success screen.
|
||
*
|
||
* On phones: prefer the OS share sheet (`navigator.share`) so the user picks the app.
|
||
* On desktop: platform buttons open that app / web intent. Instagram / Snapchat cannot
|
||
* receive an image via web intent, so we save the photo, copy the caption, then open the app.
|
||
*/
|
||
|
||
export type PhotoWallSharePlatform =
|
||
| "linkedin"
|
||
| "facebook"
|
||
| "instagram"
|
||
| "snapchat"
|
||
| "x"
|
||
| "whatsapp";
|
||
|
||
export type SharePhotoWallResult = "shared" | "downloaded" | "cancelled";
|
||
|
||
const PLATFORM_APP_HOME: Record<PhotoWallSharePlatform, string> = {
|
||
linkedin: "https://www.linkedin.com/feed/",
|
||
facebook: "https://www.facebook.com/",
|
||
instagram: "instagram://app",
|
||
snapchat: "snapchat://",
|
||
x: "https://x.com/compose/post",
|
||
whatsapp: "https://wa.me/",
|
||
};
|
||
|
||
export function isMobileUa(): boolean {
|
||
if (typeof navigator === "undefined") return false;
|
||
const ua = navigator.userAgent || "";
|
||
const touchMac =
|
||
/\bMacintosh\b/i.test(ua) &&
|
||
typeof navigator.maxTouchPoints === "number" &&
|
||
navigator.maxTouchPoints > 1;
|
||
return /iPhone|iPad|iPod|Android/i.test(ua) || touchMac;
|
||
}
|
||
|
||
/** True when we should show one Share button that opens the OS share sheet. */
|
||
export function canUseNativeShareSheet(): boolean {
|
||
if (typeof navigator === "undefined") return false;
|
||
if (!isMobileUa()) return false;
|
||
return typeof navigator.share === "function";
|
||
}
|
||
|
||
function friendlyFetchError(err: unknown): Error {
|
||
const msg = err instanceof Error ? err.message : String(err || "");
|
||
// Safari often surfaces network/CORS failures as "Load failed" / "Failed to fetch"
|
||
if (/load failed|failed to fetch|networkerror|access control/i.test(msg)) {
|
||
return new Error("Could not load photo for sharing. Try Download, then share from your photos.");
|
||
}
|
||
return err instanceof Error ? err : new Error("Could not load image for sharing");
|
||
}
|
||
|
||
/** Convert HEIC / unknown types to JPEG so iOS `canShare({ files })` accepts them. */
|
||
export async function ensureShareableImageFile(file: File, filename = "photo-wall.jpg"): Promise<File> {
|
||
const type = (file.type || "").toLowerCase();
|
||
if (type === "image/jpeg" || type === "image/png" || type === "image/webp") {
|
||
return file;
|
||
}
|
||
try {
|
||
const bitmap = await createImageBitmap(file);
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = bitmap.width;
|
||
canvas.height = bitmap.height;
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) return file;
|
||
ctx.drawImage(bitmap, 0, 0);
|
||
bitmap.close?.();
|
||
const blob = await new Promise<Blob | null>((resolve) =>
|
||
canvas.toBlob((b) => resolve(b), "image/jpeg", 0.92),
|
||
);
|
||
if (!blob?.size) return file;
|
||
return new File([blob], filename.replace(/\.\w+$/, "") + ".jpg", { type: "image/jpeg" });
|
||
} catch {
|
||
return file;
|
||
}
|
||
}
|
||
|
||
export async function fetchImageFile(url: string, filename = "photo-wall.jpg"): Promise<File> {
|
||
let res: Response;
|
||
try {
|
||
res = await fetch(url, { mode: "cors", credentials: "omit", cache: "no-store" });
|
||
} catch (err) {
|
||
throw friendlyFetchError(err);
|
||
}
|
||
if (!res.ok) throw new Error("Could not load image for sharing");
|
||
const blob = await res.blob();
|
||
if (!blob.size) throw new Error("Could not load image for sharing");
|
||
const type = blob.type && blob.type.startsWith("image/") ? blob.type : "image/jpeg";
|
||
const ext = type.includes("png") ? "png" : type.includes("webp") ? "webp" : "jpg";
|
||
const name = filename.replace(/\.\w+$/, "") + `.${ext}`;
|
||
return new File([blob], name, { type });
|
||
}
|
||
|
||
/** Read pixels from an already-displayed <img> (must be CORS-clean / same-origin). */
|
||
export async function fileFromHtmlImage(
|
||
img: HTMLImageElement,
|
||
filename = "photo-wall.jpg",
|
||
): Promise<File | null> {
|
||
if (!img?.naturalWidth || !img.naturalHeight) return null;
|
||
try {
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = img.naturalWidth;
|
||
canvas.height = img.naturalHeight;
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) return null;
|
||
ctx.drawImage(img, 0, 0);
|
||
const blob = await new Promise<Blob | null>((resolve) =>
|
||
canvas.toBlob((b) => resolve(b), "image/jpeg", 0.92),
|
||
);
|
||
if (!blob?.size) return null;
|
||
return new File([blob], filename.replace(/\.\w+$/, "") + ".jpg", { type: "image/jpeg" });
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export async function downloadImageFromUrl(url: string, filename = "photo-wall.jpg") {
|
||
const file = await fetchImageFile(url, filename);
|
||
const objectUrl = URL.createObjectURL(file);
|
||
const a = document.createElement("a");
|
||
a.href = objectUrl;
|
||
a.download = file.name;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
URL.revokeObjectURL(objectUrl);
|
||
}
|
||
|
||
async function copyText(text: string): Promise<boolean> {
|
||
try {
|
||
if (navigator.clipboard?.writeText) {
|
||
await navigator.clipboard.writeText(text);
|
||
return true;
|
||
}
|
||
} catch {
|
||
/* fall through */
|
||
}
|
||
try {
|
||
const el = document.createElement("textarea");
|
||
el.value = text;
|
||
el.setAttribute("readonly", "");
|
||
el.style.position = "fixed";
|
||
el.style.left = "-9999px";
|
||
document.body.appendChild(el);
|
||
el.select();
|
||
const ok = document.execCommand("copy");
|
||
el.remove();
|
||
return ok;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function openUrl(url: string) {
|
||
// Prefer same-tab navigation on mobile so deep links / app handoff work more reliably.
|
||
if (isMobileUa()) {
|
||
window.location.assign(url);
|
||
return;
|
||
}
|
||
window.open(url, "_blank", "noopener,noreferrer");
|
||
}
|
||
|
||
function platformShareUrl(
|
||
platform: PhotoWallSharePlatform,
|
||
imageUrl: string,
|
||
text: string,
|
||
): string | null {
|
||
const encodedText = encodeURIComponent(text);
|
||
const encodedUrl = encodeURIComponent(imageUrl);
|
||
const textWithUrl = encodeURIComponent(`${text}\n${imageUrl}`);
|
||
|
||
switch (platform) {
|
||
case "whatsapp":
|
||
// wa.me opens the WhatsApp app on phones and WhatsApp Web on desktop
|
||
return `https://wa.me/?text=${textWithUrl}`;
|
||
case "facebook":
|
||
return `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`;
|
||
case "x":
|
||
return `https://twitter.com/intent/tweet?text=${encodedText}&url=${encodedUrl}`;
|
||
case "linkedin":
|
||
return `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`;
|
||
case "instagram":
|
||
case "snapchat":
|
||
return null;
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function saveImageAndOpenApp(
|
||
platform: PhotoWallSharePlatform,
|
||
file: File,
|
||
text: string,
|
||
): Promise<SharePhotoWallResult> {
|
||
const objectUrl = URL.createObjectURL(file);
|
||
try {
|
||
const a = document.createElement("a");
|
||
a.href = objectUrl;
|
||
a.download = file.name;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
} finally {
|
||
URL.revokeObjectURL(objectUrl);
|
||
}
|
||
|
||
await copyText(text);
|
||
|
||
// Prefer native schemes on mobile; fall back to https homes.
|
||
if (platform === "instagram") {
|
||
openUrl(isMobileUa() ? "instagram://app" : "https://www.instagram.com/");
|
||
} else if (platform === "snapchat") {
|
||
openUrl(isMobileUa() ? "snapchat://" : "https://www.snapchat.com/");
|
||
} else {
|
||
openUrl(PLATFORM_APP_HOME[platform]);
|
||
}
|
||
|
||
return "downloaded";
|
||
}
|
||
|
||
async function resolveShareFile(options: {
|
||
file?: File | null;
|
||
imageUrl?: string | null;
|
||
imgEl?: HTMLImageElement | null;
|
||
}): Promise<File | null> {
|
||
if (options.file && options.file.size > 0) return options.file;
|
||
|
||
if (options.imageUrl) {
|
||
try {
|
||
return await fetchImageFile(options.imageUrl, "photo-wall.jpg");
|
||
} catch {
|
||
/* try canvas next */
|
||
}
|
||
}
|
||
|
||
if (options.imgEl) {
|
||
const fromImg = await fileFromHtmlImage(options.imgEl);
|
||
if (fromImg) return fromImg;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Open the OS share sheet (iOS / Android) with the photo + caption.
|
||
* By default falls back to text/url-only share if the image bytes cannot be loaded.
|
||
* Pass `filesOnly: true` to never share a URL.
|
||
*
|
||
* Critical: do not `await` network/canvas work before the first `navigator.share`
|
||
* when a File is already in hand — iOS Safari drops the user gesture and fails
|
||
* with "Load failed".
|
||
*/
|
||
export async function shareViaNativeSheet(options: {
|
||
imageUrl?: string | null;
|
||
file?: File | null;
|
||
imgEl?: HTMLImageElement | null;
|
||
title?: string;
|
||
text?: string;
|
||
/** When true, never fall back to sharing a URL — only the image file. */
|
||
filesOnly?: boolean;
|
||
}): Promise<SharePhotoWallResult> {
|
||
const title = options.title || "EventBuzz";
|
||
const text = options.text || `Check out my photo from ${title}!`;
|
||
const imageUrl = options.imageUrl || undefined;
|
||
const filesOnly = options.filesOnly === true;
|
||
|
||
if (typeof navigator.share !== "function") {
|
||
throw new Error("Sharing is not supported on this device");
|
||
}
|
||
|
||
const readyFile = options.file && options.file.size > 0 ? options.file : null;
|
||
|
||
const tryShareFiles = async (file: File): Promise<SharePhotoWallResult | "skip"> => {
|
||
const type = (file.type || "").toLowerCase();
|
||
const shareable =
|
||
type === "image/jpeg" || type === "image/png" || type === "image/webp"
|
||
? file
|
||
: await ensureShareableImageFile(file, file.name || "photo.jpg");
|
||
const payloadWithFiles: ShareData = { title, text, files: [shareable] };
|
||
try {
|
||
const canFiles =
|
||
typeof navigator.canShare !== "function" || navigator.canShare(payloadWithFiles);
|
||
if (!canFiles) return "skip";
|
||
await navigator.share(payloadWithFiles);
|
||
return "shared";
|
||
} catch (err) {
|
||
const name = err instanceof DOMException ? err.name : "";
|
||
if (name === "AbortError") return "cancelled";
|
||
return "skip";
|
||
}
|
||
};
|
||
|
||
// 1) Prefer already-cached bytes — no await before share (keeps iOS user gesture).
|
||
if (readyFile) {
|
||
const result = await tryShareFiles(readyFile);
|
||
if (result !== "skip") return result;
|
||
}
|
||
|
||
// 2) Resolve the image file (fetch / canvas) so we share bytes, not a URL.
|
||
if (!readyFile) {
|
||
const file = await resolveShareFile({
|
||
file: null,
|
||
imageUrl,
|
||
imgEl: options.imgEl,
|
||
});
|
||
if (file) {
|
||
const result = await tryShareFiles(file);
|
||
if (result !== "skip") return result;
|
||
}
|
||
}
|
||
|
||
if (filesOnly) {
|
||
throw new Error("Could not share this photo. Try Download, then share from your photos.");
|
||
}
|
||
|
||
const payloadTextOnly: ShareData = imageUrl
|
||
? { title, text, url: imageUrl }
|
||
: { title, text };
|
||
|
||
try {
|
||
await navigator.share(payloadTextOnly);
|
||
return "shared";
|
||
} catch (err) {
|
||
const name = err instanceof DOMException ? err.name : "";
|
||
if (name === "AbortError") return "cancelled";
|
||
}
|
||
|
||
throw new Error("Could not share this photo. Try Download, then share from your photos.");
|
||
}
|
||
|
||
/**
|
||
* Share to a specific platform (desktop / non–share-sheet path).
|
||
* Does NOT use navigator.share so each button can target a different app.
|
||
*/
|
||
export async function sharePhotoWallImage(options: {
|
||
platform: PhotoWallSharePlatform;
|
||
imageUrl: string;
|
||
file?: File | null;
|
||
title?: string;
|
||
text?: string;
|
||
}): Promise<SharePhotoWallResult> {
|
||
const title = options.title || "EventBuzz";
|
||
const text = options.text || `Check out my photo from ${title}!`;
|
||
const imageUrl = options.imageUrl;
|
||
const platform = options.platform;
|
||
|
||
const intent = imageUrl ? platformShareUrl(platform, imageUrl, text) : null;
|
||
if (intent) {
|
||
// Copy caption so users can paste if the app only receives the URL.
|
||
await copyText(`${text}\n${imageUrl}`);
|
||
openUrl(intent);
|
||
return "shared";
|
||
}
|
||
|
||
// Instagram / Snapchat (or missing public URL): save file locally, copy caption, open the app.
|
||
const file =
|
||
options.file && options.file.size > 0
|
||
? options.file
|
||
: imageUrl
|
||
? await fetchImageFile(imageUrl, "photo-wall.jpg")
|
||
: null;
|
||
if (!file) throw new Error("Could not load image for sharing");
|
||
return saveImageAndOpenApp(platform, file, text);
|
||
}
|