EventBuzz_Website/src/lib/photoWallShare.ts

365 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** 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);
}