diff --git a/messages/de.json b/messages/de.json
index 1870f9b..13b129f 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -116,7 +116,8 @@
"zoom": "Zoom",
"cropCancel": "Abbrechen",
"cropSave": "Übernehmen",
- "cropSaving": "Wird verarbeitet…"
+ "cropSaving": "Wird verarbeitet…",
+ "heicUnsupported": "Dieser Browser kann HEIC-Fotos nicht lesen. Öffne PawFeed in Safari oder speichere das Foto als JPG."
},
"Onboarding": {
"addFirstPet": "Füge dein erstes Tier hinzu",
@@ -510,7 +511,8 @@
"videoTooLong": "Story-Videos dürfen höchstens {max} Sekunden lang sein.",
"videoUnreadable": "Videolänge konnte nicht gelesen werden — bitte versuch eine andere Datei.",
"musicLabel": "Musik hinzufügen (optional)",
- "muteOriginalLabel": "Original-Ton stummschalten, stattdessen Musik abspielen"
+ "muteOriginalLabel": "Original-Ton stummschalten, stattdessen Musik abspielen",
+ "heicUnsupported": "Dieser Browser kann HEIC-Fotos nicht lesen. Öffne PawFeed in Safari oder speichere das Foto als JPG."
},
"MilestoneForm": {
"questionLabel": "Was ist der Meilenstein?",
@@ -530,7 +532,8 @@
"errorToast": "Meilenstein konnte nicht geteilt werden. Bitte versuch es erneut.",
"fileTooLarge": "Foto muss unter 10 MB sein.",
"invalidType": "Lade ein JPG-, PNG- oder WebP-Bild hoch.",
- "uploadFailed": "Upload fehlgeschlagen — bitte versuch es erneut."
+ "uploadFailed": "Upload fehlgeschlagen — bitte versuch es erneut.",
+ "heicUnsupported": "Dieser Browser kann HEIC-Fotos nicht lesen. Öffne PawFeed in Safari oder speichere das Foto als JPG."
},
"MilestoneTypes": {
"BIRTHDAY": "Geburtstag",
diff --git a/messages/en.json b/messages/en.json
index 9dcb179..7e5d810 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -116,7 +116,8 @@
"zoom": "Zoom",
"cropCancel": "Cancel",
"cropSave": "Apply",
- "cropSaving": "Processing…"
+ "cropSaving": "Processing…",
+ "heicUnsupported": "This browser cannot read HEIC photos. Open PawFeed in Safari or save the photo as JPG."
},
"Onboarding": {
"addFirstPet": "Add your first pet",
@@ -510,7 +511,8 @@
"videoTooLong": "Story videos can be at most {max} seconds long.",
"videoUnreadable": "Couldn't read the video length — please try a different file.",
"musicLabel": "Add music (optional)",
- "muteOriginalLabel": "Mute original audio, play this track instead"
+ "muteOriginalLabel": "Mute original audio, play this track instead",
+ "heicUnsupported": "This browser cannot read HEIC photos. Open PawFeed in Safari or save the photo as JPG."
},
"MilestoneForm": {
"questionLabel": "What's the milestone?",
@@ -530,7 +532,8 @@
"errorToast": "Couldn't share this milestone. Please try again.",
"fileTooLarge": "Photo must be under 10 MB.",
"invalidType": "Upload a JPG, PNG, or WebP image.",
- "uploadFailed": "Upload failed — please try again."
+ "uploadFailed": "Upload failed — please try again.",
+ "heicUnsupported": "This browser cannot read HEIC photos. Open PawFeed in Safari or save the photo as JPG."
},
"MilestoneTypes": {
"BIRTHDAY": "Birthday",
diff --git a/mobile/app/(app)/post-new.tsx b/mobile/app/(app)/post-new.tsx
index feacbcb..18da4ff 100644
--- a/mobile/app/(app)/post-new.tsx
+++ b/mobile/app/(app)/post-new.tsx
@@ -14,7 +14,10 @@ import { TextField } from "../../src/components/TextField";
import { Button } from "../../src/components/Button";
import { radii, getTypography, type ColorTokens } from "../../src/theme/tokens";
-const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp"];
+// HEIC/HEIF (iPhone, manche Android-Kameras) ist hier ausdrücklich erlaubt:
+// resizeForUpload() wandelt jedes Bild vor dem Upload in JPEG um, der Server
+// sieht also nie HEIC. Vorher wurden solche Fotos hier still aussortiert.
+const ALLOWED_MIME_TYPES = ["image/jpeg", "image/png", "image/webp", "image/heic", "image/heif"];
const MAX_IMAGES = 10;
type PickedImage = {
@@ -92,9 +95,13 @@ export default function NewPostScreen() {
return;
}
- const accepted = assets
- .filter((asset) => ALLOWED_MIME_TYPES.includes(asset.mimeType ?? "image/jpeg"))
- .slice(0, available);
+ const supported = assets.filter((asset) =>
+ ALLOWED_MIME_TYPES.includes((asset.mimeType ?? "image/jpeg").toLowerCase())
+ );
+ if (supported.length < assets.length) {
+ Alert.alert(t("post.unsupportedFormat"));
+ }
+ const accepted = supported.slice(0, available);
if (accepted.length === 0) return;
diff --git a/mobile/found.md b/mobile/found.md
index e198a9f..6459aee 100644
--- a/mobile/found.md
+++ b/mobile/found.md
@@ -145,4 +145,11 @@ Neu 7:
!!! iOS - Founds beginnen hier - nicht mit Android vertauschen !!!
+- [x] Nutzer-Feedback: Storys und Beiträge mit Bildern im HEIF/HEIC-Format (Apple) lassen sich nicht erstellen.
+ → **Kein reines Serverproblem.** Der Server nimmt bewusst nur JPEG/PNG/WebP an (`ALLOWED_CONTENT_TYPES` in `src/lib/supabase-storage.ts`), und die Bytes gehen direkt vom Client zu Supabase — umgewandelt wurde HEIC nirgends. Zwei Fundstellen:
+ 1. **App, Beiträge:** `app/(app)/post-new.tsx` filterte gewählte Fotos nach ihrem *Original*-MIME-Typ (`image/jpeg|png|webp`) und warf `image/heic`/`image/heif` still raus — obwohl `resizeForUpload()` danach sowieso jedes Bild in JPEG umwandelt. Storys/Meilensteine/Avatar in der App filtern nicht und waren nicht betroffen.
+ 2. **Web, alle Bild-Uploads** (Beiträge, Storys, Meilensteine, Tierbild): nur JPEG/PNG/WebP erlaubt; ein `.heic` vom Mac oder eine rohe HEIC-Datei aus iOS-Safari (Storys nutzen `accept="image/*,video/*"`, das Safaris automatische HEIC→JPEG-Umwandlung aushebelt) wurde abgelehnt.
+ Fix: App — HEIC/HEIF in `post-new.tsx` zugelassen, plus Hinweis-Alert, falls doch einmal Bilder übersprungen werden (`post.unsupportedFormat`). Web — neues `src/lib/heic-to-jpeg.ts` wandelt HEIC vor dem Upload per Browser-Dekodierung (Canvas → JPEG 0,9) um, eingebaut in `MultiImageUpload`, `StoryForm`, `MilestoneForm`, `AvatarUpload`; `accept` um HEIC ergänzt, damit der Mac-Dateidialog sie anbietet. Bewusst **ohne** Zusatzbibliothek (heic-to: LGPL-3.0, ~26 MB; heic2any: seit 2023 ungepflegt): Safari (iOS 17+/macOS 14+) dekodiert HEIC selbst; Browser ohne HEIC-Support (z. B. Chrome/Windows) bekommen eine klare Meldung „Öffne PawFeed in Safari oder speichere das Foto als JPG“ (`heicUnsupported`). Tests in `src/__tests__/heic-to-jpeg.test.ts`, alle 266 Tests + Build grün. Nicht auf einem echten iPhone getestet.
+
+
diff --git a/mobile/src/i18n/locales/de.json b/mobile/src/i18n/locales/de.json
index df8f9f2..7d6f4a6 100644
--- a/mobile/src/i18n/locales/de.json
+++ b/mobile/src/i18n/locales/de.json
@@ -84,7 +84,8 @@
"shareButton": "Teilen",
"deleteConfirmTitle": "Beitrag wirklich löschen?",
"deleteConfirmMessage": "Dieser Beitrag wird unwiderruflich gelöscht.",
- "deleteError": "Beitrag konnte nicht gelöscht werden. Bitte versuch es erneut."
+ "deleteError": "Beitrag konnte nicht gelöscht werden. Bitte versuch es erneut.",
+ "unsupportedFormat": "Einige Fotos haben ein Format, das PawFeed nicht verarbeiten kann, und wurden übersprungen."
},
"story": {
"changePhotoHint": "Antippen, um ein anderes Foto zu wählen",
diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json
index 3f5e4d5..e78e01c 100644
--- a/mobile/src/i18n/locales/en.json
+++ b/mobile/src/i18n/locales/en.json
@@ -84,7 +84,8 @@
"shareButton": "Share",
"deleteConfirmTitle": "Delete this post?",
"deleteConfirmMessage": "This post will be permanently deleted.",
- "deleteError": "Couldn't delete this post. Please try again."
+ "deleteError": "Couldn't delete this post. Please try again.",
+ "unsupportedFormat": "Some photos use a format PawFeed cannot process and were skipped."
},
"story": {
"changePhotoHint": "Tap to choose a different photo",
diff --git a/src/__tests__/heic-to-jpeg.test.ts b/src/__tests__/heic-to-jpeg.test.ts
new file mode 100644
index 0000000..c0eb735
--- /dev/null
+++ b/src/__tests__/heic-to-jpeg.test.ts
@@ -0,0 +1,31 @@
+/**
+ * HEIC/HEIF-Erkennung und -Umwandlung vor dem Upload (src/lib/heic-to-jpeg.ts).
+ * jsdom kann keine Bilder dekodieren — der Fehlerpfad steht damit für
+ * "Browser ohne HEIC-Unterstützung" (z. B. Chrome unter Windows).
+ */
+
+import { describe, it, expect } from "vitest";
+import { HeicUnsupportedError, isHeicFile, normalizeImageFile } from "@/lib/heic-to-jpeg";
+
+const file = (name: string, type: string) => new File([new Uint8Array([1, 2, 3])], name, { type });
+
+describe("isHeicFile", () => {
+ it("detects HEIC/HEIF by MIME type or by extension when the type is empty", () => {
+ expect(isHeicFile(file("IMG_0001.HEIC", "image/heic"))).toBe(true);
+ expect(isHeicFile(file("photo.heif", "image/heif"))).toBe(true);
+ expect(isHeicFile(file("IMG_0002.heic", ""))).toBe(true);
+ expect(isHeicFile(file("photo.jpg", "image/jpeg"))).toBe(false);
+ expect(isHeicFile(file("photo.png", "image/png"))).toBe(false);
+ });
+});
+
+describe("normalizeImageFile", () => {
+ it("passes JPEG/PNG/WebP through unchanged", async () => {
+ const jpeg = file("photo.jpg", "image/jpeg");
+ expect(await normalizeImageFile(jpeg)).toBe(jpeg);
+ });
+
+ it("reports HeicUnsupportedError when the browser cannot decode HEIC", async () => {
+ await expect(normalizeImageFile(file("IMG_0001.HEIC", "image/heic"))).rejects.toBeInstanceOf(HeicUnsupportedError);
+ });
+});
diff --git a/src/components/pet/AvatarUpload.tsx b/src/components/pet/AvatarUpload.tsx
index fd324f5..51a1c4f 100644
--- a/src/components/pet/AvatarUpload.tsx
+++ b/src/components/pet/AvatarUpload.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState, useRef, useCallback } from "react";
+import { HEIC_ACCEPT, HeicUnsupportedError, normalizeImageFile } from "@/lib/heic-to-jpeg";
import { useTranslations } from "next-intl";
import { Camera, Loader2 } from "lucide-react";
import { toast } from "sonner";
@@ -142,11 +143,19 @@ export function AvatarUpload({
// Validates a freshly picked/dropped file and opens the crop dialog —
// the actual upload only fires once the user confirms a crop.
const selectFile = useCallback(
- (file: File) => {
+ async (originalFile: File) => {
if (!petId) {
toast.info(t("savePetFirst"));
return;
}
+ // Apple HEIC/HEIF → JPEG im Browser (Server nimmt nur JPEG/PNG/WebP an)
+ let file: File;
+ try {
+ file = await normalizeImageFile(originalFile);
+ } catch (err) {
+ handleError(err instanceof HeicUnsupportedError ? t("heicUnsupported") : t("invalidType"));
+ return;
+ }
if (file.size > MAX_FILE_SIZE) {
handleError(t("fileTooLarge"));
return;
@@ -226,7 +235,7 @@ export function AvatarUpload({
const fileInput = (
{
+ async (originalFile: File) => {
if (!activePetId) return;
setUploadError(null);
+ // Apple HEIC/HEIF → JPEG im Browser (Server nimmt nur JPEG/PNG/WebP an)
+ let file: File;
+ try {
+ file = await normalizeImageFile(originalFile);
+ } catch (err) {
+ setUploadError(err instanceof HeicUnsupportedError ? t("heicUnsupported") : t("invalidType"));
+ return;
+ }
+
if (file.size > MAX_FILE_SIZE) {
setUploadError(t("fileTooLarge"));
return;
@@ -229,7 +239,7 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
{
+ async (originalFile: File, index: number) => {
+ // Apple HEIC/HEIF → JPEG im Browser (Server nimmt nur JPEG/PNG/WebP an)
+ let file: File;
+ try {
+ file = await normalizeImageFile(originalFile);
+ } catch (err) {
+ setImages((prev) => {
+ const updated = [...prev];
+ updated[index] = { ...updated[index], status: "error" };
+ return updated;
+ });
+ toast.error(
+ err instanceof HeicUnsupportedError
+ ? "This browser cannot read HEIC photos. Please use Safari or export the photo as JPG."
+ : "Upload a JPG, PNG, or WebP image."
+ );
+ return null;
+ }
+
// Validate size
if (file.size > MAX_FILE_SIZE) {
setImages((prev) => {
@@ -274,7 +293,7 @@ export function MultiImageUpload({
{/* Hidden file input */}
{
+ async (originalFile: File) => {
if (!activePetId) {
toast.error(t("noPetSelected"));
return;
}
setErrorMessage(null);
+ // Apple HEIC/HEIF → JPEG im Browser (siehe ACCEPT_ATTR-Kommentar oben:
+ // image/* lässt rohe HEIC-Dateien durch, der Server nimmt sie nicht an)
+ let file: File;
+ try {
+ file = await normalizeImageFile(originalFile);
+ } catch (err) {
+ setErrorMessage(err instanceof HeicUnsupportedError ? t("heicUnsupported") : t("invalidType"));
+ return;
+ }
// Strip codec parameters before comparing — some Android camera-roll
// pickers report e.g. `video/mp4;codecs=hvc1` for HEVC-recorded clips,
// which never matched the bare `ALLOWED_VIDEO_TYPES` strings. This was
diff --git a/src/lib/heic-to-jpeg.ts b/src/lib/heic-to-jpeg.ts
new file mode 100644
index 0000000..eed5e3f
--- /dev/null
+++ b/src/lib/heic-to-jpeg.ts
@@ -0,0 +1,81 @@
+/**
+ * Apple-Fotos im HEIF/HEIC-Format vor dem Upload in JPEG umwandeln.
+ *
+ * Der Server (ALLOWED_CONTENT_TYPES in src/lib/supabase-storage.ts) nimmt nur
+ * JPEG/PNG/WebP an, und die Bytes gehen direkt vom Browser zu Supabase — eine
+ * Umwandlung kann also nur hier im Client passieren. Ohne Zusatzbibliothek:
+ * Safari (iOS 17+, macOS 14+) dekodiert HEIC selbst, das deckt iPhone- und
+ * Mac-Nutzer ab. Kann der Browser das Bild nicht lesen (z. B. Chrome unter
+ * Windows), wirft convertHeicToJpeg einen HeicUnsupportedError, damit die UI
+ * eine verständliche Meldung statt "falsches Format" zeigen kann.
+ */
+
+const HEIC_TYPES = new Set(["image/heic", "image/heif", "image/heic-sequence", "image/heif-sequence"]);
+const HEIC_EXTENSION = /\.(heic|heif)$/i;
+const JPEG_QUALITY = 0.9;
+
+export class HeicUnsupportedError extends Error {
+ constructor() {
+ super("HEIC_UNSUPPORTED");
+ this.name = "HeicUnsupportedError";
+ }
+}
+
+/** Manche Browser liefern für .heic einen leeren file.type — dann zählt die Endung. */
+export function isHeicFile(file: File): boolean {
+ return HEIC_TYPES.has(file.type.toLowerCase()) || HEIC_EXTENSION.test(file.name);
+}
+
+async function decode(file: File): Promise {
+ if (typeof createImageBitmap === "function") {
+ try {
+ return await createImageBitmap(file);
+ } catch {
+ // weiter mit
, das in manchen Safari-Versionen mehr Formate kann
+ }
+ }
+ const url = URL.createObjectURL(file);
+ try {
+ const img = new Image();
+ img.src = url;
+ await img.decode();
+ return img;
+ } finally {
+ URL.revokeObjectURL(url);
+ }
+}
+
+export async function convertHeicToJpeg(file: File): Promise {
+ let source: ImageBitmap | HTMLImageElement;
+ try {
+ source = await decode(file);
+ } catch {
+ throw new HeicUnsupportedError();
+ }
+
+ const width = "naturalWidth" in source ? source.naturalWidth : source.width;
+ const height = "naturalHeight" in source ? source.naturalHeight : source.height;
+ if (!width || !height) throw new HeicUnsupportedError();
+
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) throw new HeicUnsupportedError();
+ ctx.drawImage(source, 0, 0);
+ if ("close" in source) source.close();
+
+ const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", JPEG_QUALITY));
+ if (!blob) throw new HeicUnsupportedError();
+
+ const name = HEIC_EXTENSION.test(file.name) ? file.name.replace(HEIC_EXTENSION, ".jpg") : `${file.name}.jpg`;
+ return new File([blob], name, { type: "image/jpeg", lastModified: file.lastModified });
+}
+
+/** Gibt HEIC-Dateien als JPEG zurück, alle anderen unverändert. */
+export async function normalizeImageFile(file: File): Promise {
+ return isHeicFile(file) ? convertHeicToJpeg(file) : file;
+}
+
+/** accept-Attribut-Ergänzung, damit Datei-Dialoge (v. a. macOS) HEIC-Dateien überhaupt anbieten. */
+export const HEIC_ACCEPT = "image/heic,image/heif,.heic,.heif";