mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b50436b1ba | ||
|
|
11e74042e2 | ||
|
|
95580b84ea | ||
|
|
9a15334bef | ||
|
|
3aca2d80c0 | ||
|
|
7c534301ee | ||
|
|
14dc5ff2d4 | ||
|
|
14ba28c79c | ||
|
|
598adc154d | ||
|
|
3c0af52c4e | ||
|
|
7108431dbb | ||
|
|
744b17908d | ||
|
|
c7edae9ed3 | ||
|
|
c07b39a070 | ||
|
|
7e5e296271 | ||
|
|
ad6dff79ba | ||
|
|
a4ca72fb3d | ||
|
|
8ce53816af | ||
|
|
767d4b9f9f |
+2
-1
@@ -407,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "فتح في تطبيق",
|
||||
"pdf_external": "تُفتح ملفات PDF في عارض PDF على هذا الجهاز.",
|
||||
"unsupported": "لا توجد معاينة لهذا النوع من الملفات."
|
||||
"unsupported": "لا توجد معاينة لهذا النوع من الملفات.",
|
||||
"media_unsupported": "لا يمكن تشغيل هذا الملف هنا."
|
||||
},
|
||||
"received_via": "إلى {name}",
|
||||
"remove_color": "إزالة الوسم",
|
||||
|
||||
+2
-1
@@ -407,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Obre en una aplicació",
|
||||
"pdf_external": "Els PDF s'obren amb el visualitzador de PDF d'aquest dispositiu.",
|
||||
"unsupported": "No hi ha previsualització per a aquest tipus de fitxer."
|
||||
"unsupported": "No hi ha previsualització per a aquest tipus de fitxer.",
|
||||
"media_unsupported": "Aquest fitxer no es pot reproduir aquí."
|
||||
},
|
||||
"received_via": "per a {name}",
|
||||
"remove_color": "Elimina l'etiqueta",
|
||||
|
||||
+2
-1
@@ -407,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Otevřít v aplikaci",
|
||||
"pdf_external": "Soubory PDF se otevírají v prohlížeči PDF na tomto zařízení.",
|
||||
"unsupported": "Pro tento typ souboru není náhled k dispozici."
|
||||
"unsupported": "Pro tento typ souboru není náhled k dispozici.",
|
||||
"media_unsupported": "Tento soubor zde nelze přehrát."
|
||||
},
|
||||
"received_via": "pro {name}",
|
||||
"remove_color": "Odebrat štítek",
|
||||
|
||||
+2
-1
@@ -407,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Åbn i app",
|
||||
"pdf_external": "PDF'er åbnes i din PDF-fremviser på denne enhed.",
|
||||
"unsupported": "Ingen forhåndsvisning for denne filtype."
|
||||
"unsupported": "Ingen forhåndsvisning for denne filtype.",
|
||||
"media_unsupported": "Denne fil kan ikke afspilles her."
|
||||
},
|
||||
"received_via": "til {name}",
|
||||
"remove_color": "Fjern tag",
|
||||
|
||||
+2
-1
@@ -413,7 +413,8 @@
|
||||
"preview": {
|
||||
"open_external": "In App öffnen",
|
||||
"pdf_external": "PDFs werden in Ihrem PDF-Viewer auf diesem Gerät geöffnet.",
|
||||
"unsupported": "Keine Vorschau für diesen Dateityp."
|
||||
"unsupported": "Keine Vorschau für diesen Dateityp.",
|
||||
"media_unsupported": "Diese Datei kann hier nicht abgespielt werden."
|
||||
},
|
||||
"received_via": "an {name}",
|
||||
"remove_color": "Label entfernen",
|
||||
|
||||
+2
-1
@@ -424,7 +424,8 @@
|
||||
"preview": {
|
||||
"open_external": "Open in app",
|
||||
"pdf_external": "PDFs open in your PDF viewer on this device.",
|
||||
"unsupported": "No preview for this file type."
|
||||
"unsupported": "No preview for this file type.",
|
||||
"media_unsupported": "This file can't be played here."
|
||||
},
|
||||
"received_via": "to {name}",
|
||||
"remove_color": "Remove tag",
|
||||
|
||||
+2
-1
@@ -407,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Abrir en una app",
|
||||
"pdf_external": "Los PDF se abren en el visor de PDF de este dispositivo.",
|
||||
"unsupported": "No hay vista previa para este tipo de archivo."
|
||||
"unsupported": "No hay vista previa para este tipo de archivo.",
|
||||
"media_unsupported": "Este archivo no se puede reproducir aquí."
|
||||
},
|
||||
"received_via": "para {name}",
|
||||
"remove_color": "Eliminar etiqueta",
|
||||
|
||||
+2
-1
@@ -407,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "باز کردن در برنامه",
|
||||
"pdf_external": "فایلهای PDF در نمایشگر PDF این دستگاه باز میشوند.",
|
||||
"unsupported": "برای این نوع فایل پیشنمایشی وجود ندارد."
|
||||
"unsupported": "برای این نوع فایل پیشنمایشی وجود ندارد.",
|
||||
"media_unsupported": "این فایل اینجا پخش نمیشود."
|
||||
},
|
||||
"received_via": "به {name}",
|
||||
"remove_color": "حذف برچسب",
|
||||
|
||||
+2
-1
@@ -407,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Ouvrir dans une app",
|
||||
"pdf_external": "Les PDF s'ouvrent dans votre lecteur PDF sur cet appareil.",
|
||||
"unsupported": "Aucun aperçu pour ce type de fichier."
|
||||
"unsupported": "Aucun aperçu pour ce type de fichier.",
|
||||
"media_unsupported": "Ce fichier ne peut pas être lu ici."
|
||||
},
|
||||
"received_via": "à {name}",
|
||||
"remove_color": "Retirer l'étiquette",
|
||||
|
||||
+2
-1
@@ -407,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "פתח באפליקציה",
|
||||
"pdf_external": "קובצי PDF נפתחים במציג ה-PDF במכשיר זה.",
|
||||
"unsupported": "אין תצוגה מקדימה לסוג קובץ זה."
|
||||
"unsupported": "אין תצוגה מקדימה לסוג קובץ זה.",
|
||||
"media_unsupported": "לא ניתן להפעיל את הקובץ הזה כאן."
|
||||
},
|
||||
"received_via": "אל {name}",
|
||||
"remove_color": "הסר תג",
|
||||
|
||||
+2
-1
@@ -407,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Megnyitás alkalmazásban",
|
||||
"pdf_external": "A PDF-fájlok az eszköz PDF-megjelenítőjében nyílnak meg.",
|
||||
"unsupported": "Ehhez a fájltípushoz nincs előnézet."
|
||||
"unsupported": "Ehhez a fájltípushoz nincs előnézet.",
|
||||
"media_unsupported": "Ez a fájl itt nem játszható le."
|
||||
},
|
||||
"received_via": "címzett: {name}",
|
||||
"remove_color": "Címke eltávolítása",
|
||||
|
||||
+2
-1
@@ -407,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Apri in un'app",
|
||||
"pdf_external": "I PDF si aprono nel lettore PDF di questo dispositivo.",
|
||||
"unsupported": "Nessuna anteprima per questo tipo di file."
|
||||
"unsupported": "Nessuna anteprima per questo tipo di file.",
|
||||
"media_unsupported": "Questo file non può essere riprodotto qui."
|
||||
},
|
||||
"received_via": "a {name}",
|
||||
"remove_color": "Rimuovi etichetta",
|
||||
|
||||
+2
-1
@@ -407,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "アプリで開く",
|
||||
"pdf_external": "PDF はこのデバイスの PDF ビューアーで開きます。",
|
||||
"unsupported": "このファイル形式はプレビューできません。"
|
||||
"unsupported": "このファイル形式はプレビューできません。",
|
||||
"media_unsupported": "このファイルはここでは再生できません。"
|
||||
},
|
||||
"received_via": "{name}宛",
|
||||
"remove_color": "ラベルを削除",
|
||||
|
||||
+2
-1
@@ -407,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "앱에서 열기",
|
||||
"pdf_external": "PDF는 이 기기의 PDF 뷰어에서 열려요.",
|
||||
"unsupported": "이 파일 형식은 미리보기를 지원하지 않아요."
|
||||
"unsupported": "이 파일 형식은 미리보기를 지원하지 않아요.",
|
||||
"media_unsupported": "이 파일은 여기서 재생할 수 없습니다."
|
||||
},
|
||||
"received_via": "받는 사람: {name}",
|
||||
"remove_color": "태그 제거",
|
||||
|
||||
+2
-1
@@ -407,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Atvērt lietotnē",
|
||||
"pdf_external": "PDF faili tiek atvērti šīs ierīces PDF skatītājā.",
|
||||
"unsupported": "Šim faila tipam nav priekšskatījuma."
|
||||
"unsupported": "Šim faila tipam nav priekšskatījuma.",
|
||||
"media_unsupported": "Šo failu šeit nevar atskaņot."
|
||||
},
|
||||
"received_via": "kam: {name}",
|
||||
"remove_color": "Noņemt tagu",
|
||||
|
||||
+2
-1
@@ -407,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Апп-д нээх",
|
||||
"pdf_external": "PDF файлууд энэ төхөөрөмжийн PDF үзэгчид нээгдэнэ.",
|
||||
"unsupported": "Энэ төрлийн файлыг урьдчилан харах боломжгүй."
|
||||
"unsupported": "Энэ төрлийн файлыг урьдчилан харах боломжгүй.",
|
||||
"media_unsupported": "Энэ файлыг энд тоглуулах боломжгүй."
|
||||
},
|
||||
"received_via": "Хэнд: {name}",
|
||||
"remove_color": "Тагийг хасах",
|
||||
|
||||
+2
-1
@@ -407,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Åpne i app",
|
||||
"pdf_external": "PDF-filer åpnes i PDF-leseren på denne enheten.",
|
||||
"unsupported": "Ingen forhåndsvisning for denne filtypen."
|
||||
"unsupported": "Ingen forhåndsvisning for denne filtypen.",
|
||||
"media_unsupported": "Denne filen kan ikke spilles av her."
|
||||
},
|
||||
"received_via": "til {name}",
|
||||
"remove_color": "Fjern etiketten",
|
||||
|
||||
+2
-1
@@ -407,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Openen in app",
|
||||
"pdf_external": "PDF's worden geopend in de PDF-viewer op dit apparaat.",
|
||||
"unsupported": "Geen voorbeeld voor dit bestandstype."
|
||||
"unsupported": "Geen voorbeeld voor dit bestandstype.",
|
||||
"media_unsupported": "Dit bestand kan hier niet worden afgespeeld."
|
||||
},
|
||||
"received_via": "aan {name}",
|
||||
"remove_color": "Label verwijderen",
|
||||
|
||||
+2
-1
@@ -407,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Otwórz w aplikacji",
|
||||
"pdf_external": "Pliki PDF otwierają się w przeglądarce PDF na tym urządzeniu.",
|
||||
"unsupported": "Brak podglądu dla tego typu pliku."
|
||||
"unsupported": "Brak podglądu dla tego typu pliku.",
|
||||
"media_unsupported": "Tego pliku nie można tutaj odtworzyć."
|
||||
},
|
||||
"received_via": "do {name}",
|
||||
"remove_color": "Usuń etykietę",
|
||||
|
||||
+2
-1
@@ -407,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Abrir em outro app",
|
||||
"pdf_external": "PDFs são abertos no leitor de PDF deste dispositivo.",
|
||||
"unsupported": "Sem pré-visualização para este tipo de arquivo."
|
||||
"unsupported": "Sem pré-visualização para este tipo de arquivo.",
|
||||
"media_unsupported": "Este ficheiro não pode ser reproduzido aqui."
|
||||
},
|
||||
"received_via": "para {name}",
|
||||
"remove_color": "Remover etiqueta",
|
||||
|
||||
+2
-1
@@ -407,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Deschideți într-o aplicație",
|
||||
"pdf_external": "Fișierele PDF se deschid în vizualizatorul PDF de pe acest dispozitiv.",
|
||||
"unsupported": "Nu există previzualizare pentru acest tip de fișier."
|
||||
"unsupported": "Nu există previzualizare pentru acest tip de fișier.",
|
||||
"media_unsupported": "Acest fișier nu poate fi redat aici."
|
||||
},
|
||||
"received_via": "către {name}",
|
||||
"remove_color": "Eliminați eticheta",
|
||||
|
||||
+2
-1
@@ -407,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Открыть в приложении",
|
||||
"pdf_external": "PDF-файлы открываются в программе просмотра PDF на этом устройстве.",
|
||||
"unsupported": "Для этого типа файлов нет предпросмотра."
|
||||
"unsupported": "Для этого типа файлов нет предпросмотра.",
|
||||
"media_unsupported": "Этот файл нельзя воспроизвести здесь."
|
||||
},
|
||||
"received_via": "на {name}",
|
||||
"remove_color": "Убрать тег",
|
||||
|
||||
+2
-1
@@ -407,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Otvoriť v aplikácii",
|
||||
"pdf_external": "Súbory PDF sa otvárajú v prehliadači PDF na tomto zariadení.",
|
||||
"unsupported": "Pre tento typ súboru nie je k dispozícii náhľad."
|
||||
"unsupported": "Pre tento typ súboru nie je k dispozícii náhľad.",
|
||||
"media_unsupported": "Tento súbor tu nie je možné prehrať."
|
||||
},
|
||||
"received_via": "pre {name}",
|
||||
"remove_color": "Odstrániť štítok",
|
||||
|
||||
+2
-1
@@ -407,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Uygulamada aç",
|
||||
"pdf_external": "PDF dosyaları bu cihazdaki PDF görüntüleyicide açılır.",
|
||||
"unsupported": "Bu dosya türü için önizleme yok."
|
||||
"unsupported": "Bu dosya türü için önizleme yok.",
|
||||
"media_unsupported": "Bu dosya burada oynatılamıyor."
|
||||
},
|
||||
"received_via": "alıcı: {name}",
|
||||
"remove_color": "Etiketi kaldır",
|
||||
|
||||
+2
-1
@@ -407,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "Відкрити в застосунку",
|
||||
"pdf_external": "PDF-файли відкриваються в переглядачі PDF на цьому пристрої.",
|
||||
"unsupported": "Для цього типу файлів немає попереднього перегляду."
|
||||
"unsupported": "Для цього типу файлів немає попереднього перегляду.",
|
||||
"media_unsupported": "Цей файл не можна відтворити тут."
|
||||
},
|
||||
"received_via": "на {name}",
|
||||
"remove_color": "Прибрати тег",
|
||||
|
||||
@@ -407,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "在應用程式中開啟",
|
||||
"pdf_external": "PDF 會在此裝置的 PDF 檢視器中開啟。",
|
||||
"unsupported": "無法預覽此檔案類型。"
|
||||
"unsupported": "無法預覽此檔案類型。",
|
||||
"media_unsupported": "無法在此播放這個檔案。"
|
||||
},
|
||||
"received_via": "寄給 {name}",
|
||||
"remove_color": "移除標籤",
|
||||
|
||||
+2
-1
@@ -407,7 +407,8 @@
|
||||
"preview": {
|
||||
"open_external": "在应用中打开",
|
||||
"pdf_external": "PDF 将在此设备的 PDF 查看器中打开。",
|
||||
"unsupported": "无法预览此类文件。"
|
||||
"unsupported": "无法预览此类文件。",
|
||||
"media_unsupported": "无法在此播放该文件。"
|
||||
},
|
||||
"received_via": "发给 {name}",
|
||||
"remove_color": "删除标签",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.77",
|
||||
"version": "0.1.80",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.77",
|
||||
"version": "0.1.80",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.77",
|
||||
"version": "0.1.80",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
diff --git a/node_modules/postal-mime/src/base64-decoder.js b/node_modules/postal-mime/src/base64-decoder.js
|
||||
index 93c2aea..ba6e3f1 100644
|
||||
--- a/node_modules/postal-mime/src/base64-decoder.js
|
||||
+++ b/node_modules/postal-mime/src/base64-decoder.js
|
||||
@@ -1,4 +1,4 @@
|
||||
-import { decodeBase64, blobToArrayBuffer } from './decode-strings.js';
|
||||
+import { decodeBase64, concatChunks } from './decode-strings.js';
|
||||
|
||||
export default class Base64Decoder {
|
||||
constructor(opts) {
|
||||
@@ -49,6 +49,6 @@ export default class Base64Decoder {
|
||||
finalize() {
|
||||
this.flushRemainder();
|
||||
|
||||
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
|
||||
+ return concatChunks(this.chunks);
|
||||
}
|
||||
}
|
||||
diff --git a/node_modules/postal-mime/src/decode-strings.js b/node_modules/postal-mime/src/decode-strings.js
|
||||
index a5e058e..f255e8d 100644
|
||||
--- a/node_modules/postal-mime/src/decode-strings.js
|
||||
+++ b/node_modules/postal-mime/src/decode-strings.js
|
||||
@@ -145,6 +145,23 @@ export function getDecoder(charset) {
|
||||
* @param {Blob} blob Blob to convert
|
||||
* @returns {ArrayBuffer} Converted value
|
||||
*/
|
||||
+// React Native's Blob cannot be built from ArrayBuffers or typed arrays, so
|
||||
+// the decoders join their chunks here instead of through a Blob.
|
||||
+export function concatChunks(chunks) {
|
||||
+ const parts = chunks.map(chunk => {
|
||||
+ if (typeof chunk === 'string') return textEncoder.encode(chunk);
|
||||
+ if (chunk instanceof ArrayBuffer) return new Uint8Array(chunk);
|
||||
+ return new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
||||
+ });
|
||||
+ const out = new Uint8Array(parts.reduce((n, part) => n + part.byteLength, 0));
|
||||
+ let pos = 0;
|
||||
+ for (const part of parts) {
|
||||
+ out.set(part, pos);
|
||||
+ pos += part.byteLength;
|
||||
+ }
|
||||
+ return out.buffer;
|
||||
+}
|
||||
+
|
||||
export async function blobToArrayBuffer(blob) {
|
||||
if ('arrayBuffer' in blob) {
|
||||
return await blob.arrayBuffer();
|
||||
diff --git a/node_modules/postal-mime/src/pass-through-decoder.js b/node_modules/postal-mime/src/pass-through-decoder.js
|
||||
index ad1c222..1e1d012 100644
|
||||
--- a/node_modules/postal-mime/src/pass-through-decoder.js
|
||||
+++ b/node_modules/postal-mime/src/pass-through-decoder.js
|
||||
@@ -1,4 +1,4 @@
|
||||
-import { blobToArrayBuffer } from './decode-strings.js';
|
||||
+import { concatChunks } from './decode-strings.js';
|
||||
|
||||
export default class PassThroughDecoder {
|
||||
constructor() {
|
||||
@@ -12,6 +12,6 @@ export default class PassThroughDecoder {
|
||||
|
||||
finalize() {
|
||||
// convert an array of arraybuffers into a blob and then back into a single arraybuffer
|
||||
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
|
||||
+ return concatChunks(this.chunks);
|
||||
}
|
||||
}
|
||||
diff --git a/node_modules/postal-mime/src/qp-decoder.js b/node_modules/postal-mime/src/qp-decoder.js
|
||||
index adee72c..3973673 100644
|
||||
--- a/node_modules/postal-mime/src/qp-decoder.js
|
||||
+++ b/node_modules/postal-mime/src/qp-decoder.js
|
||||
@@ -1,4 +1,4 @@
|
||||
-import { blobToArrayBuffer, hexNibble } from './decode-strings.js';
|
||||
+import { concatChunks, hexNibble } from './decode-strings.js';
|
||||
|
||||
const CHR_EQUALS = 0x3d;
|
||||
const CHR_LF = 0x0a;
|
||||
@@ -83,6 +83,6 @@ export default class QPDecoder {
|
||||
this.flushBuffer();
|
||||
|
||||
// convert an array of arraybuffers into a blob and then back into a single arraybuffer
|
||||
- return blobToArrayBuffer(new Blob(this.chunks, { type: 'application/octet-stream' }));
|
||||
+ return concatChunks(this.chunks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'acc-1',
|
||||
request: vi.fn(),
|
||||
getMaxCallsInRequest: vi.fn(() => 16),
|
||||
getMaxObjectsInGet: vi.fn(() => 500),
|
||||
getMaxObjectsInSet: vi.fn(() => 500),
|
||||
},
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../jmap-client';
|
||||
import { saveDraftVersion, type OutgoingAttachment, type OutgoingEmail } from '../email';
|
||||
import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs } from '../../lib/draft-part-blobs';
|
||||
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
|
||||
/**
|
||||
* A tiny server with Stalwart's blob semantics, as measured on 0.16.22 (2026-10-14):
|
||||
* an email's part blobIds are derived from that email and stop resolving the
|
||||
* moment it is destroyed ("blobId … does not exist on this server"); creating
|
||||
* an email copies the referenced bytes into new parts with new blobIds.
|
||||
*/
|
||||
function fakeStalwart() {
|
||||
let seq = 0;
|
||||
const emails = new Map<string, Array<{ blobId: string; name: string; size: number; cid?: string; type: string }>>();
|
||||
const live = (blobId: string) => [...emails.values()].some((parts) => parts.some((p) => p.blobId === blobId));
|
||||
const seed = (id: string, parts: OutgoingAttachment[]) => {
|
||||
emails.set(id, parts.map((p) => ({ blobId: p.blobId, name: p.name, size: p.size ?? 0, cid: p.cid, type: p.type })));
|
||||
};
|
||||
mockRequest.mockImplementation(async (calls: Array<[string, Record<string, any>, string]>) => {
|
||||
const out: Array<[string, unknown, string]> = [];
|
||||
let createdId: string | undefined;
|
||||
for (const [method, args, callId] of calls) {
|
||||
if (method === 'Email/set' && args.create) {
|
||||
const create = args.create.draft;
|
||||
const dead = (create.attachments ?? []).find((a: { blobId: string }) => !live(a.blobId));
|
||||
if (dead) {
|
||||
out.push(['Email/set', { notCreated: { draft: {
|
||||
type: 'blobNotFound', description: `blobId ${dead.blobId} does not exist on this server.`,
|
||||
} } }, callId]);
|
||||
continue;
|
||||
}
|
||||
createdId = `e${++seq}`;
|
||||
emails.set(createdId, (create.attachments ?? []).map((a: OutgoingAttachment, i: number) => ({
|
||||
blobId: `${createdId}-part${i}`, name: a.name, size: a.size ?? 0, cid: a.cid ? `<${a.cid}>` : undefined, type: a.type,
|
||||
})));
|
||||
out.push(['Email/set', { created: { draft: { id: createdId } } }, callId]);
|
||||
} else if (method === 'Email/set' && args.destroy) {
|
||||
for (const id of args.destroy) emails.delete(id);
|
||||
out.push(['Email/set', { destroyed: args.destroy }, callId]);
|
||||
} else if (method === 'Email/get') {
|
||||
if (!createdId || args.ids?.[0] !== '#draft') {
|
||||
out.push(['error', { type: 'invalidResultReference' }, callId]);
|
||||
continue;
|
||||
}
|
||||
out.push(['Email/get', { list: [{ id: createdId, attachments: emails.get(createdId) }] }, callId]);
|
||||
}
|
||||
}
|
||||
return { methodResponses: out };
|
||||
});
|
||||
return { seed, emails };
|
||||
}
|
||||
|
||||
const IMG_A: OutgoingAttachment = { blobId: 'd0-part-a', name: 'schema-a.png', size: 136574, type: 'image/png', disposition: 'inline', cid: 'a@undust.co' };
|
||||
const IMG_B: OutgoingAttachment = { blobId: 'd0-part-b', name: 'schema-b.png', size: 175155, type: 'image/png', disposition: 'inline', cid: 'b@undust.co' };
|
||||
|
||||
const draftWith = (attachments: OutgoingAttachment[]): OutgoingEmail => ({
|
||||
from: [{ email: 'me@undust.co' }],
|
||||
to: [{ email: 'test@example.invalid' }],
|
||||
subject: 'RE: draft with two inline images',
|
||||
htmlBody: '<p>x <img src="cid:a@undust.co"> <img src="cid:b@undust.co"></p>',
|
||||
textBody: 'x',
|
||||
attachments,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('saveDraftVersion', () => {
|
||||
it('reads the new version\'s parts back in the same request, by creation id', async () => {
|
||||
fakeStalwart().seed('d0', [IMG_A]);
|
||||
const saved = await saveDraftVersion(draftWith([IMG_A]), 'drafts', 'd0');
|
||||
const calls = mockRequest.mock.calls[0][0];
|
||||
expect(calls.map((c: [string]) => c[0])).toEqual(['Email/set', 'Email/get']);
|
||||
expect(calls[1][1].ids).toEqual(['#draft']);
|
||||
expect(saved.id).toBe('e1');
|
||||
expect(saved.attachments.map((a) => a.blobId)).toEqual(['e1-part0']);
|
||||
});
|
||||
|
||||
it('destroys the previous version only after the new one exists', async () => {
|
||||
fakeStalwart().seed('d0', [IMG_A]);
|
||||
await saveDraftVersion(draftWith([IMG_A]), 'drafts', 'd0');
|
||||
const destroyCall = mockRequest.mock.calls[1][0][0];
|
||||
expect(destroyCall[0]).toBe('Email/set');
|
||||
expect(destroyCall[1].destroy).toEqual(['d0']);
|
||||
});
|
||||
|
||||
it('still returns the id when the read-back fails', async () => {
|
||||
mockRequest.mockResolvedValueOnce({ methodResponses: [
|
||||
['Email/set', { created: { draft: { id: 'e9' } } }, '0'],
|
||||
['error', { type: 'serverFail' }, '1'],
|
||||
] });
|
||||
const saved = await saveDraftVersion(draftWith([]), 'drafts');
|
||||
expect(saved).toEqual({ id: 'e9', attachments: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-opened draft with inline images: save, save again, send', () => {
|
||||
// What ComposeScreen does: buildOutgoing maps each held blobId through the
|
||||
// state, saveDraftOnce rebases it on the parts read back.
|
||||
async function editSession(rebase: boolean) {
|
||||
const server = fakeStalwart();
|
||||
server.seed('d0', [IMG_A, IMG_B]); // the draft as deposited (IMAP APPEND or an earlier save)
|
||||
let state = initialDraftPartBlobState();
|
||||
let draftId = 'd0';
|
||||
const outgoing = () => draftWith([IMG_A, IMG_B].map((a) => ({ ...a, blobId: liveBlobId(state, a.blobId) })));
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const sent = outgoing();
|
||||
const saved = await saveDraftVersion(sent, 'drafts', draftId);
|
||||
draftId = saved.id;
|
||||
if (rebase) state = rebaseDraftPartBlobs(state, sent.attachments ?? [], saved.attachments);
|
||||
}
|
||||
return { server, draftId };
|
||||
}
|
||||
|
||||
it('reproduces the bug without the rebase: the second save references the destroyed draft\'s parts', async () => {
|
||||
await expect(editSession(false)).rejects.toThrow('blobId d0-part-a does not exist on this server.');
|
||||
});
|
||||
|
||||
it('keeps every save (and so the send, which builds the same attachments) on live blobs', async () => {
|
||||
const { server, draftId } = await editSession(true);
|
||||
expect([...server.emails.keys()]).toEqual([draftId]);
|
||||
expect(server.emails.get(draftId)!.map((p) => p.name)).toEqual(['schema-a.png', 'schema-b.png']);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
getFullEmails,
|
||||
getThreadsHeaders,
|
||||
createDraft,
|
||||
saveDraftVersion,
|
||||
destroyEmails,
|
||||
sendEmail,
|
||||
markAsSpam,
|
||||
undoSpam,
|
||||
@@ -33,6 +35,9 @@ import {
|
||||
EMAIL_FULL_PROPERTIES,
|
||||
} from '../email';
|
||||
import { getIdentities } from '../identity';
|
||||
import { uploadBytes } from '../blob';
|
||||
import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs } from '../../lib/draft-part-blobs';
|
||||
import type { OutgoingAttachment } from '../email';
|
||||
import { getCalendars, queryEvents, createEvent, deleteEvents } from '../calendar';
|
||||
import { getAddressBooks, createContact, deleteContacts } from '../contacts';
|
||||
|
||||
@@ -192,6 +197,44 @@ live('live Stalwart', () => {
|
||||
await destroyEmails([id]);
|
||||
}, 60_000);
|
||||
|
||||
it('keeps a re-opened draft with an inline image saveable across saves (#849 part blobs)', async () => {
|
||||
// A 1x1 PNG uploaded once; D0 stands for the draft as it sits on the server.
|
||||
const png = Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='), (c) => c.charCodeAt(0));
|
||||
const up = await uploadBytes(png, 'image/png');
|
||||
const base = {
|
||||
from: [{ email: USER }],
|
||||
to: [{ email: 'test@example.invalid' }],
|
||||
subject: '[TEST bulwark-blob — supprimable]',
|
||||
htmlBody: '<p>live <img src="cid:img1@live.test"></p>',
|
||||
textBody: 'live',
|
||||
};
|
||||
const img = (blobId: string): OutgoingAttachment => ({
|
||||
blobId, type: 'image/png', name: 'img1.png', size: png.byteLength, disposition: 'inline', cid: 'img1@live.test',
|
||||
});
|
||||
const d0 = await saveDraftVersion({ ...base, attachments: [img(up.blobId)] }, drafts);
|
||||
const created = [d0.id];
|
||||
try {
|
||||
// Re-open D0: the composer holds D0's part blobId.
|
||||
const seeded = d0.attachments[0].blobId;
|
||||
let state = initialDraftPartBlobState();
|
||||
let draftId = d0.id;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const sent = [img(liveBlobId(state, seeded))];
|
||||
const saved = await saveDraftVersion({ ...base, attachments: sent }, drafts, draftId);
|
||||
created.push(saved.id);
|
||||
draftId = saved.id;
|
||||
state = rebaseDraftPartBlobs(state, sent, saved.attachments);
|
||||
}
|
||||
// Without the rebase the seeded part blob is gone with D0.
|
||||
await expect(saveDraftVersion({ ...base, attachments: [img(seeded)] }, drafts))
|
||||
.rejects.toThrow(/does not exist|blobNotFound/);
|
||||
const [last] = await getFullEmails([draftId]);
|
||||
expect(last.attachments?.[0]?.cid).toContain('img1@live.test');
|
||||
} finally {
|
||||
await destroyEmails(created).catch(() => undefined);
|
||||
}
|
||||
}, 90_000);
|
||||
|
||||
it('sends through Drafts and files into Sent only after submission (#188)', async () => {
|
||||
const identities = await getIdentities();
|
||||
expect(identities.length).toBeGreaterThan(0);
|
||||
|
||||
+48
-5
@@ -1679,31 +1679,64 @@ export async function sendEmail(
|
||||
};
|
||||
}
|
||||
|
||||
/** A saved draft version and the attachment parts the server built for it. */
|
||||
export interface SavedDraftVersion {
|
||||
id: string;
|
||||
/**
|
||||
* Attachment and inline parts of the new version, with their blobIds. A
|
||||
* part's blobId only lives as long as this version: re-opened drafts must
|
||||
* send these (not the previous version's) on their next save or send.
|
||||
* Empty when the server did not answer the read-back.
|
||||
*/
|
||||
attachments: Attachment[];
|
||||
}
|
||||
|
||||
const DRAFT_PART_BODY_PROPERTIES = ['partId', 'blobId', 'size', 'name', 'type', 'cid', 'disposition'];
|
||||
|
||||
/**
|
||||
* Save a draft into the Drafts folder (`$draft` + `$seen`). When `previousDraftId`
|
||||
* is given it is destroyed only AFTER the replacement was created (#849): a
|
||||
* combined create+destroy would delete the last good copy when the create
|
||||
* fails (e.g. blobNotFound on a re-opened draft's attachments). Returns the
|
||||
* new draft id.
|
||||
* fails (e.g. blobNotFound on a re-opened draft's attachments). The new
|
||||
* version's attachment parts are read back in the same request, so the
|
||||
* composer can re-point its draft-part blobIds before the old version (and
|
||||
* its part blobs) is destroyed.
|
||||
*/
|
||||
export async function createDraft(
|
||||
export async function saveDraftVersion(
|
||||
email: OutgoingEmail,
|
||||
draftsMailboxId: string,
|
||||
previousDraftId?: string,
|
||||
accountIdOverride?: string,
|
||||
): Promise<string> {
|
||||
): Promise<SavedDraftVersion> {
|
||||
const accountId = accountIdOverride ?? jmapClient.accountId;
|
||||
const emailCreate = buildEmailCreate(email);
|
||||
emailCreate.mailboxIds = { [draftsMailboxId]: true };
|
||||
emailCreate.keywords = { $seen: true, $draft: true };
|
||||
const res = await jmapClient.request([
|
||||
['Email/set', { accountId, create: { draft: emailCreate } }, '0'],
|
||||
// `#draft` is the creation id (RFC 8620 §5.3). Stalwart 0.16 does not
|
||||
// resolve a result reference into an Email/set `created` map
|
||||
// (invalidResultReference), but it does resolve the creation id.
|
||||
['Email/get', {
|
||||
accountId,
|
||||
ids: ['#draft'],
|
||||
properties: ['id', 'attachments'],
|
||||
bodyProperties: DRAFT_PART_BODY_PROPERTIES,
|
||||
}, '1'],
|
||||
]);
|
||||
const body = requireMethodResult(res, '0', 'Email/set');
|
||||
const notCreated = body.notCreated?.draft as { description?: string; type?: string } | undefined;
|
||||
if (notCreated) throw new Error(notCreated.description || notCreated.type || 'Failed to save draft');
|
||||
const id = body.created?.draft?.id as string | undefined;
|
||||
if (!id) throw new Error('Draft save returned no id');
|
||||
let attachments: Attachment[] = [];
|
||||
try {
|
||||
const got = requireMethodResult(res, '1', 'Email/get');
|
||||
const saved = (got.list as Array<{ id: string; attachments?: Attachment[] }> | undefined)?.find((e) => e.id === id);
|
||||
attachments = (saved?.attachments ?? []).filter((a) => !!a.blobId);
|
||||
} catch (err) {
|
||||
console.warn('[email] could not read back the saved draft parts:', err);
|
||||
}
|
||||
if (previousDraftId && previousDraftId !== id) {
|
||||
try {
|
||||
await destroyEmails([previousDraftId], accountId);
|
||||
@@ -1711,7 +1744,17 @@ export async function createDraft(
|
||||
console.warn('[email] failed to destroy previous draft version:', err);
|
||||
}
|
||||
}
|
||||
return id;
|
||||
return { id, attachments };
|
||||
}
|
||||
|
||||
/** {@link saveDraftVersion} for callers that only need the new draft id. */
|
||||
export async function createDraft(
|
||||
email: OutgoingEmail,
|
||||
draftsMailboxId: string,
|
||||
previousDraftId?: string,
|
||||
accountIdOverride?: string,
|
||||
): Promise<string> {
|
||||
return (await saveDraftVersion(email, draftsMailboxId, previousDraftId, accountIdOverride)).id;
|
||||
}
|
||||
|
||||
export interface ScheduledEmail {
|
||||
|
||||
@@ -78,10 +78,16 @@ interface Props {
|
||||
onSelectionChange?: (state: RichTextSelectionState) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
/**
|
||||
* Android: the page undid a caret-reveal pan of `dy` px (dp) inside the
|
||||
* WebView. The host should scroll its ScrollView down by `dy` so the caret
|
||||
* stays visible (see `pinVisualViewport` in editor-html).
|
||||
*/
|
||||
onViewportShift?: (dy: number) => void;
|
||||
}
|
||||
|
||||
const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
|
||||
{ initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur },
|
||||
{ initialHtml = '', placeholder = '', blockRemoteImages = false, onChange, onSelectionChange, onFocus, onBlur, onViewportShift },
|
||||
ref,
|
||||
) {
|
||||
const c = useColors();
|
||||
@@ -92,6 +98,8 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
const onSelectionRef = React.useRef(onSelectionChange);
|
||||
const onFocusRef = React.useRef(onFocus);
|
||||
const onBlurRef = React.useRef(onBlur);
|
||||
const onViewportShiftRef = React.useRef(onViewportShift);
|
||||
onViewportShiftRef.current = onViewportShift;
|
||||
onChangeRef.current = onChange;
|
||||
onSelectionRef.current = onSelectionChange;
|
||||
onFocusRef.current = onFocus;
|
||||
@@ -100,7 +108,9 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
// Build the source HTML once. Theming changes won't auto-rebuild the page,
|
||||
// which is fine: themes change rarely and the user can recompose if needed.
|
||||
const html = React.useMemo(
|
||||
() => buildEditorHtml({ initialHtml, placeholder, c, blockRemoteImages }),
|
||||
() => buildEditorHtml({
|
||||
initialHtml, placeholder, c, blockRemoteImages, pinVisualViewport: Platform.OS === 'android',
|
||||
}),
|
||||
// c reference changes per theme switch but we don't want to lose user
|
||||
// edits on a theme change. Bake in the initial colors only.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -224,6 +234,11 @@ const RichTextEditor = React.forwardRef<RichTextEditorHandle, Props>(function Ri
|
||||
case 'blur':
|
||||
onBlurRef.current?.();
|
||||
break;
|
||||
case 'viewportShift': {
|
||||
const dy = typeof data.payload === 'number' ? data.payload : Number(data.payload);
|
||||
if (Number.isFinite(dy) && dy !== 0) onViewportShiftRef.current?.(dy);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import {
|
||||
Check, X, Upload, Rss, Shuffle, Star, Plus, Pencil, Share2, Eraser, Trash2, RefreshCw,
|
||||
Check, X, Upload, Rss, Shuffle, Star, Plus, Pencil, Share2, Eraser, Trash2, RefreshCw, ArrowUp, ArrowDown,
|
||||
type AppIcon,
|
||||
} from '../icons';
|
||||
import type { Calendar } from '../../api/types';
|
||||
@@ -55,6 +55,9 @@ interface CalendarSidebarDrawerProps {
|
||||
onShare?: (calendar: Calendar) => void;
|
||||
onClear?: (calendar: Calendar) => void;
|
||||
onDelete?: (calendar: Calendar) => void;
|
||||
// Move one of the user's own calendars next to `targetId`, a neighbour in
|
||||
// the same section (before it when moving up, after it when moving down).
|
||||
onMove?: (calendar: Calendar, targetId: string) => void;
|
||||
// Client-side iCal subscriptions are managed from the subscriptions sheet.
|
||||
isSubscriptionCalendar?: (calendarId: string) => boolean;
|
||||
}
|
||||
@@ -79,6 +82,7 @@ export function CalendarSidebarDrawer({
|
||||
onShare,
|
||||
onClear,
|
||||
onDelete,
|
||||
onMove,
|
||||
isSubscriptionCalendar,
|
||||
}: CalendarSidebarDrawerProps) {
|
||||
const c = useColors();
|
||||
@@ -148,6 +152,7 @@ export function CalendarSidebarDrawer({
|
||||
onShare,
|
||||
onClear,
|
||||
onDelete,
|
||||
onMove,
|
||||
isSubscriptionCalendar,
|
||||
};
|
||||
|
||||
@@ -284,6 +289,7 @@ function Section({
|
||||
onShare,
|
||||
onClear,
|
||||
onDelete,
|
||||
onMove,
|
||||
isSubscriptionCalendar,
|
||||
}: {
|
||||
title: string;
|
||||
@@ -299,11 +305,14 @@ function Section({
|
||||
onShare?: (calendar: Calendar) => void;
|
||||
onClear?: (calendar: Calendar) => void;
|
||||
onDelete?: (calendar: Calendar) => void;
|
||||
onMove?: (calendar: Calendar, targetId: string) => void;
|
||||
isSubscriptionCalendar?: (calendarId: string) => boolean;
|
||||
}) {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
// Neighbours a calendar can move past: the server calendars of this section.
|
||||
const orderable = calendars.filter((cal) => cal.id !== BIRTHDAY_CALENDAR_ID);
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
@@ -320,7 +329,11 @@ function Section({
|
||||
const canClear = !!onClear && isOwn && isWritableCalendar(cal);
|
||||
// Like webmail: the default calendar and shared calendars can't be deleted.
|
||||
const canDelete = !!onDelete && isOwn && !cal.isDefault && (!cal.myRights || cal.myRights.mayDelete !== false);
|
||||
const hasActions = canSetDefault || canRecolor || canRename || canShare || canClear || canDelete;
|
||||
const orderIndex = orderable.indexOf(cal);
|
||||
const previousId = isOwn && orderIndex > 0 ? orderable[orderIndex - 1].id : undefined;
|
||||
const nextId = isOwn && orderIndex >= 0 ? orderable[orderIndex + 1]?.id : undefined;
|
||||
const canMove = !!onMove && (!!previousId || !!nextId);
|
||||
const hasActions = canSetDefault || canRecolor || canRename || canShare || canClear || canDelete || canMove;
|
||||
const expanded = expandedId === cal.id && hasActions;
|
||||
return (
|
||||
<View key={cal.id}>
|
||||
@@ -355,6 +368,27 @@ function Section({
|
||||
|
||||
{expanded && (
|
||||
<View style={styles.actionsPanel}>
|
||||
{/* The panel stays open so a calendar can be moved several places. */}
|
||||
{canMove && previousId && (
|
||||
<Pressable
|
||||
onPress={() => onMove!(cal, previousId)}
|
||||
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<ArrowUp size={16} color={c.textSecondary} />
|
||||
<Text style={styles.panelRowText}>{t('settings.account.accounts.move_up', 'Move up')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{canMove && nextId && (
|
||||
<Pressable
|
||||
onPress={() => onMove!(cal, nextId)}
|
||||
style={({ pressed }) => [styles.panelRow, pressed && styles.rowPressed]}
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<ArrowDown size={16} color={c.textSecondary} />
|
||||
<Text style={styles.panelRowText}>{t('settings.account.accounts.move_down', 'Move down')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
{canSetDefault && (
|
||||
<Pressable
|
||||
onPress={() => { onExpand(null); onSetDefault!(cal); }}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
TextInput,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
Platform,
|
||||
Alert as NativeAlert,
|
||||
} from 'react-native';
|
||||
@@ -70,6 +69,7 @@ import { ParticipantInput } from './ParticipantInput';
|
||||
import { useContactNameResolver } from '../../lib/contact-name-resolver';
|
||||
import { RecurrenceEditor } from './RecurrenceEditor';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
import ToggleSwitch from '../ToggleSwitch';
|
||||
|
||||
type RecurrenceOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom';
|
||||
|
||||
@@ -459,7 +459,7 @@ export function EventModal({
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder={t('calendar.form.title', 'Title')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
style={styles.titleInput}
|
||||
/>
|
||||
|
||||
@@ -515,12 +515,10 @@ export function EventModal({
|
||||
<Section icon={<Clock size={16} color={c.textMuted} />} label={t('calendar.form.time', 'Time')}>
|
||||
<View style={styles.allDayRow}>
|
||||
<Text style={styles.fieldText}>{t('calendar.form.all_day_event', 'All-day event')}</Text>
|
||||
<Switch
|
||||
<ToggleSwitch
|
||||
value={allDay}
|
||||
onValueChange={setAllDay}
|
||||
accessibilityLabel={t('calendar.form.all_day_event', 'All-day event')}
|
||||
thumbColor={allDay ? c.primary : c.textMuted}
|
||||
trackColor={{ false: c.surface, true: c.primaryBg }}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -673,7 +671,7 @@ export function EventModal({
|
||||
keyboardType="number-pad"
|
||||
style={styles.customAmountInput}
|
||||
placeholder="30"
|
||||
placeholderTextColor={c.textMuted}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
/>
|
||||
<View style={styles.customUnits}>
|
||||
{(['minutes', 'hours', 'days', 'weeks'] as ReminderUnit[]).map((unit) => (
|
||||
@@ -708,7 +706,7 @@ export function EventModal({
|
||||
value={location}
|
||||
onChangeText={setLocation}
|
||||
placeholder={t('calendar.form.location_placeholder', 'Place or address')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
style={styles.fieldButton}
|
||||
/>
|
||||
</Section>
|
||||
@@ -718,7 +716,7 @@ export function EventModal({
|
||||
value={videoUrl}
|
||||
onChangeText={setVideoUrl}
|
||||
placeholder="https://meet.example.com/…"
|
||||
placeholderTextColor={c.textMuted}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
@@ -734,7 +732,7 @@ export function EventModal({
|
||||
value={description}
|
||||
onChangeText={setDescription}
|
||||
placeholder={t('calendar.form.description_placeholder', 'Notes, agenda…')}
|
||||
placeholderTextColor={c.textMuted}
|
||||
placeholderTextColor={c.mutedForeground}
|
||||
multiline
|
||||
style={styles.descriptionInput}
|
||||
/>
|
||||
@@ -756,12 +754,10 @@ export function EventModal({
|
||||
<Text style={[styles.fieldText, { flex: 1 }]}>
|
||||
{t('calendar.participants.send_invitations', 'Send invitations to participants')}
|
||||
</Text>
|
||||
<Switch
|
||||
<ToggleSwitch
|
||||
value={sendInvitations}
|
||||
onValueChange={setSendInvitations}
|
||||
accessibilityLabel={t('calendar.participants.send_invitations', 'Send invitations to participants')}
|
||||
thumbColor={sendInvitations ? c.primary : c.textMuted}
|
||||
trackColor={{ false: c.surface, true: c.primaryBg }}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
@@ -913,7 +909,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.background,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
fieldText: { ...typography.body, color: c.text },
|
||||
@@ -930,7 +926,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
reminderRemove: { padding: 2 },
|
||||
customReminderRow: {
|
||||
@@ -939,7 +935,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
customAmountInput: {
|
||||
...typography.body,
|
||||
@@ -975,7 +971,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.popover,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
popoverRow: {
|
||||
@@ -997,7 +993,7 @@ function makeStyles(c: ThemePalette) {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.background,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
},
|
||||
@@ -1009,7 +1005,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
dateLabel: { ...typography.body, color: c.textSecondary, flex: 1 },
|
||||
datePickers: { flexDirection: 'row', gap: spacing.sm },
|
||||
@@ -1030,7 +1026,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderRadius: radius.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.background,
|
||||
color: c.text,
|
||||
...typography.body,
|
||||
textAlignVertical: 'top',
|
||||
|
||||
@@ -530,7 +530,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.background,
|
||||
},
|
||||
addBtn: {
|
||||
width: 38,
|
||||
@@ -552,7 +552,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderRadius: radius.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: c.surface,
|
||||
backgroundColor: c.background,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
editorRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap' },
|
||||
|
||||
@@ -136,6 +136,6 @@ function makeStyles(c: ThemePalette) {
|
||||
info: { flex: 1, minWidth: 0 },
|
||||
name: { ...typography.bodyMedium, color: c.text },
|
||||
subtitle: { ...typography.caption, color: c.textMuted, marginTop: 1 },
|
||||
tertiary: { ...typography.small, color: c.textSecondary, marginTop: 1 },
|
||||
tertiary: { ...typography.caption, color: c.mutedForeground, marginTop: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Alert } from 'react-native';
|
||||
import { Paperclip, Download, Eye, ExternalLink, Save, Share2, FolderArchive } from '../icons';
|
||||
import type { File } from 'expo-file-system';
|
||||
import type { Attachment, Email } from '../../api/types';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import {
|
||||
shareAttachment, downloadAttachment, shareAttachmentViaSheet, fetchBlobBytes,
|
||||
shareBytes, shareLocalFile, cachePreviewFile, writePreviewFile, discardPreviewFile,
|
||||
shareAttachment, downloadAttachment, shareAttachmentViaSheet, fetchBlobBytes, shareBytes, attachmentSaveName,
|
||||
} from '../../lib/email-export';
|
||||
import {
|
||||
getAttachmentDisplayName, visibleAttachments, previewKindFor, formatSize,
|
||||
getAttachmentDisplayName, visibleAttachments, formatSize,
|
||||
} from '../../lib/attachment-display';
|
||||
import { emailExportFilename } from '../../lib/download-filename';
|
||||
import { ActionSheet, type ActionSheetItem } from './ActionSheet';
|
||||
import { AttachmentPreviewModal, type PreviewItem } from './AttachmentPreviewModal';
|
||||
import { emlPreviewFromBytes, type ExtractedAttachment } from './use-body-override';
|
||||
import { AttachmentPreviewModal } from './AttachmentPreviewModal';
|
||||
import type { ExtractedAttachment } from './use-body-override';
|
||||
import { previewKindOf, useFilePreview, type PreviewSource } from './use-file-preview';
|
||||
|
||||
interface Props {
|
||||
email: Email;
|
||||
@@ -65,24 +64,7 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const [busyKey, setBusyKey] = React.useState<string | null>(null);
|
||||
const [sheetItem, setSheetItem] = React.useState<Item | null>(null);
|
||||
const [preview, setPreview] = React.useState<PreviewItem | null>(null);
|
||||
const [previewLoading, setPreviewLoading] = React.useState(false);
|
||||
const previewSource = React.useRef<Item | null>(null);
|
||||
// The file the open preview shows, in a temp folder of its own: images are
|
||||
// cached by path, so two attachments both called "image001.png" must not
|
||||
// share one. Deleted when the preview closes, unless it went to another
|
||||
// app, which may still be reading it; the stale-export sweep gets that one.
|
||||
const previewFile = React.useRef<{ file: File; handedOff: boolean } | null>(null);
|
||||
// Bumped whenever a preview ends, so a download that finishes after its
|
||||
// preview was closed is dropped instead of reopening it.
|
||||
const previewSeq = React.useRef(0);
|
||||
const endPreview = React.useCallback(() => {
|
||||
previewSeq.current += 1;
|
||||
const open = previewFile.current;
|
||||
previewFile.current = null;
|
||||
if (open && !open.handedOff) discardPreviewFile(open.file);
|
||||
}, []);
|
||||
React.useEffect(() => endPreview, [endPreview]);
|
||||
const preview = useFilePreview();
|
||||
|
||||
const items = React.useMemo<Item[]>(() => {
|
||||
const blobs = visibleAttachments(email, { hideInlineImageAttachments, calendarBannerShown, tnefUnpacked })
|
||||
@@ -134,49 +116,29 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
|
||||
else await shareBytes(item.part.bytes, item.name, item.type, { forceSheet: true });
|
||||
}, t('email_viewer.attachment_failed', 'Could not share attachment'));
|
||||
|
||||
const showPreview = (item: Item) => {
|
||||
const kind = previewKindFor({ name: item.name, type: item.type });
|
||||
if (kind === 'none') { void open(item); return; }
|
||||
previewSource.current = item;
|
||||
endPreview();
|
||||
const seq = previewSeq.current;
|
||||
const stillOpen = () => seq === previewSeq.current;
|
||||
setPreviewLoading(true);
|
||||
setPreview(null);
|
||||
void (async () => {
|
||||
try {
|
||||
if (kind === 'text' || kind === 'eml') {
|
||||
const bytes = item.kind === 'blob'
|
||||
? await fetchBlobBytes(item.att.blobId, item.att.name, item.att.type, jmapAccountId)
|
||||
: item.part.bytes;
|
||||
const next: PreviewItem = kind === 'eml'
|
||||
? { kind, name: item.name, mimeType: item.type, eml: await emlPreviewFromBytes(bytes) }
|
||||
: { kind, name: item.name, mimeType: item.type, text: new TextDecoder('utf-8').decode(bytes) };
|
||||
if (stillOpen()) setPreview(next);
|
||||
return;
|
||||
}
|
||||
const file = item.kind === 'blob'
|
||||
? await cachePreviewFile(item.att.blobId, item.name, item.type, jmapAccountId)
|
||||
: writePreviewFile(item.part.bytes, item.name, item.type);
|
||||
if (!stillOpen()) {
|
||||
discardPreviewFile(file);
|
||||
return;
|
||||
}
|
||||
previewFile.current = { file, handedOff: false };
|
||||
setPreview({ kind, name: item.name, mimeType: item.type, fileUri: file.uri });
|
||||
} catch (e) {
|
||||
if (!stillOpen()) return;
|
||||
setPreview(null);
|
||||
Alert.alert(t('email_viewer.attachment_failed', 'Could not open attachment'), e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
if (stillOpen()) setPreviewLoading(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
const sourceOf = (item: Item): PreviewSource => (item.kind === 'blob'
|
||||
? {
|
||||
from: 'blob',
|
||||
blobId: item.att.blobId,
|
||||
accountId: jmapAccountId,
|
||||
email,
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
size: item.size,
|
||||
saveName: attachmentSaveName(email, item.att.name, item.att.type),
|
||||
}
|
||||
: { from: 'bytes', bytes: item.part.bytes, name: item.name, type: item.type, size: item.size });
|
||||
|
||||
// The in-app preview where the type has one, else straight to another app.
|
||||
const showPreview = (item: Item) => run(
|
||||
item.key,
|
||||
() => preview.open(sourceOf(item)),
|
||||
t('email_viewer.attachment_failed', 'Could not open attachment'),
|
||||
);
|
||||
|
||||
const onTap = (item: Item) => {
|
||||
if (mailAttachmentAction === 'download') void save(item);
|
||||
else showPreview(item);
|
||||
else void showPreview(item);
|
||||
};
|
||||
|
||||
const downloadAll = () => run('__all__', async () => {
|
||||
@@ -204,11 +166,11 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
|
||||
|
||||
const visible = expanded ? items : items.slice(0, 3);
|
||||
const sheetActions: ActionSheetItem[] = sheetItem ? [
|
||||
...(previewKindFor({ name: sheetItem.name, type: sheetItem.type }) !== 'none' ? [{
|
||||
...(previewKindOf(sheetItem) !== 'none' ? [{
|
||||
key: 'preview',
|
||||
label: t('email_viewer.attachment_actions.preview', 'Preview'),
|
||||
icon: <Eye size={18} color={c.textSecondary} />,
|
||||
onPress: () => { const it = sheetItem; setSheetItem(null); showPreview(it); },
|
||||
onPress: () => { const it = sheetItem; setSheetItem(null); void showPreview(it); },
|
||||
}] : []),
|
||||
{
|
||||
key: 'open',
|
||||
@@ -278,36 +240,7 @@ export function AttachmentChips({ email, jmapAccountId, calendarBannerShown, tne
|
||||
onClose={() => setSheetItem(null)}
|
||||
/>
|
||||
|
||||
<AttachmentPreviewModal
|
||||
item={preview}
|
||||
loading={previewLoading}
|
||||
onClose={() => {
|
||||
endPreview();
|
||||
setPreview(null);
|
||||
setPreviewLoading(false);
|
||||
}}
|
||||
onOpenExternal={() => {
|
||||
const it = previewSource.current;
|
||||
if (!it) return;
|
||||
if (preview?.fileUri && it.kind === 'blob') {
|
||||
void run(it.key, () => shareAttachment(it.att.blobId, it.att.name, it.att.type, email, jmapAccountId), t('email_viewer.attachment_failed', 'Could not open attachment'));
|
||||
} else {
|
||||
void open(it);
|
||||
}
|
||||
}}
|
||||
onShare={() => {
|
||||
const it = previewSource.current;
|
||||
if (!it) return;
|
||||
const open = previewFile.current;
|
||||
if (open) {
|
||||
// The receiving app may keep reading it after the sheet closes.
|
||||
open.handedOff = true;
|
||||
void shareLocalFile(open.file, it.type, it.name, { forceSheet: true, keep: true }).catch(() => undefined);
|
||||
} else {
|
||||
void share(it);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<AttachmentPreviewModal {...preview.modalProps} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { spacing, radius, typography, componentSizes, type ThemePalette } from '
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import EmailBodyView from '../EmailBodyView';
|
||||
import type { PreviewKind } from '../../lib/attachment-display';
|
||||
import { needsNativeImageDecoder, needsWebImageViewer, type PreviewKind } from '../../lib/file-preview';
|
||||
import type { EmlPreview } from './use-body-override';
|
||||
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
|
||||
|
||||
@@ -18,10 +18,14 @@ export interface PreviewItem {
|
||||
kind: PreviewKind;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
/** Local file:// URI for images and PDFs. */
|
||||
/** Local file:// URI of the downloaded copy (images, PDFs, media). */
|
||||
fileUri?: string;
|
||||
/** The viewer page next to the file (images, video, audio); see preview-viewer-html. */
|
||||
viewerUri?: string;
|
||||
/** Decoded text for text-like parts. */
|
||||
text?: string;
|
||||
/** The document for HTML and Markdown files, shown through the mail sanitiser. */
|
||||
html?: string;
|
||||
/** Parsed embedded message for .eml previews. */
|
||||
eml?: EmlPreview;
|
||||
/** Replaces "No preview for this file type." on the fallback screen (e.g. the file is too large). */
|
||||
@@ -34,7 +38,7 @@ interface Props {
|
||||
/** Header title while the item is still loading. */
|
||||
title?: string;
|
||||
onClose: () => void;
|
||||
/** Hand the file to an external app (PDF on Android, unsupported types, the header's Open button). */
|
||||
/** Hand the file to another app (types without a preview, the header's Open button). */
|
||||
onOpenExternal: () => void;
|
||||
onShare: () => void;
|
||||
/** Save a copy; adds a Download button to the header. */
|
||||
@@ -44,10 +48,45 @@ interface Props {
|
||||
}
|
||||
|
||||
/**
|
||||
* In-app preview for images, PDFs (iOS renders them in the WebView; Android
|
||||
* has no built-in PDF renderer, so it offers the external viewer), text and
|
||||
* embedded messages. Anything else goes to the external viewer / share sheet.
|
||||
* Used by the reader's attachment chips and the Files tab.
|
||||
* A local page in a WebView that may read only its own folder: the image
|
||||
* and media viewers. It posts 'error' when the file doesn't decode.
|
||||
*/
|
||||
function ViewerWebView({ uri, background, onFailed }: { uri: string; background: string; onFailed: () => void }) {
|
||||
const folder = uri.slice(0, uri.lastIndexOf('/') + 1);
|
||||
return (
|
||||
<WebView
|
||||
source={{ uri }}
|
||||
originWhitelist={['file://*']}
|
||||
allowFileAccess
|
||||
allowingReadAccessToURL={folder}
|
||||
javaScriptEnabled
|
||||
domStorageEnabled={false}
|
||||
cacheEnabled={false}
|
||||
incognito
|
||||
allowsInlineMediaPlayback
|
||||
mediaPlaybackRequiresUserAction={false}
|
||||
allowsFullscreenVideo
|
||||
allowsPictureInPictureMediaPlayback
|
||||
allowsAirPlayForMediaPlayback
|
||||
setSupportMultipleWindows={false}
|
||||
setBuiltInZoomControls
|
||||
setDisplayZoomControls={false}
|
||||
overScrollMode="never"
|
||||
bounces={false}
|
||||
onShouldStartLoadWithRequest={(req) => req.url === uri}
|
||||
onMessage={(e) => { if (e.nativeEvent.data === 'error') onFailed(); }}
|
||||
onError={onFailed}
|
||||
style={[viewerStyles.fill, { backgroundColor: background }]}
|
||||
containerStyle={{ backgroundColor: background }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* In-app preview for images, PDFs (iOS renders them in the WebView), video
|
||||
* and audio (the WebView's player), text, Markdown and HTML documents and
|
||||
* embedded messages. Files that don't decode, and types without a preview,
|
||||
* offer another app instead. Used by every place that opens a file.
|
||||
*/
|
||||
export function AttachmentPreviewModal({
|
||||
item, loading, title, onClose, onOpenExternal, onShare, onDownload, docked = false,
|
||||
@@ -56,31 +95,49 @@ export function AttachmentPreviewModal({
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const { width } = useWindowDimensions();
|
||||
// An image the platform can't decode would stay blank; fall back to the
|
||||
// external viewer instead. Keyed by URI so the next item starts clean.
|
||||
const [failedImageUri, setFailedImageUri] = React.useState<string | null>(null);
|
||||
// Which viewers failed for the open file, keyed by its URI so the next one
|
||||
// starts clean: React Native's Image falls back to the WebView viewer, and
|
||||
// a WebView that can't decode the file falls back to another app.
|
||||
const [failed, setFailed] = React.useState<{ uri: string; native?: boolean; web?: boolean } | null>(null);
|
||||
const markFailed = React.useCallback((uri: string | undefined, which: 'native' | 'web') => {
|
||||
if (!uri) return;
|
||||
setFailed((prev) => ({ ...(prev?.uri === uri ? prev : {}), uri, [which]: true }));
|
||||
}, []);
|
||||
// The image fits the area it is shown in (a pane, a landscape window),
|
||||
// not a box derived from the window width that ran past the screen.
|
||||
const [imageArea, setImageArea] = React.useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
const emlEmail = React.useMemo<Email | null>(() => {
|
||||
if (!item?.eml) return null;
|
||||
const docEmail = React.useMemo<Email | null>(() => {
|
||||
if (!item || (!item.eml && item.html == null)) return null;
|
||||
return {
|
||||
id: `eml-preview-${item.name}`,
|
||||
id: `file-preview-${item.kind}-${item.fileUri ?? item.name}`,
|
||||
threadId: '',
|
||||
mailboxIds: {},
|
||||
keywords: {},
|
||||
size: 0,
|
||||
receivedAt: item.eml.date ?? new Date().toISOString(),
|
||||
receivedAt: item.eml?.date ?? new Date().toISOString(),
|
||||
hasAttachment: false,
|
||||
subject: item.eml.subject,
|
||||
subject: item.eml?.subject ?? item.name,
|
||||
};
|
||||
}, [item]);
|
||||
|
||||
const imageFailed = item?.kind === 'image' && !!item.fileUri && failedImageUri === item.fileUri;
|
||||
const failure = item?.fileUri && failed?.uri === item.fileUri ? failed : null;
|
||||
// Android's WebView zooms; React Native's Image there doesn't. iOS zooms
|
||||
// the native image in its ScrollView. Each side's gaps go to the other.
|
||||
const imageInWeb = item?.kind === 'image' && !!item.viewerUri && !failure?.web && (
|
||||
failure?.native
|
||||
|| (Platform.OS === 'android' ? !needsNativeImageDecoder(item) : needsWebImageViewer(item))
|
||||
);
|
||||
const imageFailed = item?.kind === 'image' && (
|
||||
!!failure?.web && (!!failure.native || !item.viewerUri || needsWebImageViewer(item))
|
||||
|| (!!failure?.native && !item.viewerUri)
|
||||
);
|
||||
const mediaFailed = (item?.kind === 'video' || item?.kind === 'audio') && !!failure?.web;
|
||||
const pdfInline = item?.kind === 'pdf' && Platform.OS === 'ios' && !!item.fileUri;
|
||||
// The fallback screen has its own Open button; the header adds one elsewhere.
|
||||
const showsFallback = !!item && (item.kind === 'none' || (item.kind === 'pdf' && !pdfInline) || imageFailed);
|
||||
const showsFallback = !!item && (
|
||||
item.kind === 'none' || (item.kind === 'pdf' && !pdfInline) || imageFailed || mediaFailed
|
||||
);
|
||||
|
||||
const renderFallback = (message: string) => (
|
||||
<View style={styles.centered}>
|
||||
@@ -92,6 +149,13 @@ export function AttachmentPreviewModal({
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderDocument = (header?: React.ReactNode) => (
|
||||
<ScrollView style={styles.flex}>
|
||||
{header}
|
||||
{docEmail && <EmailBodyView email={docEmail} bodyOverride={{ html: item?.html ?? item?.eml?.html, text: item?.eml?.text }} />}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
const renderBody = () => {
|
||||
if (loading || !item) {
|
||||
return (
|
||||
@@ -105,6 +169,16 @@ export function AttachmentPreviewModal({
|
||||
if (imageFailed) {
|
||||
return renderFallback(t('email_viewer.preview.unsupported', 'No preview for this file type.'));
|
||||
}
|
||||
if (imageInWeb && item.viewerUri) {
|
||||
return (
|
||||
<ViewerWebView
|
||||
key={item.viewerUri}
|
||||
uri={item.viewerUri}
|
||||
background={c.background}
|
||||
onFailed={() => markFailed(item.fileUri, 'web')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.flex}
|
||||
@@ -124,10 +198,23 @@ export function AttachmentPreviewModal({
|
||||
: { width: width - spacing.lg * 2, height: width * 1.2 }}
|
||||
resizeMode="contain"
|
||||
accessibilityLabel={item.name}
|
||||
onError={() => setFailedImageUri(item.fileUri ?? null)}
|
||||
onError={() => markFailed(item.fileUri, 'native')}
|
||||
/>
|
||||
</ScrollView>
|
||||
);
|
||||
case 'video':
|
||||
case 'audio':
|
||||
if (mediaFailed || !item.viewerUri) {
|
||||
return renderFallback(t('email_viewer.preview.media_unsupported', "This file can't be played here."));
|
||||
}
|
||||
return (
|
||||
<ViewerWebView
|
||||
key={item.viewerUri}
|
||||
uri={item.viewerUri}
|
||||
background={item.kind === 'video' ? '#000' : c.background}
|
||||
onFailed={() => markFailed(item.fileUri, 'web')}
|
||||
/>
|
||||
);
|
||||
case 'pdf':
|
||||
if (pdfInline && item.fileUri) {
|
||||
return (
|
||||
@@ -150,21 +237,16 @@ export function AttachmentPreviewModal({
|
||||
<Text selectable style={styles.text}>{item.text ?? ''}</Text>
|
||||
</ScrollView>
|
||||
);
|
||||
case 'html':
|
||||
case 'markdown':
|
||||
return renderDocument();
|
||||
case 'eml':
|
||||
return (
|
||||
<ScrollView style={styles.flex}>
|
||||
<View style={styles.emlHeader}>
|
||||
<Text style={styles.emlSubject}>{item.eml?.subject || t('email_viewer.no_subject', '(No Subject)')}</Text>
|
||||
{item.eml?.from ? <Text style={styles.emlMeta}>{item.eml.from}</Text> : null}
|
||||
{item.eml?.date ? <Text style={styles.emlMeta}>{item.eml.date}</Text> : null}
|
||||
</View>
|
||||
{emlEmail && (
|
||||
<EmailBodyView
|
||||
email={emlEmail}
|
||||
bodyOverride={{ html: item.eml?.html, text: item.eml?.text }}
|
||||
/>
|
||||
)}
|
||||
</ScrollView>
|
||||
return renderDocument(
|
||||
<View style={styles.emlHeader}>
|
||||
<Text style={styles.emlSubject}>{item.eml?.subject || t('email_viewer.no_subject', '(No Subject)')}</Text>
|
||||
{item.eml?.from ? <Text style={styles.emlMeta}>{item.eml.from}</Text> : null}
|
||||
{item.eml?.date ? <Text style={styles.emlMeta}>{item.eml.date}</Text> : null}
|
||||
</View>,
|
||||
);
|
||||
default:
|
||||
return renderFallback(item.notice ?? t('email_viewer.preview.unsupported', 'No preview for this file type.'));
|
||||
@@ -229,6 +311,8 @@ export function AttachmentPreviewModal({
|
||||
);
|
||||
}
|
||||
|
||||
const viewerStyles = StyleSheet.create({ fill: { flex: 1 } });
|
||||
|
||||
function makeStyles(c: ThemePalette) {
|
||||
return StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: c.background },
|
||||
|
||||
@@ -15,9 +15,10 @@ import {
|
||||
attachmentKind, realAttachments, requestListAttachments, shortAttachmentName,
|
||||
type AttachmentKind, type LoadListAttachments,
|
||||
} from '../../lib/list-attachments';
|
||||
import { getAttachmentDisplayName, previewKindFor } from '../../lib/attachment-display';
|
||||
import { cacheBlobFile, downloadAttachment, fetchBlobBytes, shareAttachment, shareLocalFile } from '../../lib/email-export';
|
||||
import { AttachmentPreviewModal, type PreviewItem } from './AttachmentPreviewModal';
|
||||
import { getAttachmentDisplayName } from '../../lib/attachment-display';
|
||||
import { attachmentSaveName, downloadAttachment } from '../../lib/email-export';
|
||||
import { AttachmentPreviewModal } from './AttachmentPreviewModal';
|
||||
import { useFilePreview } from './use-file-preview';
|
||||
|
||||
/** How many chips a row shows before collapsing the rest into a count. */
|
||||
const MAX_CHIPS = 2;
|
||||
@@ -124,26 +125,16 @@ export interface ListAttachmentOpenerHandle {
|
||||
open: (email: Email, attachment: Attachment, accountId?: string) => void;
|
||||
}
|
||||
|
||||
interface OpenTarget {
|
||||
email: Email;
|
||||
attachment: Attachment;
|
||||
accountId?: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a list-row attachment the way the reader's chips do: the "attachment
|
||||
* click action" setting either saves it or previews it in-app (images, PDFs,
|
||||
* text), and anything that can't be previewed goes to the platform viewer.
|
||||
* Holds the preview modal, so the list itself never re-renders for it.
|
||||
* click action" setting either saves it or previews it in-app, and anything
|
||||
* that can't be previewed goes to another app. Holds the preview modal, so
|
||||
* the list itself never re-renders for it.
|
||||
*/
|
||||
export const ListAttachmentOpener = React.forwardRef<ListAttachmentOpenerHandle>(
|
||||
function ListAttachmentOpener(_props, ref) {
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const [preview, setPreview] = React.useState<PreviewItem | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const target = React.useRef<OpenTarget | null>(null);
|
||||
const preview = useFilePreview();
|
||||
const busy = React.useRef(false);
|
||||
|
||||
const run = React.useCallback(async (fn: () => Promise<void>) => {
|
||||
@@ -161,59 +152,27 @@ export const ListAttachmentOpener = React.forwardRef<ListAttachmentOpenerHandle>
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const openExternal = React.useCallback((it: OpenTarget) => run(
|
||||
() => shareAttachment(it.attachment.blobId, it.attachment.name, it.attachment.type, it.email, it.accountId),
|
||||
), [run]);
|
||||
|
||||
const { open: openPreview } = preview;
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
open: (email, attachment, accountId) => {
|
||||
if (busy.current) return;
|
||||
const name = getAttachmentDisplayName(attachment.name, attachment.type);
|
||||
const type = attachment.type || 'application/octet-stream';
|
||||
const it: OpenTarget = { email, attachment, accountId, name, type };
|
||||
target.current = it;
|
||||
|
||||
if (useSettingsStore.getState().mailAttachmentAction === 'download') {
|
||||
void run(() => downloadAttachment(attachment.blobId, attachment.name, attachment.type, email, accountId));
|
||||
return;
|
||||
}
|
||||
const kind = previewKindFor({ name, type });
|
||||
if (kind !== 'image' && kind !== 'pdf' && kind !== 'text') {
|
||||
void openExternal(it);
|
||||
return;
|
||||
}
|
||||
setPreview(null);
|
||||
setLoading(true);
|
||||
void run(async () => {
|
||||
try {
|
||||
if (kind === 'text') {
|
||||
const bytes = await fetchBlobBytes(attachment.blobId, attachment.name, attachment.type, accountId);
|
||||
setPreview({ kind, name, mimeType: type, text: new TextDecoder('utf-8').decode(bytes) });
|
||||
} else {
|
||||
const file = await cacheBlobFile(attachment.blobId, name, type, accountId);
|
||||
setPreview({ kind, name, mimeType: type, fileUri: file.uri });
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
void run(() => openPreview({
|
||||
from: 'blob',
|
||||
blobId: attachment.blobId,
|
||||
accountId,
|
||||
email,
|
||||
name: getAttachmentDisplayName(attachment.name, attachment.type),
|
||||
type: attachment.type || 'application/octet-stream',
|
||||
size: attachment.size,
|
||||
saveName: attachmentSaveName(email, attachment.name, attachment.type),
|
||||
}));
|
||||
},
|
||||
}), [run, openExternal]);
|
||||
}), [run, openPreview]);
|
||||
|
||||
return (
|
||||
<AttachmentPreviewModal
|
||||
item={preview}
|
||||
loading={loading}
|
||||
onClose={() => { setPreview(null); setLoading(false); }}
|
||||
onOpenExternal={() => { if (target.current) void openExternal(target.current); }}
|
||||
onShare={() => {
|
||||
const it = target.current;
|
||||
if (!it || !preview?.fileUri) return;
|
||||
const { File } = require('expo-file-system') as typeof import('expo-file-system');
|
||||
void shareLocalFile(new File(preview.fileUri), it.type, it.name, { forceSheet: true }).catch(() => undefined);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return <AttachmentPreviewModal {...preview.modalProps} />;
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import React from 'react';
|
||||
import { Alert, Platform } from 'react-native';
|
||||
import { File } from 'expo-file-system';
|
||||
import type { Email } from '../../api/types';
|
||||
import {
|
||||
cachePreviewFile, copyPreviewFile, discardPreviewFile, saveLocalFileCopy, shareAttachment,
|
||||
shareBytes, shareLocalFile, writePreviewFile,
|
||||
} from '../../lib/email-export';
|
||||
import {
|
||||
exceedsPreviewLimit, inAppPreviewKind, type FilePreviewOptions,
|
||||
} from '../../lib/file-preview';
|
||||
import type { PreviewKind } from '../../lib/file-preview';
|
||||
import { markdownToHtml } from '../../lib/markdown-html';
|
||||
import { imageViewerHtml, mediaViewerHtml, type ViewerColors } from '../../lib/preview-viewer-html';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useColors, useResolvedTheme } from '../../theme/colors';
|
||||
import type { PreviewItem } from './AttachmentPreviewModal';
|
||||
import { emlPreviewFromBytes } from './use-body-override';
|
||||
|
||||
export const PREVIEW_OPTIONS: FilePreviewOptions = { platform: Platform.OS === 'ios' ? 'ios' : 'android' };
|
||||
|
||||
interface PreviewSourceBase {
|
||||
/** Display name, shown in the header and given to the cached copy. */
|
||||
name: string;
|
||||
type: string;
|
||||
size?: number | null;
|
||||
/** Name for "Download" (the attachment filename template); defaults to `name`. */
|
||||
saveName?: string;
|
||||
}
|
||||
|
||||
/** Where a previewed file comes from. */
|
||||
export type PreviewSource = PreviewSourceBase & (
|
||||
| { from: 'blob'; blobId: string; accountId?: string; email?: Email | null }
|
||||
| { from: 'bytes'; bytes: Uint8Array }
|
||||
| { from: 'local'; uri: string }
|
||||
);
|
||||
|
||||
/** The preview a tap on `source` opens, or 'none' when it goes to another app. */
|
||||
export function previewKindOf(source: Pick<PreviewSource, 'name' | 'type' | 'size'>): PreviewKind {
|
||||
return inAppPreviewKind(source, PREVIEW_OPTIONS);
|
||||
}
|
||||
|
||||
/** Copy the source into a preview folder of its own (see cachePreviewFile). */
|
||||
async function materialize(source: PreviewSource): Promise<File> {
|
||||
switch (source.from) {
|
||||
case 'blob':
|
||||
return cachePreviewFile(source.blobId, source.name, source.type, source.accountId);
|
||||
case 'bytes':
|
||||
return writePreviewFile(source.bytes, source.name, source.type);
|
||||
case 'local':
|
||||
return copyPreviewFile(source.uri, source.name, source.type);
|
||||
}
|
||||
}
|
||||
|
||||
/** Hand a file the app doesn't show to the app that owns its type ("Open with"). */
|
||||
function handOff(source: PreviewSource): Promise<void> {
|
||||
switch (source.from) {
|
||||
case 'blob':
|
||||
return shareAttachment(source.blobId, source.name, source.type, source.email, source.accountId);
|
||||
case 'bytes':
|
||||
return shareBytes(source.bytes, source.name, source.type);
|
||||
case 'local':
|
||||
// A copy, so the cleanup after the hand-off never removes the original.
|
||||
return shareLocalFile(copyPreviewFile(source.uri, source.name, source.type), source.type, source.name);
|
||||
}
|
||||
}
|
||||
|
||||
// The viewer page sits next to the file it shows (see preview-viewer-html).
|
||||
function writeViewer(local: File, html: string): string {
|
||||
const page = new File(local.parentDirectory, '__preview.html');
|
||||
if (page.exists) page.delete();
|
||||
page.create();
|
||||
page.write(html);
|
||||
return page.uri;
|
||||
}
|
||||
|
||||
async function buildItem(
|
||||
kind: Exclude<PreviewKind, 'none'>,
|
||||
source: PreviewSource,
|
||||
local: File,
|
||||
tooLarge: string,
|
||||
colors: ViewerColors,
|
||||
): Promise<PreviewItem> {
|
||||
const base = { name: source.name, mimeType: source.type };
|
||||
// The source may not know its size; the copy does.
|
||||
if (exceedsPreviewLimit(kind, local.size)) return { ...base, kind: 'none', notice: tooLarge };
|
||||
switch (kind) {
|
||||
case 'text':
|
||||
return { ...base, kind, text: await local.text() };
|
||||
case 'markdown':
|
||||
return { ...base, kind, html: markdownToHtml(await local.text()) };
|
||||
case 'html':
|
||||
return { ...base, kind, html: await local.text() };
|
||||
case 'eml':
|
||||
return { ...base, kind, eml: await emlPreviewFromBytes(await local.bytes()) };
|
||||
case 'image':
|
||||
return { ...base, kind, fileUri: local.uri, viewerUri: writeViewer(local, imageViewerHtml(local.name, colors)) };
|
||||
case 'video':
|
||||
case 'audio':
|
||||
return { ...base, kind, fileUri: local.uri, viewerUri: writeViewer(local, mediaViewerHtml(kind, local.name, colors)) };
|
||||
default:
|
||||
return { ...base, kind, fileUri: local.uri };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One in-app preview: picks how a file is shown, downloads it once into a
|
||||
* temp folder of its own, and serves Open with, Share and Download from that
|
||||
* copy. Files the app can't show go straight to another app. The copy is
|
||||
* deleted when the preview closes or the next one opens, unless another app
|
||||
* got it, which may still be reading it; the stale-export sweep removes
|
||||
* those. Used by every place that opens a file: the reader's chips, the
|
||||
* list's chips, the Files tab and compose.
|
||||
*
|
||||
* Render `<AttachmentPreviewModal {...preview.modalProps} />` next to it.
|
||||
*/
|
||||
export function useFilePreview(opts: {
|
||||
/** Called when the preview closes: the close button, or a download that failed. */
|
||||
onClose?: () => void;
|
||||
} = {}) {
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const c = useColors();
|
||||
const scheme = useResolvedTheme();
|
||||
// Read when a preview is built; the viewer page keeps the theme it opened with.
|
||||
const viewerColors: ViewerColors = { scheme, background: c.background, text: c.text, textMuted: c.textMuted, surface: c.surface };
|
||||
const colors = React.useRef(viewerColors);
|
||||
colors.current = viewerColors;
|
||||
const onCloseRef = React.useRef(opts.onClose);
|
||||
onCloseRef.current = opts.onClose;
|
||||
const [item, setItem] = React.useState<PreviewItem | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [title, setTitle] = React.useState('');
|
||||
const source = React.useRef<PreviewSource | null>(null);
|
||||
const session = React.useRef<{ file: File; handedOff: boolean } | null>(null);
|
||||
// Bumped whenever a preview ends, so a download that finishes after its
|
||||
// preview was closed is dropped instead of reopening it.
|
||||
const seq = React.useRef(0);
|
||||
const busy = React.useRef(false);
|
||||
|
||||
const end = React.useCallback(() => {
|
||||
seq.current += 1;
|
||||
const open = session.current;
|
||||
session.current = null;
|
||||
if (open && !open.handedOff) discardPreviewFile(open.file);
|
||||
}, []);
|
||||
React.useEffect(() => end, [end]);
|
||||
|
||||
/** Close without telling the owner (it closed the preview itself). */
|
||||
const reset = React.useCallback(() => {
|
||||
end();
|
||||
source.current = null;
|
||||
setItem(null);
|
||||
setLoading(false);
|
||||
}, [end]);
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
reset();
|
||||
onCloseRef.current?.();
|
||||
}, [reset]);
|
||||
|
||||
/**
|
||||
* Show `src` in the preview, or hand it to another app when the preview
|
||||
* can't show it. The promise settles once that hand-off is done (and
|
||||
* rejects when it fails); a preview resolves at once.
|
||||
*/
|
||||
const open = React.useCallback((src: PreviewSource): Promise<void> => {
|
||||
const kind = previewKindOf(src);
|
||||
if (kind === 'none') return handOff(src);
|
||||
end();
|
||||
source.current = src;
|
||||
const mine = seq.current;
|
||||
const current = () => mine === seq.current;
|
||||
setTitle(src.name);
|
||||
setItem(null);
|
||||
setLoading(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const local = await materialize(src);
|
||||
if (!current()) {
|
||||
discardPreviewFile(local);
|
||||
return;
|
||||
}
|
||||
session.current = { file: local, handedOff: false };
|
||||
const next = await buildItem(
|
||||
kind, src, local, t('files.preview_too_large', 'This file is too large to preview.'), colors.current,
|
||||
);
|
||||
if (current()) setItem(next);
|
||||
} catch (e) {
|
||||
if (!current()) return;
|
||||
close();
|
||||
Alert.alert(t('files.preview_error', 'Failed to load preview'), e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
if (current()) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return Promise.resolve();
|
||||
}, [end, close, t]);
|
||||
|
||||
// The preview's own buttons, all on the downloaded copy.
|
||||
const act = React.useCallback((run: (local: File, src: PreviewSource) => Promise<void>, handsOff: boolean, failTitle: string) => {
|
||||
const open = session.current;
|
||||
const src = source.current;
|
||||
if (!open || !src || busy.current) return;
|
||||
busy.current = true;
|
||||
// Another app may keep reading the file after the sheet or viewer returns.
|
||||
if (handsOff) open.handedOff = true;
|
||||
run(open.file, src)
|
||||
.catch((e) => Alert.alert(failTitle, e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => { busy.current = false; });
|
||||
}, []);
|
||||
|
||||
const modalProps = React.useMemo(() => ({
|
||||
item,
|
||||
loading,
|
||||
title,
|
||||
onClose: close,
|
||||
onOpenExternal: () => act(
|
||||
(local, src) => shareLocalFile(local, src.type, src.name, { keep: true }),
|
||||
true,
|
||||
t('files.open_error', 'Could not open the file'),
|
||||
),
|
||||
onShare: () => act(
|
||||
(local, src) => shareLocalFile(local, src.type, src.name, { forceSheet: true, keep: true }),
|
||||
true,
|
||||
t('files.share_file_error', 'Could not share the file'),
|
||||
),
|
||||
onDownload: () => act(
|
||||
(local, src) => saveLocalFileCopy(local, src.type, src.saveName ?? src.name),
|
||||
false,
|
||||
t('files.download_error', 'Failed to download'),
|
||||
),
|
||||
}), [item, loading, title, close, act, t]);
|
||||
|
||||
return { open, close, reset, modalProps };
|
||||
}
|
||||
@@ -1,24 +1,13 @@
|
||||
import React from 'react';
|
||||
import { Alert, Platform } from 'react-native';
|
||||
import type { File } from 'expo-file-system';
|
||||
import type { FileNode } from '../../api/types';
|
||||
import {
|
||||
cachePreviewFile, discardPreviewFile, saveLocalFileCopy, shareLocalFile,
|
||||
} from '../../lib/email-export';
|
||||
import {
|
||||
exceedsPreviewLimit, inAppPreviewKind, type FilePreviewOptions,
|
||||
} from '../../lib/file-preview';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { AttachmentPreviewModal, type PreviewItem } from '../email/AttachmentPreviewModal';
|
||||
import { emlPreviewFromBytes } from '../email/use-body-override';
|
||||
|
||||
const PREVIEW_OPTIONS: FilePreviewOptions = { inlinePdf: Platform.OS === 'ios' };
|
||||
import { AttachmentPreviewModal } from '../email/AttachmentPreviewModal';
|
||||
import { previewKindOf, useFilePreview } from '../email/use-file-preview';
|
||||
|
||||
export type PreviewableFileNode = Pick<FileNode, 'name' | 'type' | 'size' | 'blobId' | 'accountId'>;
|
||||
|
||||
/** Whether a tap on this file opens the in-app preview (else the OS chooser, as before). */
|
||||
export function canPreviewInApp(node: PreviewableFileNode): boolean {
|
||||
return !!node.blobId && inAppPreviewKind(node, PREVIEW_OPTIONS) !== 'none';
|
||||
return !!node.blobId && previewKindOf({ name: node.name, type: node.type || '', size: node.size }) !== 'none';
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -30,22 +19,13 @@ interface Props {
|
||||
}
|
||||
|
||||
/**
|
||||
* The Files tab's in-app preview: the reader's attachment preview fed from a
|
||||
* FileNode. The file is downloaded once, with the same authenticated
|
||||
* download as "Download" (token refresh, client certificate), into its own
|
||||
* temp folder; Open with, Share and Download then all use that copy instead
|
||||
* of fetching it again. The copy is deleted when the preview closes, unless
|
||||
* another app got it, in which case the stale-export sweep removes it.
|
||||
* The Files tab's in-app preview: the shared file preview fed from a
|
||||
* FileNode, downloaded with the same authenticated download as "Download"
|
||||
* (token refresh, client certificate).
|
||||
*/
|
||||
export function FilePreviewModal({ file, onClose, docked = false }: Props) {
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const [item, setItem] = React.useState<PreviewItem | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
// The downloaded copy of the open preview.
|
||||
const session = React.useRef<{ file: File; handedOff: boolean } | null>(null);
|
||||
const busy = React.useRef(false);
|
||||
const onCloseRef = React.useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
const preview = useFilePreview({ onClose });
|
||||
const { open, reset } = preview;
|
||||
|
||||
const blobId = file?.blobId ?? null;
|
||||
const accountId = file?.accountId;
|
||||
@@ -55,93 +35,9 @@ export function FilePreviewModal({ file, onClose, docked = false }: Props) {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!blobId) return undefined;
|
||||
let cancelled = false;
|
||||
setItem(null);
|
||||
setLoading(true);
|
||||
void open({ from: 'blob', blobId, accountId, name, type, size });
|
||||
return reset;
|
||||
}, [blobId, accountId, name, type, size, open, reset]);
|
||||
|
||||
const build = async (local: File): Promise<PreviewItem> => {
|
||||
const kind = inAppPreviewKind({ name, type, size }, PREVIEW_OPTIONS);
|
||||
// The node may not report a size; the download does.
|
||||
if (kind === 'none' || exceedsPreviewLimit(kind, local.size)) {
|
||||
return {
|
||||
kind: 'none',
|
||||
name,
|
||||
mimeType: type,
|
||||
notice: kind === 'none' ? undefined : t('files.preview_too_large', 'This file is too large to preview.'),
|
||||
};
|
||||
}
|
||||
switch (kind) {
|
||||
case 'text':
|
||||
return { kind, name, mimeType: type, text: await local.text() };
|
||||
case 'eml':
|
||||
return { kind, name, mimeType: type, eml: await emlPreviewFromBytes(await local.bytes()) };
|
||||
default:
|
||||
return { kind, name, mimeType: type, fileUri: local.uri };
|
||||
}
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const local = await cachePreviewFile(blobId, name, type, accountId);
|
||||
if (cancelled) {
|
||||
discardPreviewFile(local);
|
||||
return;
|
||||
}
|
||||
session.current = { file: local, handedOff: false };
|
||||
const next = await build(local);
|
||||
if (!cancelled) setItem(next);
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
Alert.alert(t('files.preview_error', 'Failed to load preview'), e instanceof Error ? e.message : String(e));
|
||||
onCloseRef.current();
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
const open = session.current;
|
||||
session.current = null;
|
||||
if (open && !open.handedOff) discardPreviewFile(open.file);
|
||||
setItem(null);
|
||||
setLoading(false);
|
||||
};
|
||||
}, [blobId, accountId, name, type, size, t]);
|
||||
|
||||
const act = (run: (local: File) => Promise<void>, handsOff: boolean, failTitle: string) => {
|
||||
const open = session.current;
|
||||
if (!open || busy.current) return;
|
||||
busy.current = true;
|
||||
// Another app may keep reading the file after the sheet or viewer returns.
|
||||
if (handsOff) open.handedOff = true;
|
||||
run(open.file)
|
||||
.catch((e) => Alert.alert(failTitle, e instanceof Error ? e.message : String(e)))
|
||||
.finally(() => { busy.current = false; });
|
||||
};
|
||||
|
||||
return (
|
||||
<AttachmentPreviewModal
|
||||
item={item}
|
||||
loading={loading}
|
||||
title={name}
|
||||
docked={docked}
|
||||
onClose={onClose}
|
||||
onOpenExternal={() => act(
|
||||
(local) => shareLocalFile(local, type, name, { keep: true }),
|
||||
true,
|
||||
t('files.open_error', 'Could not open the file'),
|
||||
)}
|
||||
onShare={() => act(
|
||||
(local) => shareLocalFile(local, type, name, { forceSheet: true, keep: true }),
|
||||
true,
|
||||
t('files.share_file_error', 'Could not share the file'),
|
||||
)}
|
||||
onDownload={() => act(
|
||||
(local) => saveLocalFileCopy(local, type),
|
||||
false,
|
||||
t('files.download_error', 'Failed to download'),
|
||||
)}
|
||||
/>
|
||||
);
|
||||
return <AttachmentPreviewModal {...preview.modalProps} docked={docked} />;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Modal,
|
||||
ScrollView,
|
||||
ViewStyle,
|
||||
type LayoutChangeEvent,
|
||||
} from 'react-native';
|
||||
import { FlaskConical, Lock, ChevronDown, ChevronRight, Check, ExternalLink, type AppIcon } from '../icons';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
|
||||
@@ -68,6 +69,9 @@ export function SettingsSection({
|
||||
);
|
||||
}
|
||||
|
||||
/** Narrowest label column before a row's control moves under the label. */
|
||||
const STACK_BELOW = 140;
|
||||
|
||||
// The label of the SettingItem a control sits in. A switch has no text of
|
||||
// its own, so without it a screen reader only announces "switch, off".
|
||||
const SettingLabelContext = React.createContext<string | undefined>(undefined);
|
||||
@@ -95,10 +99,27 @@ export function SettingItem({
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const highlight = useSearchHighlight(label);
|
||||
const rowStyle = [styles.settingItem, !noBorder && styles.settingItemBorder, locked && styles.locked, highlight.highlighted && styles.searchHighlight];
|
||||
// The control sits on the right. When that leaves the label column narrower
|
||||
// than STACK_BELOW (a long translation, two buttons), the control moves
|
||||
// under the label, as the webmail stacks it on a phone. A new row width
|
||||
// (rotation, split view) measures again.
|
||||
const [stacked, setStacked] = useState(false);
|
||||
const rowWidth = React.useRef(0);
|
||||
const onRowLayout = (e: LayoutChangeEvent) => {
|
||||
highlight.onLayout(e);
|
||||
const width = e.nativeEvent.layout.width;
|
||||
if (Math.abs(width - rowWidth.current) > 1) {
|
||||
rowWidth.current = width;
|
||||
setStacked(false);
|
||||
}
|
||||
};
|
||||
const onContentLayout = (e: LayoutChangeEvent) => {
|
||||
if (!stacked && children && e.nativeEvent.layout.width < STACK_BELOW) setStacked(true);
|
||||
};
|
||||
const rowStyle = [styles.settingItem, stacked && styles.settingItemStacked, !noBorder && styles.settingItemBorder, locked && styles.locked, highlight.highlighted && styles.searchHighlight];
|
||||
const body = (
|
||||
<>
|
||||
<View style={styles.settingContent}>
|
||||
<View style={[styles.settingContent, stacked && styles.settingContentStacked]} onLayout={onContentLayout}>
|
||||
<View style={styles.settingLabelRow}>
|
||||
<Text style={styles.settingLabel}>{label}</Text>
|
||||
{locked && <Lock size={12} color={c.mutedForeground} />}
|
||||
@@ -119,7 +140,7 @@ export function SettingItem({
|
||||
return (
|
||||
<Pressable
|
||||
ref={highlight.ref}
|
||||
onLayout={highlight.onLayout}
|
||||
onLayout={onRowLayout}
|
||||
onPress={onPress}
|
||||
accessibilityRole={link === 'external' ? 'link' : 'button'}
|
||||
style={({ pressed }) => [...rowStyle, pressed && styles.settingItemPressed]}
|
||||
@@ -129,7 +150,7 @@ export function SettingItem({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View ref={highlight.ref} onLayout={highlight.onLayout} style={rowStyle}>
|
||||
<View ref={highlight.ref} onLayout={onRowLayout} style={rowStyle}>
|
||||
{body}
|
||||
</View>
|
||||
);
|
||||
@@ -366,6 +387,16 @@ function makeStyles(c: ThemePalette) {
|
||||
settingContent: {
|
||||
flex: 1,
|
||||
},
|
||||
// The control under the label instead of beside it (see SettingItem).
|
||||
settingItemStacked: {
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
settingContentStacked: {
|
||||
flex: 0,
|
||||
alignSelf: 'stretch',
|
||||
},
|
||||
settingLabelRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { I18nManager } from 'react-native';
|
||||
import { getLocales } from 'expo-localization';
|
||||
import { formatMessage, type MessageParams } from './format';
|
||||
import { setDeviceLocales } from '../lib/intl-locale';
|
||||
|
||||
// English is the fallback for every lookup, so it is loaded eagerly. The other
|
||||
// catalogs are loaded on first use; see CATALOG_LOADERS below.
|
||||
@@ -212,6 +213,20 @@ export function detectDeviceLocale(): LocaleCode {
|
||||
return 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand the device locales to the date formatters (lib/intl-locale), so an
|
||||
* en-AU phone formats dates as en-AU while reading the English catalog.
|
||||
*/
|
||||
export function registerDeviceLocales(): void {
|
||||
try {
|
||||
setDeviceLocales(getLocales().map((l) => ({ appLocale: resolveLocaleTag(l), languageTag: l.languageTag })));
|
||||
} catch {
|
||||
setDeviceLocales([]);
|
||||
}
|
||||
}
|
||||
|
||||
registerDeviceLocales();
|
||||
|
||||
function lookup(dict: Dictionary, key: string): string | undefined {
|
||||
let current: unknown = dict;
|
||||
for (const part of key.split('.')) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getAttachmentDisplayName, visibleAttachments, previewKindFor, isReportPart, isRfc822Attachment,
|
||||
getAttachmentDisplayName, visibleAttachments, isReportPart, isRfc822Attachment,
|
||||
isEmbeddedInBody,
|
||||
} from '../attachment-display';
|
||||
import { buildForwardAsAttachmentPayload } from '../forward-as-attachment';
|
||||
@@ -93,16 +93,8 @@ describe('isEmbeddedInBody', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('previewKindFor / classification', () => {
|
||||
it('classifies previewable types and refuses script-bearing ones', () => {
|
||||
expect(previewKindFor({ type: 'image/jpeg', name: 'a.jpg' })).toBe('image');
|
||||
expect(previewKindFor({ type: 'application/pdf', name: 'a.pdf' })).toBe('pdf');
|
||||
expect(previewKindFor({ type: 'text/plain', name: 'a.txt' })).toBe('text');
|
||||
expect(previewKindFor({ type: 'application/octet-stream', name: 'notes.md' })).toBe('text');
|
||||
expect(previewKindFor({ type: 'message/rfc822', name: 'fwd.eml' })).toBe('eml');
|
||||
expect(previewKindFor({ type: 'text/html', name: 'a.html' })).toBe('none');
|
||||
expect(previewKindFor({ type: 'image/svg+xml', name: 'a.svg' })).toBe('none');
|
||||
expect(previewKindFor({ type: 'application/zip', name: 'a.zip' })).toBe('none');
|
||||
describe('report and embedded-message parts', () => {
|
||||
it('recognises DSN reports and .eml parts', () => {
|
||||
expect(isReportPart('message/delivery-status')).toBe(true);
|
||||
expect(isRfc822Attachment({ type: 'application/octet-stream', name: 'x.eml' })).toBe(true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { compareCalendars, moveCalendar, sortCalendars, sortOrderUpdates } from '../calendar-sort';
|
||||
import type { Calendar } from '../../api/types';
|
||||
|
||||
function cal(overrides: Partial<Calendar>): Calendar {
|
||||
return { id: 'c', name: 'Cal', sortOrder: 0, ...overrides };
|
||||
}
|
||||
|
||||
const names = (list: Calendar[]) => list.map((c) => c.name);
|
||||
const ids = (list: Calendar[]) => list.map((c) => c.id);
|
||||
|
||||
describe('sortCalendars', () => {
|
||||
it('orders by sortOrder, whatever order the server listed them in', () => {
|
||||
const list = [
|
||||
cal({ id: 'b', name: 'Default', sortOrder: 20 }),
|
||||
cal({ id: 'n', name: 'Holidays', sortOrder: 30 }),
|
||||
cal({ id: 'e', name: 'Work', sortOrder: 1 }),
|
||||
cal({ id: 'd', name: 'Personal', sortOrder: 10 }),
|
||||
];
|
||||
expect(names(sortCalendars(list))).toEqual(['Work', 'Personal', 'Default', 'Holidays']);
|
||||
});
|
||||
|
||||
it('sorts equal sortOrder by name, ignoring case and accents, numbers by value', () => {
|
||||
const list = [
|
||||
cal({ id: '1', name: 'zeta' }),
|
||||
cal({ id: '2', name: 'Team 10' }),
|
||||
cal({ id: '3', name: 'Éclair' }),
|
||||
cal({ id: '4', name: 'alpha' }),
|
||||
cal({ id: '5', name: 'Team 2' }),
|
||||
cal({ id: '6', name: 'Beta' }),
|
||||
];
|
||||
expect(names(sortCalendars(list))).toEqual(['alpha', 'Beta', 'Éclair', 'Team 2', 'Team 10', 'zeta']);
|
||||
});
|
||||
|
||||
it('treats a missing sortOrder as 0', () => {
|
||||
const list = [cal({ id: 'a', name: 'B', sortOrder: 1 }), cal({ id: 'b', name: 'A', sortOrder: undefined })];
|
||||
expect(names(sortCalendars(list))).toEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
it('keeps each account together, in the order the accounts came', () => {
|
||||
const list = [
|
||||
cal({ id: 'own-2', name: 'Own 2', sortOrder: 2 }),
|
||||
cal({ id: 'own-1', name: 'Own 1', sortOrder: 1 }),
|
||||
cal({ id: 'x:b', name: 'Shared B', sortOrder: 0, isShared: true, accountId: 'x' }),
|
||||
cal({ id: 'y:a', name: 'Other A', sortOrder: 0, isShared: true, accountId: 'y' }),
|
||||
cal({ id: 'x:a', name: 'Shared A', sortOrder: 5, isShared: true, accountId: 'x' }),
|
||||
];
|
||||
expect(ids(sortCalendars(list))).toEqual(['own-1', 'own-2', 'x:b', 'x:a', 'y:a']);
|
||||
});
|
||||
|
||||
it('does not change the list it is given', () => {
|
||||
const list = [cal({ id: 'b', sortOrder: 2 }), cal({ id: 'a', sortOrder: 1 })];
|
||||
sortCalendars(list);
|
||||
expect(ids(list)).toEqual(['b', 'a']);
|
||||
});
|
||||
|
||||
it('compareCalendars puts the lower sortOrder first', () => {
|
||||
expect(compareCalendars(cal({ sortOrder: 1, name: 'Z' }), cal({ sortOrder: 2, name: 'A' }))).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortOrderUpdates', () => {
|
||||
it('numbers the calendars 1, 2, 3 in the given order and skips unchanged ones', () => {
|
||||
const list = [cal({ id: 'a', sortOrder: 1 }), cal({ id: 'b', sortOrder: 2 }), cal({ id: 'c', sortOrder: 3 })];
|
||||
expect(sortOrderUpdates(list, ['a', 'c', 'b'])).toEqual([
|
||||
{ id: 'c', sortOrder: 2 },
|
||||
{ id: 'b', sortOrder: 3 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveCalendar', () => {
|
||||
const list = [
|
||||
cal({ id: 'work', name: 'Work', sortOrder: 1 }),
|
||||
cal({ id: 'home', name: 'Home', sortOrder: 10 }),
|
||||
cal({ id: 'tasks', name: 'Tasks', sortOrder: 15 }),
|
||||
cal({ id: 'holidays', name: 'Holidays', sortOrder: 30 }),
|
||||
cal({ id: 'x:team', name: 'Team', sortOrder: 0, isShared: true, accountId: 'x' }),
|
||||
];
|
||||
|
||||
it('moving up puts the calendar just before the target', () => {
|
||||
expect(moveCalendar(list, 'holidays', 'home')).toEqual(['work', 'holidays', 'home', 'tasks']);
|
||||
});
|
||||
|
||||
it('moving down puts the calendar just after the target', () => {
|
||||
expect(moveCalendar(list, 'work', 'home')).toEqual(['home', 'work', 'tasks', 'holidays']);
|
||||
});
|
||||
|
||||
it('refuses a move across accounts, onto itself or to an unknown id', () => {
|
||||
expect(moveCalendar(list, 'work', 'x:team')).toBeNull();
|
||||
expect(moveCalendar(list, 'work', 'work')).toBeNull();
|
||||
expect(moveCalendar(list, 'work', 'gone')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
initialDraftPartBlobState, liveBlobId, matchDraftParts, rebaseDraftPartBlobs, withSnapshotBody,
|
||||
} from '../draft-part-blobs';
|
||||
import type { OutgoingAttachment } from '../../api/email';
|
||||
|
||||
const inline = (blobId: string, cid: string, name = 'img.png', size = 10): OutgoingAttachment => ({
|
||||
blobId, cid, name, size, type: 'image/png', disposition: 'inline',
|
||||
});
|
||||
const file = (blobId: string, name: string, size: number): OutgoingAttachment => ({
|
||||
blobId, name, size, type: 'application/pdf', disposition: 'attachment',
|
||||
});
|
||||
|
||||
describe('matchDraftParts', () => {
|
||||
it('pairs inline parts by Content-ID, brackets and case aside', () => {
|
||||
const pairs = matchDraftParts(
|
||||
[inline('old-a', 'a@x'), inline('old-b', 'b@x')],
|
||||
[
|
||||
{ blobId: 'new-b', cid: '<B@x>', name: 'img.png', size: 10 },
|
||||
{ blobId: 'new-a', cid: '<a@x>', name: 'img.png', size: 10 },
|
||||
],
|
||||
);
|
||||
expect(pairs).toEqual(new Map([['old-a', 'new-a'], ['old-b', 'new-b']]));
|
||||
});
|
||||
|
||||
it('pairs file attachments by name and size, claiming each new part once', () => {
|
||||
const pairs = matchDraftParts(
|
||||
[file('old-1', 'q.pdf', 5), file('old-2', 'q.pdf', 5), file('old-3', 'r.pdf', 7)],
|
||||
[
|
||||
{ blobId: 'new-r', name: 'r.pdf', size: 7 },
|
||||
{ blobId: 'new-q1', name: 'q.pdf', size: 5 },
|
||||
{ blobId: 'new-q2', name: 'q.pdf', size: 5 },
|
||||
],
|
||||
);
|
||||
expect(pairs.get('old-1')).toBe('new-q1');
|
||||
expect(pairs.get('old-2')).toBe('new-q2');
|
||||
expect(pairs.get('old-3')).toBe('new-r');
|
||||
});
|
||||
|
||||
it('leaves a part it cannot find unpaired', () => {
|
||||
expect(matchDraftParts([file('old', 'gone.pdf', 1)], [{ blobId: 'n', name: 'other.pdf', size: 1 }]).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rebaseDraftPartBlobs (webmail #849, second half)', () => {
|
||||
it('follows the draft across successive saves so no save sends a destroyed part', () => {
|
||||
// Re-opened draft D0 with one inline image and one PDF.
|
||||
let state = initialDraftPartBlobState();
|
||||
const sentAt = () => [inline(liveBlobId(state, 'd0-img'), 'img@x'), file(liveBlobId(state, 'd0-pdf'), 'q.pdf', 5)];
|
||||
|
||||
// Save 1 sends D0's parts (D0 is still alive), creates D1, destroys D0.
|
||||
expect(sentAt().map((a) => a.blobId)).toEqual(['d0-img', 'd0-pdf']);
|
||||
state = rebaseDraftPartBlobs(state, sentAt(), [
|
||||
{ blobId: 'd1-img', cid: '<img@x>', name: 'img.png', size: 10 },
|
||||
{ blobId: 'd1-pdf', name: 'q.pdf', size: 5 },
|
||||
]);
|
||||
// Save 2 / send must reference D1's parts, not D0's dead ones.
|
||||
expect(sentAt().map((a) => a.blobId)).toEqual(['d1-img', 'd1-pdf']);
|
||||
state = rebaseDraftPartBlobs(state, sentAt(), [
|
||||
{ blobId: 'd2-img', cid: '<img@x>', name: 'img.png', size: 10 },
|
||||
{ blobId: 'd2-pdf', name: 'q.pdf', size: 5 },
|
||||
]);
|
||||
expect(sentAt().map((a) => a.blobId)).toEqual(['d2-img', 'd2-pdf']);
|
||||
});
|
||||
|
||||
it('re-points uploads too: an upload expires once nothing references it', () => {
|
||||
let state = initialDraftPartBlobState();
|
||||
const sent = () => [inline(liveBlobId(state, 'd0-img'), 'img@x'), file(liveBlobId(state, 'upload-1'), 'new.pdf', 3)];
|
||||
state = rebaseDraftPartBlobs(state, sent(), [
|
||||
{ blobId: 'd1-img', cid: '<img@x>', name: 'img.png', size: 10 },
|
||||
{ blobId: 'd1-new', name: 'new.pdf', size: 3 },
|
||||
]);
|
||||
expect(sent().map((a) => a.blobId)).toEqual(['d1-img', 'd1-new']);
|
||||
});
|
||||
|
||||
it('leaves the state alone when nothing was sent', () => {
|
||||
const state = initialDraftPartBlobState();
|
||||
expect(rebaseDraftPartBlobs(state, [], [{ blobId: 'p', name: 'a.pdf', size: 1 }])).toBe(state);
|
||||
});
|
||||
|
||||
it('keeps the old id when the read-back came back empty (next save fails loudly, nothing is lost)', () => {
|
||||
const state = initialDraftPartBlobState();
|
||||
const next = rebaseDraftPartBlobs(state, [inline('d0-img', 'img@x')], []);
|
||||
expect(liveBlobId(next, 'd0-img')).toBe('d0-img');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withSnapshotBody', () => {
|
||||
it('replaces only the body of a composer snapshot', () => {
|
||||
const snap = JSON.stringify({ to: [['A', 'a@x', null]], cc: [], bcc: [], subject: 'S', body: '<p>raw</p>', att: ['b1'] });
|
||||
expect(JSON.parse(withSnapshotBody(snap, '<p>normalised</p>'))).toEqual({
|
||||
to: [['A', 'a@x', null]], cc: [], bcc: [], subject: 'S', body: '<p>normalised</p>', att: ['b1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the key order, so an unchanged composer still compares equal', () => {
|
||||
const make = (body: string) => JSON.stringify({ to: [], cc: [], bcc: [], subject: 'S', body, att: [] });
|
||||
expect(withSnapshotBody(make('<p>raw</p>'), '<p>x</p>')).toBe(make('<p>x</p>'));
|
||||
});
|
||||
});
|
||||
@@ -196,3 +196,87 @@ describe('editor page messages', () => {
|
||||
expect((page.selections()[1].payload as { bold: boolean }).bold).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Android pans the editor page's visual viewport to reveal the caret and the
|
||||
// pan outlived the moment: back at the top of the composer the body's first
|
||||
// lines stayed hidden under To/Cc/Subject. The page undoes each pan and hands
|
||||
// the offset to RN instead.
|
||||
describe('editor visual viewport pinning', () => {
|
||||
function boot(pinVisualViewport: boolean | undefined, withVisualViewport = true) {
|
||||
const posted: Array<{ type: string; payload: unknown }> = [];
|
||||
const scrolledIntoView: unknown[] = [];
|
||||
const vvListeners: Record<string, Array<() => void>> = {};
|
||||
const visualViewport = {
|
||||
offsetTop: 0,
|
||||
addEventListener: (name: string, fn: () => void) => { (vvListeners[name] ??= []).push(fn); },
|
||||
};
|
||||
const editor = {
|
||||
innerHTML: '',
|
||||
get textContent() { return this.innerHTML.replace(/<[^>]*>/g, ''); },
|
||||
scrollHeight: 100,
|
||||
querySelector: () => null,
|
||||
setAttribute: () => undefined,
|
||||
focus: () => undefined,
|
||||
addEventListener: () => undefined,
|
||||
// Resetting the pan is what scrolling the page top into view does.
|
||||
scrollIntoView: (opts: unknown) => { scrolledIntoView.push(opts); visualViewport.offsetTop = 0; },
|
||||
};
|
||||
const document = {
|
||||
activeElement: editor as unknown,
|
||||
getElementById: () => editor,
|
||||
addEventListener: () => undefined,
|
||||
queryCommandState: () => false,
|
||||
};
|
||||
const window = {
|
||||
ReactNativeWebView: { postMessage: (msg: string) => posted.push(JSON.parse(msg)) },
|
||||
addEventListener: () => undefined,
|
||||
getSelection: () => null,
|
||||
visualViewport: withVisualViewport ? visualViewport : undefined,
|
||||
};
|
||||
const script = extractScript(buildEditorHtml({
|
||||
initialHtml: '<p><br></p>', placeholder: '', c: LIGHT_COLORS, pinVisualViewport,
|
||||
}));
|
||||
new Function('window', 'document', script)(window, document);
|
||||
const pan = (offsetTop: number) => {
|
||||
visualViewport.offsetTop = offsetTop;
|
||||
(vvListeners.scroll ?? []).forEach((fn) => fn());
|
||||
};
|
||||
const shifts = () => posted.filter((m) => m.type === 'viewportShift').map((m) => m.payload);
|
||||
return { pan, shifts, scrolledIntoView, visualViewport, vvListeners };
|
||||
}
|
||||
|
||||
it('undoes a caret-reveal pan and reports its offset to RN', () => {
|
||||
const page = boot(true);
|
||||
page.pan(18.5);
|
||||
expect(page.visualViewport.offsetTop).toBe(0);
|
||||
expect(page.scrolledIntoView).toEqual([{ block: 'start', inline: 'nearest' }]);
|
||||
expect(page.shifts()).toEqual([18.5]);
|
||||
});
|
||||
|
||||
it('ignores the scroll event its own reset fires', () => {
|
||||
const page = boot(true);
|
||||
page.pan(40);
|
||||
page.pan(0);
|
||||
page.pan(0.2);
|
||||
expect(page.shifts()).toEqual([40]);
|
||||
expect(page.scrolledIntoView).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports each later pan separately', () => {
|
||||
const page = boot(true);
|
||||
page.pan(12);
|
||||
page.pan(30);
|
||||
expect(page.shifts()).toEqual([12, 30]);
|
||||
});
|
||||
|
||||
it('leaves the viewport alone unless asked (iOS, settings editors)', () => {
|
||||
const off = boot(false);
|
||||
expect(off.vvListeners.scroll).toBeUndefined();
|
||||
const unset = boot(undefined);
|
||||
expect(unset.vvListeners.scroll).toBeUndefined();
|
||||
});
|
||||
|
||||
it('survives a WebView without visualViewport', () => {
|
||||
expect(() => boot(true, false)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,29 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
filePreviewKind, inAppPreviewKind, exceedsPreviewLimit, FILE_PREVIEW_MAX_BYTES,
|
||||
needsNativeImageDecoder, needsWebImageViewer, handoffMimeType,
|
||||
} from '../file-preview';
|
||||
|
||||
const IOS = { inlinePdf: true };
|
||||
const ANDROID = { inlinePdf: false };
|
||||
const IOS = { platform: 'ios' } as const;
|
||||
const ANDROID = { platform: 'android' } as const;
|
||||
|
||||
describe('filePreviewKind', () => {
|
||||
it('previews the images React Native decodes, by type or by extension', () => {
|
||||
it('previews common images by type or by extension', () => {
|
||||
expect(filePreviewKind({ name: 'photo.jpg', type: 'image/jpeg' }, ANDROID)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'scan.PNG', type: 'application/octet-stream' }, ANDROID)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'IMG_0001', type: 'image/heic' }, ANDROID)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'anim.webp', type: '' }, ANDROID)).toBe('image');
|
||||
});
|
||||
|
||||
it('hands images it cannot decode to another app', () => {
|
||||
it('draws SVG and ICO images instead of handing them off', () => {
|
||||
expect(filePreviewKind({ name: 'logo.svg', type: 'image/svg+xml' }, IOS)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'logo.svg', type: 'application/octet-stream' }, ANDROID)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'favicon.ico', type: 'image/x-icon' }, ANDROID)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'logo.svgz', type: 'image/svg+xml' }, IOS)).toBe('none');
|
||||
});
|
||||
|
||||
it('shows TIFF only where the platform decodes it', () => {
|
||||
expect(filePreviewKind({ name: 'fax.tiff', type: 'image/tiff' }, IOS)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'fax.tif', type: '' }, IOS)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'fax.tiff', type: 'image/tiff' }, ANDROID)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'raw.cr2', type: 'image/x-canon-cr2' }, ANDROID)).toBe('none');
|
||||
});
|
||||
|
||||
it('keeps script-bearing documents out of the preview', () => {
|
||||
expect(filePreviewKind({ name: 'logo.svg', type: 'image/svg+xml' }, IOS)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'page.html', type: 'text/html' }, IOS)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'page.htm', type: 'application/octet-stream' }, IOS)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'doc', type: 'application/xhtml+xml' }, IOS)).toBe('none');
|
||||
it('renders HTML and Markdown documents', () => {
|
||||
expect(filePreviewKind({ name: 'page.html', type: 'text/html' }, IOS)).toBe('html');
|
||||
expect(filePreviewKind({ name: 'page.htm', type: 'application/octet-stream' }, ANDROID)).toBe('html');
|
||||
expect(filePreviewKind({ name: 'doc', type: 'application/xhtml+xml' }, IOS)).toBe('html');
|
||||
expect(filePreviewKind({ name: 'README.md', type: 'application/octet-stream' }, ANDROID)).toBe('markdown');
|
||||
expect(filePreviewKind({ name: 'notes', type: 'text/markdown' }, ANDROID)).toBe('markdown');
|
||||
});
|
||||
|
||||
it('shows PDFs in-app only where the platform renders them', () => {
|
||||
@@ -32,21 +43,37 @@ describe('filePreviewKind', () => {
|
||||
expect(filePreviewKind({ name: 'report.pdf', type: 'application/pdf' }, ANDROID)).toBe('none');
|
||||
});
|
||||
|
||||
it('shows plain text, markdown and data files as text', () => {
|
||||
it('plays the video formats each platform plays', () => {
|
||||
expect(filePreviewKind({ name: 'clip.mp4', type: 'video/mp4' }, IOS)).toBe('video');
|
||||
expect(filePreviewKind({ name: 'clip.mov', type: 'video/quicktime' }, IOS)).toBe('video');
|
||||
expect(filePreviewKind({ name: 'clip.mp4', type: 'video/mp4' }, ANDROID)).toBe('video');
|
||||
expect(filePreviewKind({ name: 'clip.webm', type: 'video/webm' }, ANDROID)).toBe('video');
|
||||
expect(filePreviewKind({ name: 'clip.mkv', type: 'application/octet-stream' }, ANDROID)).toBe('video');
|
||||
expect(filePreviewKind({ name: 'clip.webm', type: 'video/webm' }, IOS)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'old.avi', type: 'video/x-msvideo' }, ANDROID)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'old.wmv', type: 'video/x-ms-wmv' }, IOS)).toBe('none');
|
||||
});
|
||||
|
||||
it('plays the audio formats each platform plays', () => {
|
||||
expect(filePreviewKind({ name: 'song.mp3', type: 'audio/mpeg' }, IOS)).toBe('audio');
|
||||
expect(filePreviewKind({ name: 'voice.m4a', type: '' }, IOS)).toBe('audio');
|
||||
expect(filePreviewKind({ name: 'memo.ogg', type: 'audio/ogg' }, ANDROID)).toBe('audio');
|
||||
expect(filePreviewKind({ name: 'memo.ogg', type: 'application/octet-stream' }, ANDROID)).toBe('audio');
|
||||
expect(filePreviewKind({ name: 'memo.opus', type: 'audio/ogg' }, IOS)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'call.amr', type: 'audio/amr' }, ANDROID)).toBe('none');
|
||||
});
|
||||
|
||||
it('shows plain text, data and source files as text', () => {
|
||||
expect(filePreviewKind({ name: 'notes.txt', type: 'text/plain' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'README.md', type: 'application/octet-stream' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'data.csv', type: 'text/csv' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'config', type: 'application/json' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'feed', type: 'application/atom+xml' }, ANDROID)).toBe('text');
|
||||
});
|
||||
|
||||
it('shows source code as text, including the script types the reader leaves out', () => {
|
||||
expect(filePreviewKind({ name: 'app.ts', type: 'application/octet-stream' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'main.py', type: '' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'build.sh', type: 'application/x-sh' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'index.js', type: 'text/javascript' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'bundle', type: 'application/javascript' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'Dockerfile', type: '' }, ANDROID)).toBe('text');
|
||||
expect(filePreviewKind({ name: 'invite.ics', type: 'text/calendar' }, IOS)).toBe('text');
|
||||
});
|
||||
|
||||
it('shows saved messages like the reader does', () => {
|
||||
@@ -54,10 +81,21 @@ describe('filePreviewKind', () => {
|
||||
expect(filePreviewKind({ name: 'fwd', type: 'message/rfc822' }, ANDROID)).toBe('eml');
|
||||
});
|
||||
|
||||
it('leaves binaries and office documents to other apps', () => {
|
||||
it('goes by the name when the declared type is a catch-all', () => {
|
||||
expect(filePreviewKind({ name: 'invoice.pdf', type: 'application/x-as400attachment' }, IOS)).toBe('pdf');
|
||||
expect(filePreviewKind({ name: 'photo.jpg', type: 'application/x-as400attachment' }, ANDROID)).toBe('image');
|
||||
expect(filePreviewKind({ name: 'download', type: 'image/png' }, ANDROID)).toBe('image');
|
||||
});
|
||||
|
||||
it('lets a media type decide over the name', () => {
|
||||
expect(filePreviewKind({ name: 'clip.avi', type: 'video/mp4' }, ANDROID)).toBe('video');
|
||||
expect(filePreviewKind({ name: 'cover.mp3', type: 'image/jpeg' }, ANDROID)).toBe('image');
|
||||
});
|
||||
|
||||
it('leaves archives, office documents and binaries to other apps', () => {
|
||||
expect(filePreviewKind({ name: 'archive.zip', type: 'application/zip' }, IOS)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'sheet.xlsx', type: 'application/octet-stream' }, IOS)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'movie.mp4', type: 'video/mp4' }, IOS)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'contract.docx', type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }, ANDROID)).toBe('none');
|
||||
expect(filePreviewKind({ name: 'data.bin', type: undefined }, IOS)).toBe('none');
|
||||
});
|
||||
|
||||
@@ -67,11 +105,26 @@ describe('filePreviewKind', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('image viewer choice', () => {
|
||||
it('sends formats Chromium cannot decode to the native decoder', () => {
|
||||
expect(needsNativeImageDecoder({ name: 'IMG.HEIC', type: '' })).toBe(true);
|
||||
expect(needsNativeImageDecoder({ name: 'x', type: 'image/heif' })).toBe(true);
|
||||
expect(needsNativeImageDecoder({ name: 'photo.jpg', type: 'image/jpeg' })).toBe(false);
|
||||
});
|
||||
|
||||
it('sends formats React Native cannot draw to the WebView', () => {
|
||||
expect(needsWebImageViewer({ name: 'logo.svg', type: '' })).toBe(true);
|
||||
expect(needsWebImageViewer({ name: 'x', type: 'image/x-icon' })).toBe(true);
|
||||
expect(needsWebImageViewer({ name: 'photo.jpg', type: 'image/jpeg' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preview size limits', () => {
|
||||
it('caps text far below images and PDFs', () => {
|
||||
it('caps text far below images, PDFs and media', () => {
|
||||
expect(FILE_PREVIEW_MAX_BYTES.text).toBe(512 * 1024);
|
||||
expect(FILE_PREVIEW_MAX_BYTES.text).toBeLessThan(FILE_PREVIEW_MAX_BYTES.image);
|
||||
expect(FILE_PREVIEW_MAX_BYTES.image).toBeLessThan(FILE_PREVIEW_MAX_BYTES.pdf);
|
||||
expect(FILE_PREVIEW_MAX_BYTES.pdf).toBeLessThan(FILE_PREVIEW_MAX_BYTES.video);
|
||||
});
|
||||
|
||||
it('flags only known sizes over the cap', () => {
|
||||
@@ -89,5 +142,27 @@ describe('preview size limits', () => {
|
||||
expect(inAppPreviewKind({ ...big, size: undefined }, ANDROID)).toBe('text');
|
||||
expect(inAppPreviewKind({ name: 'pano.jpg', type: 'image/jpeg', size: 40 * 1024 * 1024 }, ANDROID)).toBe('none');
|
||||
expect(inAppPreviewKind({ name: 'pano.jpg', type: 'image/jpeg', size: 4 * 1024 * 1024 }, ANDROID)).toBe('image');
|
||||
expect(inAppPreviewKind({ name: 'film.mp4', type: 'video/mp4', size: 300 * 1024 * 1024 }, ANDROID)).toBe('video');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handoffMimeType', () => {
|
||||
it('keeps a real declared type', () => {
|
||||
expect(handoffMimeType('a.pdf', 'application/pdf')).toBe('application/pdf');
|
||||
expect(handoffMimeType('scan', 'image/png')).toBe('image/png');
|
||||
expect(handoffMimeType('a.zip', 'application/x-zip-compressed')).toBe('application/zip');
|
||||
});
|
||||
|
||||
it('names the format when the declared type does not, so Android finds a viewer', () => {
|
||||
expect(handoffMimeType('contract.docx', 'application/octet-stream'))
|
||||
.toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
||||
expect(handoffMimeType('invoice.PDF', '')).toBe('application/pdf');
|
||||
expect(handoffMimeType('invoice.pdf', 'application/x-as400attachment')).toBe('application/pdf');
|
||||
expect(handoffMimeType('photo.jpg', 'application/x-unknown')).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('falls back to the declared type or octet-stream for unknown names', () => {
|
||||
expect(handoffMimeType('data.bin', '')).toBe('application/octet-stream');
|
||||
expect(handoffMimeType('data.xyz', 'application/x-foo')).toBe('application/x-foo');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,13 +7,14 @@ vi.mock('expo-localization', () => ({
|
||||
getCalendars: () => [{ timeZone: 'UTC', firstWeekday: 1 }],
|
||||
}));
|
||||
|
||||
import { intlLocale, resetIntlLocaleCache } from '../intl-locale';
|
||||
import { intlLocale } from '../intl-locale';
|
||||
import { registerDeviceLocales } from '../../i18n';
|
||||
import { formatCalendarDate, formatListDate } from '../date-format';
|
||||
import { formatHeaderDate } from '../email-date';
|
||||
|
||||
function setDevice(...tags: string[]) {
|
||||
device.locales = tags.map((t) => ({ languageTag: t, languageCode: t.split('-')[0] }));
|
||||
resetIntlLocaleCache();
|
||||
registerDeviceLocales();
|
||||
}
|
||||
|
||||
describe('intlLocale', () => {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { markdownToHtml, renderInline } from '../markdown-html';
|
||||
|
||||
const body = (md: string) => {
|
||||
const html = markdownToHtml(md);
|
||||
return html.slice(html.indexOf('<body>') + 6, html.indexOf('</body>'));
|
||||
};
|
||||
|
||||
describe('markdownToHtml', () => {
|
||||
it('renders headings, paragraphs and rules', () => {
|
||||
expect(body('# Title\n\nSome text\nwrapped.\n\n---\n\n### Small ###'))
|
||||
.toBe('<h1>Title</h1>\n<p>Some text\nwrapped.</p>\n<hr>\n<h3>Small</h3>');
|
||||
expect(body('Big\n===\n\nSmaller\n---')).toBe('<h1>Big</h1>\n<h2>Smaller</h2>');
|
||||
});
|
||||
|
||||
it('renders nested and ordered lists and task items', () => {
|
||||
expect(body('- one\n- two\n - nested\n- three')).toBe(
|
||||
'<ul><li>one</li><li>two\n<ul><li>nested</li></ul></li><li>three</li></ul>',
|
||||
);
|
||||
expect(body('3. c\n4. d')).toBe('<ol start="3"><li>c</li><li>d</li></ol>');
|
||||
expect(body('- [x] done\n- [ ] open')).toBe('<ul><li class="task">\u2611 done</li><li class="task">\u2610 open</li></ul>');
|
||||
});
|
||||
|
||||
it('keeps fenced and indented code verbatim and escaped', () => {
|
||||
expect(body('```js\nconst a = "<b>";\n**not bold**\n```')).toBe(
|
||||
'<pre><code class="language-js">const a = "<b>";\n**not bold**</code></pre>',
|
||||
);
|
||||
expect(body(' x < 1')).toBe('<pre><code>x < 1</code></pre>');
|
||||
});
|
||||
|
||||
it('renders quotes and GFM tables with alignment', () => {
|
||||
expect(body('> quoted\n> **text**')).toBe('<blockquote><p>quoted\n<strong>text</strong></p></blockquote>');
|
||||
expect(body('| a | b |\n|:--|--:|\n| 1 | 2 |')).toBe(
|
||||
'<table><thead><tr><th style="text-align:left">a</th><th style="text-align:right">b</th></tr></thead>'
|
||||
+ '<tbody><tr><td style="text-align:left">1</td><td style="text-align:right">2</td></tr></tbody></table>',
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes raw HTML instead of passing it through', () => {
|
||||
expect(body('<script>alert(1)</script>')).toBe('<p><script>alert(1)</script></p>');
|
||||
expect(body('<img src=x onerror=alert(1)>')).not.toContain('<img');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderInline', () => {
|
||||
it('renders emphasis, code and strikethrough', () => {
|
||||
expect(renderInline('**b** *i* _j_ `c*d*` ~~s~~')).toBe(
|
||||
'<strong>b</strong> <em>i</em> <em>j</em> <code>c*d*</code> <del>s</del>',
|
||||
);
|
||||
expect(renderInline('snake_case_name stays')).toBe('snake_case_name stays');
|
||||
expect(renderInline('\\*literal\\*')).toBe('*literal*');
|
||||
});
|
||||
|
||||
it('links only to web and mail URLs', () => {
|
||||
expect(renderInline('[site](https://example.org "T")')).toBe('<a href="https://example.org" title="T">site</a>');
|
||||
expect(renderInline('[x](javascript:alert(1))')).toBe('x)');
|
||||
expect(renderInline('[doc](./docs/a.md)')).toBe('doc');
|
||||
expect(renderInline('see https://example.org/a.')).toBe('see <a href="https://example.org/a">https://example.org/a</a>.');
|
||||
expect(renderInline('<mailto:a@b.c>')).toBe('<a href="mailto:a@b.c">mailto:a@b.c</a>');
|
||||
});
|
||||
|
||||
it('keeps image URLs for the sanitiser and drops unsafe ones', () => {
|
||||
expect(renderInline('')).toBe('<img src="https://example.org/l.png" alt="logo">');
|
||||
expect(renderInline('')).toBe('x');
|
||||
});
|
||||
|
||||
it('escapes attribute breakouts in link text and titles', () => {
|
||||
expect(renderInline('[a"b](https://e.org "x\\" onmouseover=\\"y")')).not.toMatch(/onmouseover="/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import PostalMime from 'postal-mime';
|
||||
|
||||
// React Native's Blob only takes strings and other Blobs; building one from an
|
||||
// ArrayBuffer or typed array throws. postal-mime used to join every decoded
|
||||
// part through such a Blob, so an .eml with an attachment could not be
|
||||
// previewed on the device (patches/postal-mime+3.0.0.patch).
|
||||
class ReactNativeBlob {
|
||||
constructor(parts: unknown[] = []) {
|
||||
if (parts.some((p) => p instanceof ArrayBuffer || ArrayBuffer.isView(p))) {
|
||||
throw new Error("Creating blobs from 'ArrayBuffer' and 'ArrayBufferView' are not supported");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MESSAGE = [
|
||||
'From: Carol <carol@example.org>',
|
||||
'Subject: With parts',
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: multipart/mixed; boundary="b1"',
|
||||
'',
|
||||
'--b1',
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'Content-Transfer-Encoding: quoted-printable',
|
||||
'',
|
||||
'Gr=C3=BC=C3=9Fe',
|
||||
'--b1',
|
||||
'Content-Type: application/octet-stream; name="a.bin"',
|
||||
'Content-Transfer-Encoding: base64',
|
||||
'Content-Disposition: attachment; filename="a.bin"',
|
||||
'',
|
||||
'AAECAwQF',
|
||||
'--b1',
|
||||
'Content-Type: text/plain; name="inner.txt"',
|
||||
'Content-Disposition: attachment; filename="inner.txt"',
|
||||
'',
|
||||
'Inner text.',
|
||||
'--b1--',
|
||||
'',
|
||||
].join('\r\n');
|
||||
|
||||
describe('postal-mime under React Native', () => {
|
||||
const realBlob = globalThis.Blob;
|
||||
beforeEach(() => { (globalThis as { Blob: unknown }).Blob = ReactNativeBlob; });
|
||||
afterEach(() => { globalThis.Blob = realBlob; });
|
||||
|
||||
it('decodes base64, quoted-printable and plain parts without a Blob', async () => {
|
||||
const parsed = await PostalMime.parse(new TextEncoder().encode(MESSAGE), { attachmentEncoding: 'arraybuffer' });
|
||||
expect(parsed.text?.trim()).toBe('Grüße');
|
||||
const [bin, txt] = parsed.attachments;
|
||||
expect(Array.from(new Uint8Array(bin.content as ArrayBuffer))).toEqual([0, 1, 2, 3, 4, 5]);
|
||||
expect(new TextDecoder().decode(txt.content as ArrayBuffer).trim()).toBe('Inner text.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { imageViewerHtml, mediaViewerHtml, relativeFileUrl, type ViewerColors } from '../preview-viewer-html';
|
||||
|
||||
const COLORS: ViewerColors = { scheme: 'dark', background: '#111', text: '#eee', textMuted: '#999', surface: '#222' };
|
||||
const HOSTILE = `x"><script>alert(1)</script>'.png`;
|
||||
|
||||
describe('relativeFileUrl', () => {
|
||||
it('encodes everything a URL or an attribute could read as syntax', () => {
|
||||
expect(relativeFileUrl('Scan 1 (final).PNG')).toBe('Scan%201%20%28final%29.PNG');
|
||||
expect(relativeFileUrl('a#b?c%d.jpg')).toBe('a%23b%3Fc%25d.jpg');
|
||||
expect(relativeFileUrl(HOSTILE)).not.toMatch(/["'<>]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('viewer pages', () => {
|
||||
it('load the file by a relative URL next to the page', () => {
|
||||
expect(imageViewerHtml('photo 1.jpg', COLORS)).toContain('src="photo%201.jpg"');
|
||||
expect(mediaViewerHtml('video', 'clip.mp4', COLORS)).toContain('<video id="m" src="clip.mp4"');
|
||||
expect(mediaViewerHtml('audio', 'song.mp3', COLORS)).toContain('<audio id="m" src="song.mp3"');
|
||||
});
|
||||
|
||||
it('never put a file name into markup unescaped', () => {
|
||||
for (const html of [
|
||||
imageViewerHtml(HOSTILE, COLORS),
|
||||
mediaViewerHtml('video', HOSTILE, COLORS),
|
||||
mediaViewerHtml('audio', HOSTILE, COLORS),
|
||||
]) {
|
||||
expect(html).not.toContain('<script>alert(1)');
|
||||
expect(html.match(/<script>/g)).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('report a file that does not decode', () => {
|
||||
expect(imageViewerHtml('a.svg', COLORS)).toContain("addEventListener('error'");
|
||||
expect(mediaViewerHtml('audio', 'a.ogg', COLORS)).toContain("report('error')");
|
||||
});
|
||||
|
||||
it('draw the WebView controls in the app theme, and video on black', () => {
|
||||
expect(mediaViewerHtml('audio', 'a.mp3', COLORS)).toContain('color-scheme:dark');
|
||||
expect(mediaViewerHtml('audio', 'a.mp3', { ...COLORS, scheme: 'light' })).toContain('color-scheme:light');
|
||||
expect(mediaViewerHtml('video', 'a.mp4', { ...COLORS, scheme: 'light' })).toContain('color-scheme:dark');
|
||||
});
|
||||
});
|
||||
@@ -140,24 +140,6 @@ export function calendarBannerShownFor(email: Pick<Email, 'attachments'>, parsin
|
||||
return parsingEnabled && !!findCalendarAttachment(email);
|
||||
}
|
||||
|
||||
export type PreviewKind = 'image' | 'pdf' | 'text' | 'eml' | 'none';
|
||||
|
||||
const TEXT_EXT = /\.(txt|md|markdown|csv|log|json|xml|ics|vcf|yml|yaml|ini|conf|diff|patch)$/i;
|
||||
// Script-bearing types are never previewed inline (html/svg/js) - webmail lib/file-preview.ts.
|
||||
const SCRIPT_TYPES = /^(text\/html|image\/svg\+xml|application\/(x-)?javascript|text\/javascript|application\/xhtml\+xml)$/i;
|
||||
|
||||
/** How the in-app preview should render a part, if at all. */
|
||||
export function previewKindFor(att: Pick<Attachment, 'name' | 'type'>): PreviewKind {
|
||||
const base = (att.type || '').split(';')[0].trim().toLowerCase();
|
||||
const name = att.name || '';
|
||||
if (SCRIPT_TYPES.test(base) || /\.(html?|svg|js)$/i.test(name)) return 'none';
|
||||
if (isRfc822Attachment(att)) return 'eml';
|
||||
if (base.startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp|heic|avif)$/i.test(name)) return 'image';
|
||||
if (base === 'application/pdf' || /\.pdf$/i.test(name)) return 'pdf';
|
||||
if (base.startsWith('text/') || base === 'application/json' || base === 'application/xml' || TEXT_EXT.test(name)) return 'text';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
export function formatSize(bytes?: number): string {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Calendar } from '../api/types';
|
||||
|
||||
// Names compare the way the user reads them: accents and case are ignored, and
|
||||
// "Team 2" comes before "Team 10".
|
||||
const nameCollator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true });
|
||||
|
||||
/**
|
||||
* JMAP Calendars: a calendar with a lower `sortOrder` is shown first, and
|
||||
* calendars with equal `sortOrder` are sorted by name. Mirrors the webmail.
|
||||
*/
|
||||
export function compareCalendars(a: Calendar, b: Calendar): number {
|
||||
return (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || nameCollator.compare(a.name ?? '', b.name ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calendars of one account stay together: the user's own first, then each
|
||||
* sharing account, as fetched.
|
||||
*/
|
||||
export function calendarAccountKey(calendar: Calendar): string {
|
||||
return calendar.isShared ? calendar.accountId ?? '' : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendars in display order: grouped by account in the order the
|
||||
* accounts first appear, and by `sortOrder`, then name, within each account.
|
||||
*/
|
||||
export function sortCalendars(calendars: Calendar[]): Calendar[] {
|
||||
const accountRank = new Map<string, number>();
|
||||
for (const calendar of calendars) {
|
||||
const key = calendarAccountKey(calendar);
|
||||
if (!accountRank.has(key)) accountRank.set(key, accountRank.size);
|
||||
}
|
||||
return [...calendars].sort((a, b) =>
|
||||
accountRank.get(calendarAccountKey(a))! - accountRank.get(calendarAccountKey(b))! || compareCalendars(a, b),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `sortOrder` values that put `orderedIds` in that order: 1, 2, 3... Only the
|
||||
* calendars whose value changes are returned.
|
||||
*/
|
||||
export function sortOrderUpdates(calendars: Calendar[], orderedIds: string[]): { id: string; sortOrder: number }[] {
|
||||
const byId = new Map(calendars.map((calendar) => [calendar.id, calendar]));
|
||||
const updates: { id: string; sortOrder: number }[] = [];
|
||||
orderedIds.forEach((id, index) => {
|
||||
const calendar = byId.get(id);
|
||||
if (calendar && calendar.sortOrder !== index + 1) updates.push({ id, sortOrder: index + 1 });
|
||||
});
|
||||
return updates;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ids of the calendar's account in display order once it is moved next to
|
||||
* `targetId`: before the target when it moves up, after it when it moves down.
|
||||
* Null when the move is not possible (unknown ids, or another account).
|
||||
*/
|
||||
export function moveCalendar(calendars: Calendar[], calendarId: string, targetId: string): string[] | null {
|
||||
const sorted = sortCalendars(calendars);
|
||||
const moving = sorted.find((calendar) => calendar.id === calendarId);
|
||||
const target = sorted.find((calendar) => calendar.id === targetId);
|
||||
if (!moving || !target || moving === target) return null;
|
||||
const key = calendarAccountKey(moving);
|
||||
if (calendarAccountKey(target) !== key) return null;
|
||||
const group = sorted.filter((calendar) => calendarAccountKey(calendar) === key);
|
||||
const movingDown = group.indexOf(moving) < group.indexOf(target);
|
||||
const rest = group.filter((calendar) => calendar !== moving);
|
||||
rest.splice(rest.indexOf(target) + (movingDown ? 1 : 0), 0, moving);
|
||||
return rest.map((calendar) => calendar.id);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Keep a re-opened draft's attachments pointing at blobs that still exist.
|
||||
//
|
||||
// A draft's attachments and inline images are MIME parts of that draft, and a
|
||||
// part's blobId lives only as long as the message that holds it: once a save
|
||||
// creates the next version and destroys the previous one, the server forgets
|
||||
// the old part blobs (Stalwart answers `blobNotFound`, "blobId … does not
|
||||
// exist on this server"). The composer was seeded with those part blobIds, so
|
||||
// without a re-point the second save of a re-opened draft - and any send
|
||||
// after its first save - failed (webmail #849, second half).
|
||||
//
|
||||
// Upload blobs are only a reprieve: the server keeps an unreferenced upload
|
||||
// for a limited time, so every attachment is re-pointed at the parts of the
|
||||
// version each save creates.
|
||||
|
||||
import type { Attachment } from '../api/types';
|
||||
import type { OutgoingAttachment } from '../api/email';
|
||||
import { stripMessageIdBrackets } from './email-threading';
|
||||
|
||||
const normCid = (cid?: string | null) => (cid ? stripMessageIdBrackets(cid).trim().toLowerCase() : '');
|
||||
|
||||
/**
|
||||
* Pair each sent attachment with the part of the newly created version that
|
||||
* carries the same content: inline parts by Content-ID, the rest by name and
|
||||
* size. Each new part is claimed at most once. Returns sent blobId → new blobId.
|
||||
*/
|
||||
export function matchDraftParts(
|
||||
sent: readonly OutgoingAttachment[],
|
||||
fresh: readonly Pick<Attachment, 'blobId' | 'name' | 'size' | 'cid'>[],
|
||||
): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
const claimed = new Set<number>();
|
||||
const claim = (s: OutgoingAttachment, test: (p: (typeof fresh)[number]) => boolean) => {
|
||||
if (out.has(s.blobId)) return;
|
||||
const i = fresh.findIndex((p, idx) => !claimed.has(idx) && !!p.blobId && test(p));
|
||||
if (i === -1) return;
|
||||
claimed.add(i);
|
||||
out.set(s.blobId, fresh[i].blobId);
|
||||
};
|
||||
for (const s of sent) {
|
||||
const cid = normCid(s.cid);
|
||||
if (cid) claim(s, (p) => normCid(p.cid) === cid);
|
||||
}
|
||||
for (const s of sent) {
|
||||
claim(s, (p) => (p.name || '') === (s.name || '') && (s.size == null || p.size === s.size));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface DraftPartBlobState {
|
||||
/** blobId an attachment entry holds → blobId to send for it now. */
|
||||
live: Map<string, string>;
|
||||
}
|
||||
|
||||
export function initialDraftPartBlobState(): DraftPartBlobState {
|
||||
return { live: new Map() };
|
||||
}
|
||||
|
||||
/** The blobId to put on the wire for an entry that holds `blobId`. */
|
||||
export function liveBlobId(state: DraftPartBlobState, blobId: string): string {
|
||||
return state.live.get(blobId) ?? blobId;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a save: point every attachment that was sent at the matching part of
|
||||
* the version just created. Those parts live exactly as long as the saved
|
||||
* draft, so the next save or the send always references live blobs - whether
|
||||
* the entry came from the previous version (dies with it) or from an upload
|
||||
* (an unreferenced upload expires on the server). Blobs that could not be
|
||||
* matched keep their id: the next save then fails loudly (and, with
|
||||
* create-before-destroy, never loses the draft) instead of silently dropping
|
||||
* an attachment.
|
||||
*/
|
||||
export function rebaseDraftPartBlobs(
|
||||
state: DraftPartBlobState,
|
||||
sent: readonly OutgoingAttachment[],
|
||||
fresh: readonly Pick<Attachment, 'blobId' | 'name' | 'size' | 'cid'>[],
|
||||
): DraftPartBlobState {
|
||||
const pairs = matchDraftParts(sent, fresh);
|
||||
if (pairs.size === 0) return state;
|
||||
const live = new Map(state.live);
|
||||
const current = new Set(state.live.values());
|
||||
for (const [held, sentId] of state.live) {
|
||||
const next = pairs.get(sentId);
|
||||
if (next) live.set(held, next);
|
||||
}
|
||||
for (const [sentId, next] of pairs) {
|
||||
// An entry sent under its own blobId for the first time.
|
||||
if (!current.has(sentId)) live.set(sentId, next);
|
||||
}
|
||||
return { live };
|
||||
}
|
||||
|
||||
/**
|
||||
* The composer's dirty snapshot with its body replaced. Opening a draft whose
|
||||
* inline images get hydrated rewrites the editor body (normalised DOM HTML,
|
||||
* data URLs for `cid:` images); that rewrite is not an edit, so the baseline
|
||||
* has to follow it - comparing against the raw server HTML instead made every
|
||||
* re-opened draft with inline images look edited, and the autosave replaced
|
||||
* it (and destroyed the opened version) two seconds after opening.
|
||||
*/
|
||||
export function withSnapshotBody(snapshot: string, body: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(snapshot) as Record<string, unknown>;
|
||||
return JSON.stringify({ ...parsed, body });
|
||||
} catch {
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
+28
-1
@@ -70,8 +70,14 @@ export function buildEditorHtml(opts: {
|
||||
c: ThemePalette;
|
||||
/** Block remote images and fonts (see `buildEditorCsp`). */
|
||||
blockRemoteImages?: boolean;
|
||||
/**
|
||||
* Keep the page's visual viewport at the top and report every pan to RN as
|
||||
* a `viewportShift` (CSS px = dp) instead (see the page script). Android
|
||||
* only: its WebView pans the visual viewport to reveal the caret.
|
||||
*/
|
||||
pinVisualViewport?: boolean;
|
||||
}): string {
|
||||
const { initialHtml, placeholder, c, blockRemoteImages = false } = opts;
|
||||
const { initialHtml, placeholder, c, blockRemoteImages = false, pinVisualViewport = false } = opts;
|
||||
// Inject initial content as a JSON-encoded string so any HTML/quotes inside
|
||||
// are safely embedded (no template literal collision with the script body).
|
||||
const initialJson = JSON.stringify(initialHtml);
|
||||
@@ -362,6 +368,27 @@ export function buildEditorHtml(opts: {
|
||||
reportHeight();
|
||||
reportChange();
|
||||
|
||||
// ── Visual viewport pinning (Android) ──────────────────────────────
|
||||
// The WebView is as tall as its content and sits in RN's ScrollView, so
|
||||
// the page itself never scrolls. But Android's WebView shrinks the visual
|
||||
// viewport to the part of the view that is on screen (above the keyboard,
|
||||
// below the header fields) and pans it to reveal the caret while typing.
|
||||
// The pan outlives the moment: once the composer is scrolled back to the
|
||||
// top the body is still drawn shifted up by that offset, so its first
|
||||
// lines hide under To/Cc/Subject until a scroll to the very bottom clamps
|
||||
// it away. Undo every pan at once (window.scrollTo only moves the layout
|
||||
// viewport; scrolling the top of the page into view resets both) and hand
|
||||
// the offset to RN, which scrolls the composer by as much so the caret
|
||||
// stays where the pan had put it.
|
||||
if (${pinVisualViewport ? 'true' : 'false'} && window.visualViewport) {
|
||||
window.visualViewport.addEventListener('scroll', function () {
|
||||
var shift = window.visualViewport.offsetTop;
|
||||
if (Math.abs(shift) < 0.5) return;
|
||||
try { editor.scrollIntoView({ block: 'start', inline: 'nearest' }); } catch (e) {}
|
||||
post('viewportShift', shift);
|
||||
});
|
||||
}
|
||||
|
||||
// Re-measure after async layout (image loads, font swap).
|
||||
window.addEventListener('load', function () {
|
||||
setTimeout(reportHeight, 50);
|
||||
|
||||
+24
-4
@@ -14,6 +14,7 @@ import {
|
||||
} from './download-filename';
|
||||
import { getClientCertAlias, secureFetch } from './client-cert';
|
||||
import { sniffImageMime } from './email-html';
|
||||
import { handoffMimeType } from './file-preview';
|
||||
|
||||
const RFC822 = 'message/rfc822';
|
||||
const FLAG_GRANT_READ_URI_PERMISSION = 0x00000001;
|
||||
@@ -47,6 +48,11 @@ function attachmentFileOptions(): EmailFilenameOptions {
|
||||
};
|
||||
}
|
||||
|
||||
/** The name "Save" gives an attachment of `email`: the user's attachment filename template. */
|
||||
export function attachmentSaveName(email: Email, name: string | undefined, type: string | undefined): string {
|
||||
return attachmentDownloadFilename(email, { name, type }, attachmentFileOptions());
|
||||
}
|
||||
|
||||
function safeAttachmentName(name: string | undefined, type: string | undefined): string {
|
||||
const fallbackExt = type?.split('/')[1]?.replace(/[^a-z0-9]/gi, '').slice(0, 8) || 'bin';
|
||||
const cleaned = (name ?? '').replace(/[\\/:*?"<>|]/g, '_').trim();
|
||||
@@ -151,7 +157,7 @@ async function openWithViewer(file: File, mimeType: string): Promise<boolean> {
|
||||
// intent. Deliberately no FLAG_ACTIVITY_NEW_TASK: expo-intent-launcher
|
||||
// uses startActivityForResult, and a new task cancels that result.
|
||||
data: file.contentUri,
|
||||
type: mimeType,
|
||||
type: handoffMimeType(file.name, mimeType),
|
||||
flags: FLAG_GRANT_READ_URI_PERMISSION,
|
||||
});
|
||||
return true;
|
||||
@@ -254,7 +260,7 @@ export async function shareAttachment(
|
||||
}
|
||||
try {
|
||||
await Sharing.shareAsync(downloaded.uri, {
|
||||
mimeType,
|
||||
mimeType: handoffMimeType(filename, mimeType),
|
||||
dialogTitle: filename,
|
||||
});
|
||||
} finally {
|
||||
@@ -291,7 +297,7 @@ async function offerSavedFile(file: File, filename: string, mimeType: string): P
|
||||
throw new Error(t('files.share_unavailable', 'Sharing is not available on this device'));
|
||||
}
|
||||
await Sharing.shareAsync(file.uri, {
|
||||
mimeType,
|
||||
mimeType: handoffMimeType(filename, mimeType),
|
||||
dialogTitle: filename,
|
||||
});
|
||||
}
|
||||
@@ -347,6 +353,20 @@ export async function cachePreviewFile(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link cachePreviewFile} for a file already on the device (a compose
|
||||
* attachment): copied, so the preview owns its copy and the original is never
|
||||
* deleted with it.
|
||||
*/
|
||||
export function copyPreviewFile(uri: string, name: string | undefined, type: string | undefined): File {
|
||||
void sweepStaleExportFiles();
|
||||
const dir = newPreviewDir();
|
||||
ensureDir(dir);
|
||||
const file = new File(dir, safeAttachmentName(name, type));
|
||||
new File(uri).copy(file);
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link cachePreviewFile} for bytes already in memory (parts unpacked on the
|
||||
* device, e.g. from winmail.dat).
|
||||
@@ -403,7 +423,7 @@ export async function shareLocalFile(
|
||||
throw new Error(t('files.share_unavailable', 'Sharing is not available on this device'));
|
||||
}
|
||||
try {
|
||||
await Sharing.shareAsync(file.uri, { mimeType, dialogTitle: dialogTitle ?? file.name });
|
||||
await Sharing.shareAsync(file.uri, { mimeType: handoffMimeType(file.name, mimeType), dialogTitle: dialogTitle ?? file.name });
|
||||
} finally {
|
||||
if (!opts.keep) scheduleTempCleanup(file);
|
||||
}
|
||||
|
||||
+214
-65
@@ -1,19 +1,19 @@
|
||||
// Which Files-tab nodes open in the in-app preview, and how large they may be.
|
||||
// Builds on the reader's attachment classification (`previewKindFor`) so both
|
||||
// previews agree, and adds what a drive holds more often than mail: source
|
||||
// code and config files, shown as text. Port of the webmail's
|
||||
// `lib/file-preview.ts`, narrowed to what the native preview can render;
|
||||
// everything else keeps going to the OS "Open with" chooser.
|
||||
// Which files open in the in-app preview, how, and how large they may be.
|
||||
// One classification for every place that opens a file: the reader's and the
|
||||
// list's attachment chips, the Files tab and compose. Port of the webmail's
|
||||
// `lib/file-preview.ts`, narrowed to what each platform's viewers render;
|
||||
// everything else goes to the OS "Open with" chooser.
|
||||
|
||||
import { previewKindFor, type PreviewKind } from './attachment-display';
|
||||
export type PreviewKind =
|
||||
| 'image' | 'pdf' | 'text' | 'markdown' | 'html' | 'eml' | 'video' | 'audio' | 'none';
|
||||
|
||||
export interface FilePreviewOptions {
|
||||
/**
|
||||
* Whether this platform renders PDFs in-app. iOS does, in the WebView;
|
||||
* Android has no built-in renderer, so a PDF there goes straight to the
|
||||
* PDF viewer instead of a preview screen that can only offer that viewer.
|
||||
* The platform's viewers decide what plays: video and audio play in the
|
||||
* system WebView (Chromium on Android, WebKit on iOS), whose formats
|
||||
* differ, and only iOS renders PDFs in-app.
|
||||
*/
|
||||
inlinePdf: boolean;
|
||||
platform: 'ios' | 'android';
|
||||
}
|
||||
|
||||
interface PreviewableFile {
|
||||
@@ -24,74 +24,133 @@ interface PreviewableFile {
|
||||
|
||||
/**
|
||||
* Largest file each preview kind loads. Text is rendered in one selectable
|
||||
* `<Text>`, which gets slow to lay out past a few hundred KB; parsing an
|
||||
* .eml and decoding an image hold the whole file in memory; PDFs are paged by
|
||||
* the WebView. Larger files open in another app instead.
|
||||
* `<Text>`, which gets slow to lay out past a few hundred KB; HTML and
|
||||
* Markdown go through the mail sanitiser in JS; parsing an .eml and decoding
|
||||
* an image hold the whole file in memory. PDFs are paged by their viewer and
|
||||
* media stream from the downloaded copy. Larger files open in another app.
|
||||
*/
|
||||
export const FILE_PREVIEW_MAX_BYTES: Record<Exclude<PreviewKind, 'none'>, number> = {
|
||||
text: 512 * 1024,
|
||||
markdown: 1024 * 1024,
|
||||
html: 5 * 1024 * 1024,
|
||||
eml: 10 * 1024 * 1024,
|
||||
image: 25 * 1024 * 1024,
|
||||
pdf: 50 * 1024 * 1024,
|
||||
pdf: 100 * 1024 * 1024,
|
||||
audio: 500 * 1024 * 1024,
|
||||
video: 1024 * 1024 * 1024,
|
||||
};
|
||||
|
||||
// Script-bearing documents open in the browser, as in the reader. The webmail
|
||||
// renders HTML in a sandboxed iframe and SVG through <img>; neither has a safe
|
||||
// native equivalent here.
|
||||
const NEVER_INLINE_MIME = new Set(['text/html', 'application/xhtml+xml', 'image/svg+xml']);
|
||||
const NEVER_INLINE_EXT = /\.(html?|xht(ml)?|svgz?)$/i;
|
||||
|
||||
// Formats React Native's Image decodes. Other image types (TIFF, RAW, PSD,
|
||||
// ICO) would render blank.
|
||||
const RENDERABLE_IMAGE_MIME = new Set([
|
||||
'image/png', 'image/jpeg', 'image/jpg', 'image/pjpeg', 'image/gif', 'image/webp',
|
||||
'image/bmp', 'image/x-ms-bmp', 'image/heic', 'image/heif', 'image/avif',
|
||||
]);
|
||||
const RENDERABLE_IMAGE_EXT = /\.(png|jpe?g|jfif|gif|webp|bmp|heic|heif|avif)$/i;
|
||||
|
||||
// Source and config files the reader's list doesn't cover (it only sees mail
|
||||
// attachments). The webmail's TEXT_EXTENSIONS plus the languages the Files
|
||||
// tab already draws a code icon for.
|
||||
const CODE_EXT = new RegExp(
|
||||
'\\.('
|
||||
+ 'text|tsv|toml|cfg|env|properties|sql|graphql|gql|css|scss|sass|less'
|
||||
+ '|js|mjs|cjs|jsx|ts|mts|cts|tsx|vue|svelte'
|
||||
+ '|py|rb|go|rs|java|kt|kts|swift|c|h|cc|cpp|hpp|cs|php|pl|lua|r|dart|scala|gradle'
|
||||
+ '|sh|bash|zsh|fish|ps1|bat|cmd'
|
||||
+ '|tex|rst|adoc|org|srt|vtt'
|
||||
+ ')$',
|
||||
'i',
|
||||
);
|
||||
const CODE_FILE_NAMES = new Set(['dockerfile', 'makefile', 'readme', 'license', 'changelog', '.gitignore', '.editorconfig']);
|
||||
const TEXT_MIME = new Set([
|
||||
'application/json', 'application/ld+json', 'application/xml', 'application/javascript',
|
||||
'application/x-javascript', 'text/javascript', 'application/typescript', 'application/x-sh',
|
||||
'application/x-yaml', 'application/yaml', 'application/toml', 'application/sql', 'application/graphql',
|
||||
]);
|
||||
|
||||
function baseMime(type?: string | null): string {
|
||||
return (type || '').split(';')[0].trim().toLowerCase();
|
||||
}
|
||||
|
||||
function extension(name: string): string {
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
|
||||
}
|
||||
|
||||
// Generic types say nothing about the file; the name decides.
|
||||
const GENERIC_MIME = new Set(['', 'application/octet-stream', 'binary/octet-stream', 'application/unknown']);
|
||||
|
||||
// Images React Native's Image decodes, plus SVG and ICO, which the preview
|
||||
// draws in a WebView. iOS decodes TIFF as well; Android's decoders don't.
|
||||
const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'jfif', 'pjpeg', 'gif', 'webp', 'bmp', 'heic', 'heif', 'avif', 'svg', 'ico']);
|
||||
const IMAGE_MIME = new Set([
|
||||
'image/png', 'image/jpeg', 'image/jpg', 'image/pjpeg', 'image/gif', 'image/webp', 'image/bmp',
|
||||
'image/x-ms-bmp', 'image/heic', 'image/heif', 'image/avif', 'image/svg+xml', 'image/x-icon',
|
||||
'image/vnd.microsoft.icon', 'image/ico',
|
||||
]);
|
||||
const IOS_IMAGE_EXT = new Set(['tif', 'tiff']);
|
||||
const IOS_IMAGE_MIME = new Set(['image/tiff', 'image/tif']);
|
||||
|
||||
// What the system WebView plays. Chromium handles the open formats and
|
||||
// Matroska; WebKit handles Apple's, and neither plays AVI, WMV or FLV.
|
||||
const MEDIA: Record<'video' | 'audio', Record<FilePreviewOptions['platform'], { ext: Set<string>; mime: Set<string> }>> = {
|
||||
video: {
|
||||
android: {
|
||||
ext: new Set(['mp4', 'm4v', 'mov', 'webm', 'ogv', 'mkv', '3gp', '3g2']),
|
||||
mime: new Set(['video/mp4', 'video/x-m4v', 'video/quicktime', 'video/webm', 'video/ogg', 'video/x-matroska', 'video/3gpp', 'video/3gpp2']),
|
||||
},
|
||||
ios: {
|
||||
ext: new Set(['mp4', 'm4v', 'mov', '3gp', '3g2']),
|
||||
mime: new Set(['video/mp4', 'video/x-m4v', 'video/quicktime', 'video/3gpp', 'video/3gpp2']),
|
||||
},
|
||||
},
|
||||
audio: {
|
||||
android: {
|
||||
ext: new Set(['mp3', 'm4a', 'aac', 'wav', 'ogg', 'oga', 'opus', 'flac', 'weba']),
|
||||
mime: new Set([
|
||||
'audio/mpeg', 'audio/mp3', 'audio/mp4', 'audio/x-m4a', 'audio/aac', 'audio/wav', 'audio/x-wav', 'audio/wave',
|
||||
'audio/ogg', 'audio/opus', 'audio/flac', 'audio/x-flac', 'audio/webm',
|
||||
]),
|
||||
},
|
||||
ios: {
|
||||
ext: new Set(['mp3', 'm4a', 'aac', 'wav', 'flac', 'aif', 'aiff', 'caf']),
|
||||
mime: new Set([
|
||||
'audio/mpeg', 'audio/mp3', 'audio/mp4', 'audio/x-m4a', 'audio/aac', 'audio/wav', 'audio/x-wav', 'audio/wave',
|
||||
'audio/flac', 'audio/x-flac', 'audio/aiff', 'audio/x-aiff', 'audio/x-caf',
|
||||
]),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const HTML_MIME = new Set(['text/html', 'application/xhtml+xml']);
|
||||
const HTML_EXT = new Set(['html', 'htm', 'xhtml', 'xht']);
|
||||
const MARKDOWN_MIME = new Set(['text/markdown', 'text/x-markdown']);
|
||||
const MARKDOWN_EXT = new Set(['md', 'markdown', 'mdown', 'mkd', 'mkdn']);
|
||||
|
||||
// Plain text and source files, shown as text. Script types are inert there.
|
||||
const TEXT_EXT = new Set([
|
||||
'txt', 'text', 'log', 'csv', 'tsv', 'json', 'xml', 'ics', 'vcf', 'yml', 'yaml', 'ini', 'conf', 'cfg', 'env',
|
||||
'properties', 'toml', 'diff', 'patch', 'sql', 'graphql', 'gql', 'css', 'scss', 'sass', 'less',
|
||||
'js', 'mjs', 'cjs', 'jsx', 'ts', 'mts', 'cts', 'tsx', 'vue', 'svelte',
|
||||
'py', 'rb', 'go', 'rs', 'java', 'kt', 'kts', 'swift', 'c', 'h', 'cc', 'cpp', 'hpp', 'cs', 'php', 'pl', 'lua', 'r',
|
||||
'dart', 'scala', 'gradle', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd',
|
||||
'tex', 'rst', 'adoc', 'org', 'srt', 'vtt',
|
||||
]);
|
||||
const TEXT_FILE_NAMES = new Set(['dockerfile', 'makefile', 'readme', 'license', 'changelog', '.gitignore', '.editorconfig']);
|
||||
const TEXT_MIME = new Set([
|
||||
'application/json', 'application/ld+json', 'application/xml', 'application/javascript',
|
||||
'application/x-javascript', 'application/typescript', 'application/x-sh', 'application/x-yaml',
|
||||
'application/yaml', 'application/toml', 'application/sql', 'application/graphql',
|
||||
'application/ics', 'application/icalendar',
|
||||
]);
|
||||
|
||||
/** How the in-app preview renders a file, or 'none' for the OS chooser. */
|
||||
export function filePreviewKind(file: Pick<PreviewableFile, 'name' | 'type'>, opts: FilePreviewOptions): PreviewKind {
|
||||
const mime = baseMime(file.type);
|
||||
const declared = baseMime(file.type);
|
||||
const mime = GENERIC_MIME.has(declared) ? '' : declared;
|
||||
const name = file.name || '';
|
||||
if (NEVER_INLINE_MIME.has(mime) || NEVER_INLINE_EXT.test(name)) return 'none';
|
||||
const kind = previewKindFor({ name, type: mime });
|
||||
switch (kind) {
|
||||
case 'image':
|
||||
return RENDERABLE_IMAGE_MIME.has(mime) || RENDERABLE_IMAGE_EXT.test(name) ? 'image' : 'none';
|
||||
case 'pdf':
|
||||
return opts.inlinePdf ? 'pdf' : 'none';
|
||||
case 'none':
|
||||
// The reader keeps .js and friends out; shown as source text they are inert.
|
||||
if (CODE_EXT.test(name) || CODE_FILE_NAMES.has(name.toLowerCase())) return 'text';
|
||||
if (TEXT_MIME.has(mime) || /\+(json|xml)$/.test(mime)) return 'text';
|
||||
return 'none';
|
||||
default:
|
||||
return kind;
|
||||
const ext = extension(name);
|
||||
// An image, audio or video type decides the viewer. Any other type leaves
|
||||
// it to the name: senders put catch-alls such as the
|
||||
// application/x-as400attachment IBM i systems use on every part.
|
||||
const media = /^(image|audio|video)\//.test(mime);
|
||||
|
||||
if (mime === 'message/rfc822' || ext === 'eml') return 'eml';
|
||||
if (HTML_MIME.has(mime) || HTML_EXT.has(ext)) return 'html';
|
||||
if (MARKDOWN_MIME.has(mime) || MARKDOWN_EXT.has(ext)) return 'markdown';
|
||||
|
||||
if (mime.startsWith('image/') || (!media && (IMAGE_EXT.has(ext) || IOS_IMAGE_EXT.has(ext)))) {
|
||||
// Compressed SVG needs a Content-Encoding a local file doesn't have.
|
||||
if (ext === 'svgz') return 'none';
|
||||
const ios = opts.platform === 'ios';
|
||||
const shown = IMAGE_MIME.has(mime) || IMAGE_EXT.has(ext)
|
||||
|| (ios && (IOS_IMAGE_MIME.has(mime) || IOS_IMAGE_EXT.has(ext)));
|
||||
return shown ? 'image' : 'none';
|
||||
}
|
||||
if (mime === 'application/pdf' || (!media && ext === 'pdf')) {
|
||||
return opts.platform === 'ios' ? 'pdf' : 'none';
|
||||
}
|
||||
for (const kind of ['video', 'audio'] as const) {
|
||||
const formats = MEDIA[kind][opts.platform];
|
||||
if (mime.startsWith(`${kind}/`)) return formats.mime.has(mime) || formats.ext.has(ext) ? kind : 'none';
|
||||
if (!media && formats.ext.has(ext)) return kind;
|
||||
}
|
||||
|
||||
if (mime.startsWith('text/') || TEXT_MIME.has(mime) || /\+(json|xml)$/.test(mime)) return 'text';
|
||||
if (TEXT_EXT.has(ext) || TEXT_FILE_NAMES.has(name.toLowerCase())) return 'text';
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/** True when a known size is over the preview cap for `kind`. Unknown sizes pass; the caller checks the download. */
|
||||
@@ -102,9 +161,99 @@ export function exceedsPreviewLimit(kind: PreviewKind, size: number | null | und
|
||||
|
||||
/**
|
||||
* The preview a tap on this file opens: its kind when the app can show it and
|
||||
* the file is within the cap, else 'none' (hand it to another app, as before).
|
||||
* the file is within the cap, else 'none' (hand it to another app).
|
||||
*/
|
||||
export function inAppPreviewKind(file: PreviewableFile, opts: FilePreviewOptions): PreviewKind {
|
||||
const kind = filePreviewKind(file, opts);
|
||||
return exceedsPreviewLimit(kind, file.size) ? 'none' : kind;
|
||||
}
|
||||
|
||||
/** Images Android's WebView can't decode, which the preview draws with React Native's Image instead. */
|
||||
export function needsNativeImageDecoder(file: Pick<PreviewableFile, 'name' | 'type'>): boolean {
|
||||
const mime = baseMime(file.type);
|
||||
return /^image\/(heic|heif|tiff?)$/.test(mime) || /\.(heic|heif|tiff?)$/i.test(file.name);
|
||||
}
|
||||
|
||||
/** Images React Native's Image can't draw, which go to the WebView viewer on iOS too. */
|
||||
export function needsWebImageViewer(file: Pick<PreviewableFile, 'name' | 'type'>): boolean {
|
||||
const mime = baseMime(file.type);
|
||||
return /^image\/(svg\+xml|x-icon|vnd\.microsoft\.icon|ico)$/.test(mime) || /\.(svg|ico)$/i.test(file.name);
|
||||
}
|
||||
|
||||
// Types for handing a file to another app when the sender declared none, or
|
||||
// a catch-all: Android picks the apps for "Open with" by type, and finds
|
||||
// none for application/octet-stream.
|
||||
const EXT_TO_MIME: Record<string, string> = {
|
||||
pdf: 'application/pdf',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
odt: 'application/vnd.oasis.opendocument.text',
|
||||
ods: 'application/vnd.oasis.opendocument.spreadsheet',
|
||||
odp: 'application/vnd.oasis.opendocument.presentation',
|
||||
rtf: 'application/rtf',
|
||||
epub: 'application/epub+zip',
|
||||
zip: 'application/zip',
|
||||
'7z': 'application/x-7z-compressed',
|
||||
rar: 'application/vnd.rar',
|
||||
gz: 'application/gzip',
|
||||
tar: 'application/x-tar',
|
||||
apk: 'application/vnd.android.package-archive',
|
||||
txt: 'text/plain',
|
||||
csv: 'text/csv',
|
||||
html: 'text/html',
|
||||
htm: 'text/html',
|
||||
md: 'text/markdown',
|
||||
json: 'application/json',
|
||||
xml: 'application/xml',
|
||||
ics: 'text/calendar',
|
||||
vcf: 'text/vcard',
|
||||
eml: 'message/rfc822',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
heic: 'image/heic',
|
||||
heif: 'image/heif',
|
||||
avif: 'image/avif',
|
||||
bmp: 'image/bmp',
|
||||
tif: 'image/tiff',
|
||||
tiff: 'image/tiff',
|
||||
svg: 'image/svg+xml',
|
||||
ico: 'image/x-icon',
|
||||
mp3: 'audio/mpeg',
|
||||
m4a: 'audio/mp4',
|
||||
aac: 'audio/aac',
|
||||
wav: 'audio/wav',
|
||||
ogg: 'audio/ogg',
|
||||
opus: 'audio/ogg',
|
||||
flac: 'audio/flac',
|
||||
amr: 'audio/amr',
|
||||
mp4: 'video/mp4',
|
||||
m4v: 'video/mp4',
|
||||
mov: 'video/quicktime',
|
||||
webm: 'video/webm',
|
||||
mkv: 'video/x-matroska',
|
||||
avi: 'video/x-msvideo',
|
||||
wmv: 'video/x-ms-wmv',
|
||||
'3gp': 'video/3gpp',
|
||||
};
|
||||
|
||||
/**
|
||||
* The type to hand a file to another app with: the declared one, unless it
|
||||
* is generic or a catch-all that names no format, in which case the one the
|
||||
* file name implies.
|
||||
*/
|
||||
export function handoffMimeType(name: string | undefined, type: string | null | undefined): string {
|
||||
const declared = baseMime(type);
|
||||
const fromName = EXT_TO_MIME[extension(name || '')];
|
||||
if (!fromName) return type || 'application/octet-stream';
|
||||
if (GENERIC_MIME.has(declared)) return fromName;
|
||||
// An "x-" application type the name contradicts, such as x-as400attachment.
|
||||
if (/^application\/x-/.test(declared) && declared !== fromName) return fromName;
|
||||
return type || fromName;
|
||||
}
|
||||
|
||||
+18
-15
@@ -3,11 +3,25 @@
|
||||
// order: en-AU writes 15/10/2026, en-US 10/15/2026. intlLocale() hands the
|
||||
// date formatters the device's full tag when its language matches the app
|
||||
// language, so an Australian or British phone gets its own dates (#75).
|
||||
import { getLocales } from 'expo-localization';
|
||||
import { resolveLocaleTag } from '../i18n';
|
||||
//
|
||||
// This module stays free of react-native and expo because the device sync
|
||||
// engine reaches it through quote-header (see device-sync purity test). The
|
||||
// i18n module reads the device locales and registers them here on load.
|
||||
|
||||
export interface DeviceLocale {
|
||||
/** The app language this device locale selects, from resolveLocaleTag. */
|
||||
appLocale: string | null;
|
||||
languageTag: string;
|
||||
}
|
||||
|
||||
let deviceLocales: DeviceLocale[] = [];
|
||||
const cache = new Map<string, string>();
|
||||
|
||||
export function setDeviceLocales(locales: DeviceLocale[]): void {
|
||||
deviceLocales = locales;
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
// Android 14 appends regional preferences such as `-u-fw-mon-mu-celsius`.
|
||||
// The app has its own time-format and week-start settings, so keep only the
|
||||
// language, script and region.
|
||||
@@ -25,14 +39,8 @@ function isUsable(tag: string): boolean {
|
||||
}
|
||||
|
||||
function deviceTagFor(locale: string): string | null {
|
||||
let devices: ReturnType<typeof getLocales>;
|
||||
try {
|
||||
devices = getLocales();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const l of devices) {
|
||||
if (!l.languageTag || resolveLocaleTag(l) !== locale) continue;
|
||||
for (const l of deviceLocales) {
|
||||
if (!l.languageTag || l.appLocale !== locale) continue;
|
||||
const tag = baseTag(l.languageTag);
|
||||
// A tag without a region adds nothing over the language subtag.
|
||||
if (tag.includes('-') && isUsable(tag)) return tag;
|
||||
@@ -52,8 +60,3 @@ export function intlLocale(locale: string | null | undefined): string {
|
||||
cache.set(lang, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** Test hook: forget the resolved tags so a changed device locale is seen. */
|
||||
export function resetIntlLocaleCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
// Markdown to HTML for the file preview. Covers what README files and notes
|
||||
// use: ATX and setext headings, paragraphs, emphasis, strikethrough, inline
|
||||
// and fenced code, block quotes, nested lists and task lists, GFM tables,
|
||||
// rules, links, images and autolinks. Every piece of source text is escaped
|
||||
// and only http(s)/mailto URLs survive, so the output carries no markup the
|
||||
// author wrote; it is still shown through the mail-body sanitiser, which
|
||||
// blocks remote images like it does for mail.
|
||||
|
||||
const ESCAPES: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (ch) => ESCAPES[ch]);
|
||||
}
|
||||
|
||||
function safeUrl(raw: string): string | null {
|
||||
const url = raw.trim().replace(/^<|>$/g, '');
|
||||
if (/^(https?:|mailto:)/i.test(url)) return url;
|
||||
// Relative links point into a repository the preview doesn't have.
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Inline markup of one block's text. */
|
||||
export function renderInline(src: string): string {
|
||||
// Code spans first: nothing inside them is markup.
|
||||
const codes: string[] = [];
|
||||
let s = src.replace(/(`+)([\s\S]*?[^`])\1(?!`)/g, (_m, _ticks, code: string) => {
|
||||
codes.push(`<code>${escapeHtml(code.trim())}</code>`);
|
||||
return `\u0000${codes.length - 1}\u0000`;
|
||||
});
|
||||
// Backslash escapes.
|
||||
const escaped: string[] = [];
|
||||
s = s.replace(/\\([\\`*_{}[\]()#+\-.!|~>])/g, (_m, ch: string) => {
|
||||
escaped.push(escapeHtml(ch));
|
||||
return `\u0001${escaped.length - 1}\u0001`;
|
||||
});
|
||||
// Links and images, before escaping so the brackets are still there.
|
||||
const links: string[] = [];
|
||||
const stash = (html: string) => {
|
||||
links.push(html);
|
||||
return `\u0002${links.length - 1}\u0002`;
|
||||
};
|
||||
s = s.replace(/!\[([^\]]*)\]\(\s*(<[^>]*>|[^\s)]+)(?:\s+"([^"]*)")?\s*\)/g, (_m, alt: string, url: string, title?: string) => {
|
||||
const href = safeUrl(url);
|
||||
if (!href) return stash(escapeHtml(alt));
|
||||
const t = title ? ` title="${escapeHtml(title)}"` : '';
|
||||
return stash(`<img src="${escapeHtml(href)}" alt="${escapeHtml(alt)}"${t}>`);
|
||||
});
|
||||
s = s.replace(/\[([^\]]+)\]\(\s*(<[^>]*>|[^\s)]+)(?:\s+"([^"]*)")?\s*\)/g, (_m, text: string, url: string, title?: string) => {
|
||||
const href = safeUrl(url);
|
||||
const inner = renderEmphasis(escapeHtml(text));
|
||||
if (!href) return stash(inner);
|
||||
const t = title ? ` title="${escapeHtml(title)}"` : '';
|
||||
return stash(`<a href="${escapeHtml(href)}"${t}>${inner}</a>`);
|
||||
});
|
||||
s = s.replace(/<((?:https?:\/\/|mailto:)[^\s>]+)>/gi, (_m, url: string) => stash(`<a href="${escapeHtml(url)}">${escapeHtml(url)}</a>`));
|
||||
s = s.replace(/(^|[\s(])(https?:\/\/[^\s<>()]+[^\s<>().,;:!?'"])/g, (_m, lead: string, url: string) => (
|
||||
lead + stash(`<a href="${escapeHtml(url)}">${escapeHtml(url)}</a>`)
|
||||
));
|
||||
|
||||
s = renderEmphasis(escapeHtml(s));
|
||||
// Hard line breaks: two trailing spaces or a backslash.
|
||||
s = s.replace(/ {2,}\n|\\\n/g, '<br>\n');
|
||||
|
||||
return s
|
||||
.replace(/\u0002(\d+)\u0002/g, (_m, i: string) => links[Number(i)])
|
||||
.replace(/\u0001(\d+)\u0001/g, (_m, i: string) => escaped[Number(i)])
|
||||
.replace(/\u0000(\d+)\u0000/g, (_m, i: string) => codes[Number(i)]);
|
||||
}
|
||||
|
||||
function renderEmphasis(s: string): string {
|
||||
return s
|
||||
.replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, '<strong>$2</strong>')
|
||||
.replace(/(^|[^\w*])\*(?=\S)([\s\S]*?\S)\*(?!\*)/g, '$1<em>$2</em>')
|
||||
.replace(/(^|[^\w])_(?=\S)([\s\S]*?\S)_(?!\w)/g, '$1<em>$2</em>')
|
||||
.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<del>$1</del>');
|
||||
}
|
||||
|
||||
const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([^`\s]*)/;
|
||||
const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/;
|
||||
const RULE = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
|
||||
const LIST_ITEM = /^( *)([-*+]|\d{1,9}[.)])[ \t]+(.*)$/;
|
||||
const QUOTE = /^ {0,3}> ?(.*)$/;
|
||||
const TABLE_DELIM = /^ *\|? *:?-+:? *(\| *:?-+:? *)*\|? *$/;
|
||||
|
||||
function splitRow(line: string): string[] {
|
||||
let row = line.trim();
|
||||
if (row.startsWith('|')) row = row.slice(1);
|
||||
if (row.endsWith('|') && !row.endsWith('\\|')) row = row.slice(0, -1);
|
||||
return row.split(/(?<!\\)\|/).map((cell) => cell.trim().replace(/\\\|/g, '|'));
|
||||
}
|
||||
|
||||
function isBlockStart(line: string): boolean {
|
||||
return FENCE.test(line) || HEADING.test(line) || RULE.test(line) || QUOTE.test(line) || LIST_ITEM.test(line);
|
||||
}
|
||||
|
||||
function renderList(lines: string[], start: number): { html: string; next: number } {
|
||||
const first = LIST_ITEM.exec(lines[start])!;
|
||||
const indent = first[1].length;
|
||||
const ordered = /\d/.test(first[2]);
|
||||
const startNum = ordered ? parseInt(first[2], 10) : 1;
|
||||
const items: string[] = [];
|
||||
let i = start;
|
||||
while (i < lines.length) {
|
||||
const m = LIST_ITEM.exec(lines[i]);
|
||||
if (!m || m[1].length !== indent || /\d/.test(m[2]) !== ordered) break;
|
||||
// The item's own lines: continuation text and deeper-indented blocks.
|
||||
const body: string[] = [m[3]];
|
||||
i += 1;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (line.trim() === '') {
|
||||
const nextLine = lines[i + 1];
|
||||
if (nextLine !== undefined && /^\s+/.test(nextLine) && nextLine.search(/\S/) > indent) {
|
||||
body.push('');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const sub = LIST_ITEM.exec(line);
|
||||
if (sub && sub[1].length <= indent) break;
|
||||
if (!sub && line.search(/\S/) <= indent && isBlockStart(line)) break;
|
||||
body.push(line.slice(Math.min(line.search(/\S/), indent + 2)));
|
||||
i += 1;
|
||||
}
|
||||
let content = body.join('\n');
|
||||
let task = '';
|
||||
const box = /^\[([ xX])\][ \t]+/.exec(content);
|
||||
if (box) {
|
||||
// A glyph, not an <input>: the mail sanitiser drops form controls.
|
||||
task = box[1] === ' ' ? '☐ ' : '☑ ';
|
||||
content = content.slice(box[0].length);
|
||||
}
|
||||
const inner = renderBlocks(content.split('\n'), true);
|
||||
items.push(`<li${task ? ' class="task"' : ''}>${task}${inner}</li>`);
|
||||
if (lines[i] !== undefined && lines[i].trim() === '') {
|
||||
// A blank line between items keeps the list going.
|
||||
const after = LIST_ITEM.exec(lines[i + 1] ?? '');
|
||||
if (after && after[1].length === indent) i += 1;
|
||||
}
|
||||
}
|
||||
const tag = ordered ? 'ol' : 'ul';
|
||||
const startAttr = ordered && startNum !== 1 ? ` start="${startNum}"` : '';
|
||||
return { html: `<${tag}${startAttr}>${items.join('')}</${tag}>`, next: i };
|
||||
}
|
||||
|
||||
function renderBlocks(lines: string[], tight = false): string {
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (line.trim() === '') { i += 1; continue; }
|
||||
|
||||
const fence = FENCE.exec(line);
|
||||
if (fence) {
|
||||
const marker = fence[1];
|
||||
const code: string[] = [];
|
||||
i += 1;
|
||||
while (i < lines.length && !lines[i].trimStart().startsWith(marker)) {
|
||||
code.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
i += 1;
|
||||
const lang = fence[2] ? ` class="language-${escapeHtml(fence[2])}"` : '';
|
||||
out.push(`<pre><code${lang}>${escapeHtml(code.join('\n'))}</code></pre>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = HEADING.exec(line);
|
||||
if (heading) {
|
||||
const level = heading[1].length;
|
||||
out.push(`<h${level}>${renderInline(heading[2] ?? '')}</h${level}>`);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RULE.test(line)) {
|
||||
out.push('<hr>');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (QUOTE.test(line)) {
|
||||
const quoted: string[] = [];
|
||||
while (i < lines.length && lines[i].trim() !== '') {
|
||||
const q = QUOTE.exec(lines[i]);
|
||||
quoted.push(q ? q[1] : lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
out.push(`<blockquote>${renderBlocks(quoted)}</blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (LIST_ITEM.test(line)) {
|
||||
const list = renderList(lines, i);
|
||||
out.push(list.html);
|
||||
i = list.next;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^ {4,}\S/.test(line)) {
|
||||
const code: string[] = [];
|
||||
while (i < lines.length && (/^ {4}/.test(lines[i]) || lines[i].trim() === '')) {
|
||||
code.push(lines[i].slice(4));
|
||||
i += 1;
|
||||
}
|
||||
while (code.length && code[code.length - 1].trim() === '') code.pop();
|
||||
out.push(`<pre><code>${escapeHtml(code.join('\n'))}</code></pre>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.includes('|') && i + 1 < lines.length && TABLE_DELIM.test(lines[i + 1]) && lines[i + 1].includes('-')) {
|
||||
const head = splitRow(line);
|
||||
const align = splitRow(lines[i + 1]).map((cell) => {
|
||||
const left = cell.startsWith(':');
|
||||
const right = cell.endsWith(':');
|
||||
if (left && right) return 'center';
|
||||
if (right) return 'right';
|
||||
return left ? 'left' : '';
|
||||
});
|
||||
const cell = (tag: string, text: string, col: number) => {
|
||||
const a = align[col] ? ` style="text-align:${align[col]}"` : '';
|
||||
return `<${tag}${a}>${renderInline(text)}</${tag}>`;
|
||||
};
|
||||
const rows: string[] = [];
|
||||
i += 2;
|
||||
while (i < lines.length && lines[i].trim() !== '' && lines[i].includes('|')) {
|
||||
const cells = splitRow(lines[i]);
|
||||
rows.push(`<tr>${head.map((_h, col) => cell('td', cells[col] ?? '', col)).join('')}</tr>`);
|
||||
i += 1;
|
||||
}
|
||||
out.push(`<table><thead><tr>${head.map((h, col) => cell('th', h, col)).join('')}</tr></thead>`
|
||||
+ `<tbody>${rows.join('')}</tbody></table>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paragraph: until a blank line or another block; a setext underline
|
||||
// turns it into a heading.
|
||||
const para: string[] = [];
|
||||
while (i < lines.length && lines[i].trim() !== '' && (para.length === 0 || !isBlockStart(lines[i]))) {
|
||||
if (para.length > 0 && /^ {0,3}(=+|-+)[ \t]*$/.test(lines[i])) break;
|
||||
para.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
const setext = i < lines.length ? /^ {0,3}(=+|-+)[ \t]*$/.exec(lines[i]) : null;
|
||||
if (setext && para.length > 0) {
|
||||
const level = setext[1].startsWith('=') ? 1 : 2;
|
||||
out.push(`<h${level}>${renderInline(para.join('\n').trim())}</h${level}>`);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const text = renderInline(para.map((l) => l.replace(/^ {1,3}/, '')).join('\n'));
|
||||
// List items keep their text bare, as in a tight list.
|
||||
out.push(tight ? text : `<p>${text}</p>`);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
const STYLE = `
|
||||
body{font-family:-apple-system,system-ui,Roboto,sans-serif;font-size:15px;line-height:1.55;word-wrap:break-word}
|
||||
h1,h2{border-bottom:1px solid rgba(128,128,128,.3);padding-bottom:.25em}
|
||||
h1{font-size:1.6em}h2{font-size:1.35em}h3{font-size:1.15em}
|
||||
h1,h2,h3,h4,h5,h6{margin:1.1em 0 .5em;line-height:1.25}
|
||||
p,ul,ol,blockquote,pre,table{margin:0 0 .9em}
|
||||
ul,ol{padding-left:1.6em}li>p{margin:0 0 .4em}li.task{list-style:none;margin-left:-1.3em}
|
||||
code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:.9em;background:rgba(128,128,128,.15);padding:.1em .3em;border-radius:4px}
|
||||
pre{background:rgba(128,128,128,.12);padding:.8em;border-radius:6px;overflow-x:auto}
|
||||
pre code{background:none;padding:0;font-size:.85em}
|
||||
blockquote{margin-left:0;padding-left:1em;border-left:4px solid rgba(128,128,128,.4);color:rgba(128,128,128,1)}
|
||||
table{border-collapse:collapse;display:block;overflow-x:auto}
|
||||
th,td{border:1px solid rgba(128,128,128,.35);padding:.35em .7em}th{background:rgba(128,128,128,.1)}
|
||||
hr{border:0;border-top:1px solid rgba(128,128,128,.35);margin:1.4em 0}
|
||||
img{max-width:100%}
|
||||
`;
|
||||
|
||||
/** A Markdown file as an HTML document for the preview. */
|
||||
export function markdownToHtml(markdown: string): string {
|
||||
const lines = markdown.replace(/\r\n?/g, '\n').replace(/\t/g, ' ').split('\n');
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><style>${STYLE}</style></head><body>${renderBlocks(lines)}</body></html>`;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// The pages the file preview shows images and media in. Each is written next
|
||||
// to the downloaded file and loaded from there, so the WebView reads the
|
||||
// file by a relative URL and nothing else on the device. They post 'error'
|
||||
// when the WebView can't decode the file, so the preview can offer another
|
||||
// app instead of a blank page or a dead player.
|
||||
|
||||
export interface ViewerColors {
|
||||
/** The app's theme, so the WebView draws its own controls to match. */
|
||||
scheme: 'light' | 'dark';
|
||||
background: string;
|
||||
text: string;
|
||||
textMuted: string;
|
||||
surface: string;
|
||||
}
|
||||
|
||||
const ESCAPES: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (ch) => ESCAPES[ch]);
|
||||
}
|
||||
|
||||
/** A file name as a relative URL: every reserved character encoded. */
|
||||
export function relativeFileUrl(fileName: string): string {
|
||||
return encodeURIComponent(fileName).replace(/[!'()*]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`);
|
||||
}
|
||||
|
||||
const REPORT = `function report(m){try{window.ReactNativeWebView.postMessage(m)}catch(e){}}`;
|
||||
|
||||
function page(title: string, colors: ViewerColors, viewport: string, style: string, body: string): string {
|
||||
return '<!doctype html><html><head><meta charset="utf-8">'
|
||||
+ `<meta name="viewport" content="${viewport}">`
|
||||
+ `<title>${escapeHtml(title)}</title>`
|
||||
+ `<style>:root{color-scheme:${colors.scheme}}html,body{margin:0;height:100%;background:${colors.background};color:${colors.text};`
|
||||
+ `font-family:-apple-system,system-ui,Roboto,sans-serif;-webkit-user-select:none;user-select:none}${style}</style>`
|
||||
+ `</head><body>${body}</body></html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* An image fitted to the screen, with the WebView's own pinch zoom. Used on
|
||||
* Android, where React Native's Image can't zoom, and for SVG and ICO, which
|
||||
* it can't draw. SVG is shown through <img>, so its scripts never run and it
|
||||
* loads nothing from the network.
|
||||
*/
|
||||
export function imageViewerHtml(fileName: string, colors: ViewerColors): string {
|
||||
// Raster icons scaled up stay crisp instead of blurring.
|
||||
const fillClass = /\.(ico|bmp|gif|png)$/i.test(fileName) ? 'fill pixel' : 'fill';
|
||||
return page(
|
||||
fileName,
|
||||
colors,
|
||||
'width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=10,user-scalable=yes',
|
||||
'body{display:flex;align-items:center;justify-content:center;box-sizing:border-box;padding:16px}'
|
||||
+ 'img{display:block;max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain}'
|
||||
// A vector or a tiny icon has no useful natural size; fill the screen.
|
||||
+ 'img.fill{width:100%;height:100%}'
|
||||
+ 'img.pixel{image-rendering:pixelated}',
|
||||
`<img id="i" alt="${escapeHtml(fileName)}" src="${relativeFileUrl(fileName)}">`
|
||||
+ `<script>${REPORT}var i=document.getElementById('i');`
|
||||
+ `i.addEventListener('error',function(){report('error')});`
|
||||
+ `i.addEventListener('load',function(){var s=Math.min(innerWidth,innerHeight)/3;`
|
||||
+ `if(!i.naturalWidth||i.naturalWidth<s&&i.naturalHeight<s){i.className='${fillClass}'}`
|
||||
+ `report('loaded')});</script>`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The platform's own player for a video or a sound. A video fills the
|
||||
* screen on black, with fullscreen and picture in picture where the WebView
|
||||
* offers them; a sound gets a card with its name. Playback starts at once.
|
||||
*/
|
||||
export function mediaViewerHtml(kind: 'video' | 'audio', fileName: string, colors: ViewerColors): string {
|
||||
const src = relativeFileUrl(fileName);
|
||||
const script = `<script>${REPORT}var m=document.getElementById('m');`
|
||||
+ `function failed(){if(!m.currentSrc||m.error||m.networkState===3)report('error')}`
|
||||
+ `m.addEventListener('error',failed,true);`
|
||||
// A file whose container plays but has no track the WebView decodes
|
||||
// reports a 0x0 video; offer another app for that too.
|
||||
+ `m.addEventListener('loadedmetadata',function(){if(m.tagName==='VIDEO'&&!m.videoWidth&&!m.duration)report('error');else report('loaded')});`
|
||||
+ `</script>`;
|
||||
if (kind === 'video') {
|
||||
return page(
|
||||
fileName,
|
||||
{ ...colors, scheme: 'dark', background: '#000' },
|
||||
'width=device-width,initial-scale=1',
|
||||
'body{display:flex;align-items:center;justify-content:center}video{width:100%;height:100%;object-fit:contain;background:#000}',
|
||||
`<video id="m" src="${src}" controls autoplay playsinline preload="auto"></video>${script}`,
|
||||
);
|
||||
}
|
||||
const note = '<svg width="72" height="72" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" '
|
||||
+ 'stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
|
||||
+ '<circle cx="6" cy="17" r="3"/><circle cx="16" cy="15" r="3"/><path d="M9 17V5l10-2v12"/><path d="M9 9l10-2"/></svg>';
|
||||
return page(
|
||||
fileName,
|
||||
colors,
|
||||
'width=device-width,initial-scale=1',
|
||||
'body{display:flex;align-items:center;justify-content:center;box-sizing:border-box;padding:24px}'
|
||||
+ `.card{width:100%;max-width:480px;display:flex;flex-direction:column;align-items:center;gap:20px}`
|
||||
+ `.art{width:160px;height:160px;border-radius:24px;background:${colors.surface};color:${colors.textMuted};`
|
||||
+ 'display:flex;align-items:center;justify-content:center}'
|
||||
+ '.name{font-size:17px;font-weight:600;text-align:center;word-break:break-word}'
|
||||
+ 'audio{width:100%}',
|
||||
`<div class="card"><div class="art">${note}</div><div class="name">${escapeHtml(fileName)}</div>`
|
||||
+ `<audio id="m" src="${src}" controls autoplay preload="auto"></audio></div>${script}`,
|
||||
);
|
||||
}
|
||||
@@ -96,6 +96,7 @@ import {
|
||||
truncateRecurrenceRules,
|
||||
} from '../lib/recurrence-overrides';
|
||||
import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from '../lib/birthday-calendar';
|
||||
import { moveCalendar } from '../lib/calendar-sort';
|
||||
import { useContactsStore } from '../stores/contacts-store';
|
||||
import { useLocaleStore } from '../stores/locale-store';
|
||||
import { useUserCalendarAddresses } from '../lib/calendar-user-addresses';
|
||||
@@ -262,6 +263,7 @@ export default function CalendarScreen() {
|
||||
const setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar);
|
||||
const createCalendar = useCalendarStore((s) => s.createCalendar);
|
||||
const updateCalendar = useCalendarStore((s) => s.updateCalendar);
|
||||
const reorderCalendars = useCalendarStore((s) => s.reorderCalendars);
|
||||
const removeCalendar = useCalendarStore((s) => s.removeCalendar);
|
||||
const clearCalendarEvents = useCalendarStore((s) => s.clearCalendarEvents);
|
||||
const shareCalendar = useCalendarStore((s) => s.shareCalendar);
|
||||
@@ -954,6 +956,14 @@ export default function CalendarScreen() {
|
||||
[setSharedCalendarColor, updateCalendar, reportError],
|
||||
);
|
||||
|
||||
const handleMoveCalendar = React.useCallback(
|
||||
(cal: Calendar, targetId: string) => {
|
||||
const orderedIds = moveCalendar(storeCalendars, cal.id, targetId);
|
||||
if (orderedIds) reorderCalendars(orderedIds).catch(reportError);
|
||||
},
|
||||
[storeCalendars, reorderCalendars, reportError],
|
||||
);
|
||||
|
||||
const handleClearCalendar = React.useCallback(
|
||||
(cal: Calendar) => {
|
||||
Alert.alert(
|
||||
@@ -1277,6 +1287,7 @@ export default function CalendarScreen() {
|
||||
onShare={(cal) => { setSidebarVisible(false); setShareTarget(cal); }}
|
||||
onClear={handleClearCalendar}
|
||||
onDelete={handleDeleteCalendar}
|
||||
onMove={handleMoveCalendar}
|
||||
isSubscriptionCalendar={isSubscriptionCalendar}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View, Text, StyleSheet, TextInput, Pressable, ScrollView,
|
||||
Keyboard, Platform, ActivityIndicator, Alert, Modal, Switch, useWindowDimensions,
|
||||
Keyboard, Platform, ActivityIndicator, Alert, Modal, useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { usePreventRemove } from '@react-navigation/native';
|
||||
@@ -17,7 +17,6 @@ import DateTimePicker, { type DateTimePickerEvent } from '@react-native-communit
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as DocumentPicker from 'expo-document-picker';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
import * as Clipboard from 'expo-clipboard';
|
||||
import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
@@ -43,7 +42,7 @@ import { toast } from '../stores/toast-store';
|
||||
import { type EmailTemplate } from '../stores/templates-store';
|
||||
import { getIdentities } from '../api/identity';
|
||||
import {
|
||||
sendEmail, createDraft, destroyEmails, patchKeywordsForEmails, type OutgoingAttachment, type OutgoingEmail,
|
||||
sendEmail, saveDraftVersion, destroyEmails, patchKeywordsForEmails, type OutgoingAttachment, type OutgoingEmail,
|
||||
} from '../api/email';
|
||||
import { jmapClient, RequestTimeoutError, ScheduleTooLateError } from '../api/jmap-client';
|
||||
import { uploadBlob, uploadBytes } from '../api/blob';
|
||||
@@ -64,6 +63,9 @@ import {
|
||||
findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity,
|
||||
} from '../lib/reply-identity';
|
||||
import { shouldBlockEditorRemoteImages } from '../lib/editor-html';
|
||||
import {
|
||||
initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs, withSnapshotBody,
|
||||
} from '../lib/draft-part-blobs';
|
||||
import {
|
||||
signatureIdentityFor, buildEmbeddedSignatureHtml, containsEmbeddedSignature, spliceSignature,
|
||||
insertSignatureAboveQuote, getPlainTextSignature, appendPlainTextSignature,
|
||||
@@ -79,6 +81,9 @@ import { sanitizeDisplayName } from '../lib/rfc5322-mailbox';
|
||||
import type { EmailAddress, Identity } from '../api/types';
|
||||
import type { RootStackParamList } from '../navigation/types';
|
||||
import { MODAL_ORIENTATIONS } from '../lib/modal-orientations';
|
||||
import ToggleSwitch from '../components/ToggleSwitch';
|
||||
import { AttachmentPreviewModal } from '../components/email/AttachmentPreviewModal';
|
||||
import { useFilePreview } from '../components/email/use-file-preview';
|
||||
|
||||
type Props = NativeStackScreenProps<RootStackParamList, 'Compose'>;
|
||||
|
||||
@@ -394,6 +399,7 @@ function OptionsSheet({
|
||||
}
|
||||
|
||||
export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const filePreview = useFilePreview();
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
@@ -672,10 +678,29 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const subjectRef = React.useRef<TextInput>(null);
|
||||
const plainBodyRef = React.useRef<TextInput>(null);
|
||||
const [subjectFocused, setSubjectFocused] = React.useState(false);
|
||||
|
||||
// The composer's ScrollView and its live offset. On Android the editor page
|
||||
// hands back the caret-reveal pans it undoes (see `pinVisualViewport` in
|
||||
// editor-html); scrolling the composer by the same amount keeps the caret
|
||||
// where the pan had put it, without leaving the body drawn shifted up.
|
||||
const bodyScrollRef = React.useRef<ScrollView>(null);
|
||||
const bodyScrollY = React.useRef(0);
|
||||
const onEditorViewportShift = React.useCallback((dy: number) => {
|
||||
const y = Math.max(0, bodyScrollY.current + dy);
|
||||
bodyScrollY.current = y;
|
||||
bodyScrollRef.current?.scrollTo({ y, animated: false });
|
||||
}, []);
|
||||
const isPickingSuggestion = React.useRef(false);
|
||||
// Track inline-image placeholders that haven't yet been rewritten to cid:
|
||||
// until send time. Maps cid → blobId/type/name/size.
|
||||
const inlineRegistryRef = React.useRef<Map<string, AttachmentEntry>>(new Map());
|
||||
// A re-opened draft's attachments and inline images are parts of that
|
||||
// draft: their blobIds die with each version a save replaces (#849), and an
|
||||
// upload expires once nothing references it. This maps the blobId each
|
||||
// entry holds to the matching part of the latest saved version, which
|
||||
// `buildOutgoing` sends. Kept out of the attachment state so the dirty
|
||||
// snapshot does not change on every save.
|
||||
const draftPartBlobsRef = React.useRef(initialDraftPartBlobState());
|
||||
|
||||
const getAutocomplete = useContactsStore((s) => s.getAutocomplete);
|
||||
const getGroupRecipients = useContactsStore((s) => s.getGroupRecipients);
|
||||
@@ -835,6 +860,10 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
}
|
||||
}
|
||||
if (cancelled || map.size === 0) return;
|
||||
// No change message since the editor loaded = the user has not typed
|
||||
// yet, so the editor still holds the seeded body (normalised by the
|
||||
// WebView DOM, which is why it can differ from the raw server HTML).
|
||||
const untouched = !plainTextMode && latestRef.current.bodyHtml === initialBodyHtml;
|
||||
let live = latestRef.current.bodyHtml;
|
||||
try {
|
||||
live = (await editorRef.current?.getHtml()) ?? live;
|
||||
@@ -845,8 +874,16 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
editorRef.current?.setHtml(next);
|
||||
}
|
||||
// The hydrated images are part of the untouched baseline, not an edit.
|
||||
baselineRef.current = replaceInlineImagePlaceholders(baselineRef.current, map);
|
||||
if (lastSavedRef.current) lastSavedRef.current = replaceInlineImagePlaceholders(lastSavedRef.current, map);
|
||||
if (untouched) {
|
||||
// Rebase on the body the composer now holds: comparing it with the
|
||||
// raw server HTML made every re-opened draft with inline images look
|
||||
// edited, and the autosave replaced (and destroyed) it unasked.
|
||||
baselineRef.current = withSnapshotBody(baselineRef.current, next);
|
||||
if (lastSavedRef.current) lastSavedRef.current = withSnapshotBody(lastSavedRef.current, next);
|
||||
} else {
|
||||
baselineRef.current = replaceInlineImagePlaceholders(baselineRef.current, map);
|
||||
if (lastSavedRef.current) lastSavedRef.current = replaceInlineImagePlaceholders(lastSavedRef.current, map);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -1228,7 +1265,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
.map((cid) => inlineRegistryRef.current.get(cid))
|
||||
.filter((e): e is AttachmentEntry => !!e && !!e.blobId && !e.error)
|
||||
.map<OutgoingAttachment>((e) => ({
|
||||
blobId: e.blobId!,
|
||||
blobId: liveBlobId(draftPartBlobsRef.current, e.blobId!),
|
||||
type: e.type,
|
||||
name: e.name,
|
||||
size: e.size,
|
||||
@@ -1239,7 +1276,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
const fileAttachments = attachments
|
||||
.filter((a) => !a.inline && a.blobId && !a.error)
|
||||
.map<OutgoingAttachment>((a) => ({
|
||||
blobId: a.blobId!,
|
||||
blobId: liveBlobId(draftPartBlobsRef.current, a.blobId!),
|
||||
type: a.type,
|
||||
name: a.name,
|
||||
size: a.size,
|
||||
@@ -1299,8 +1336,14 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
setDraftStatus('saving');
|
||||
try {
|
||||
const outgoing = buildOutgoing(identity, html, { forDraft: true });
|
||||
const id = await createDraft(outgoing, draftsMailbox.id, draftIdRef.current ?? undefined);
|
||||
const saved = await saveDraftVersion(outgoing, draftsMailbox.id, draftIdRef.current ?? undefined);
|
||||
const id = saved.id;
|
||||
draftIdRef.current = id;
|
||||
// The previous version and its part blobs are gone: point every
|
||||
// attachment at the parts of the version just created.
|
||||
draftPartBlobsRef.current = rebaseDraftPartBlobs(
|
||||
draftPartBlobsRef.current, outgoing.attachments ?? [], saved.attachments,
|
||||
);
|
||||
lastSavedRef.current = snapshot;
|
||||
lastSaveAtRef.current = Date.now();
|
||||
setDraftStatus('saved');
|
||||
@@ -1704,22 +1747,14 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
await insertInlineImages(result.assets);
|
||||
};
|
||||
|
||||
// Open a chip: local files straight from their URI, server blobs after a
|
||||
// download into the cache.
|
||||
// Open a chip in the file preview: local files from their URI, server
|
||||
// blobs (forwarded, from a draft) after a download.
|
||||
const previewAttachment = async (entry: AttachmentEntry) => {
|
||||
try {
|
||||
let uri = entry.uri;
|
||||
if (!uri && entry.blobId) {
|
||||
const buf = await jmapClient.fetchBlobArrayBuffer(entry.blobId, entry.name, entry.type);
|
||||
const dir = FileSystem.cacheDirectory ?? '';
|
||||
uri = `${dir}${entry.localId}-${entry.name.replace(/[^\w.-]+/g, '_')}`;
|
||||
await FileSystem.writeAsStringAsync(uri, bytesToBase64(new Uint8Array(buf)), {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
}
|
||||
if (!uri) return;
|
||||
if (await Sharing.isAvailableAsync()) {
|
||||
await Sharing.shareAsync(uri, { mimeType: entry.type, dialogTitle: entry.name });
|
||||
if (entry.uri) {
|
||||
await filePreview.open({ from: 'local', uri: entry.uri, name: entry.name, type: entry.type, size: entry.size });
|
||||
} else if (entry.blobId) {
|
||||
await filePreview.open({ from: 'blob', blobId: entry.blobId, name: entry.name, type: entry.type, size: entry.size });
|
||||
}
|
||||
} catch (err) {
|
||||
Alert.alert(t('email_composer.attach', 'Attach'), err instanceof Error ? err.message : String(err));
|
||||
@@ -2362,9 +2397,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
|
||||
<View style={[styles.flex, { paddingBottom: bottomPad }]}>
|
||||
<ScrollView
|
||||
ref={bodyScrollRef}
|
||||
style={styles.flex}
|
||||
keyboardShouldPersistTaps="always"
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
onScroll={(e) => { bodyScrollY.current = e.nativeEvent.contentOffset.y; }}
|
||||
scrollEventThrottle={16}
|
||||
>
|
||||
<View style={styles.fieldRow}>
|
||||
<Text style={[styles.fieldLabel, fieldLabelWidth]} numberOfLines={1}>{t('email_composer.from', 'From')}</Text>
|
||||
@@ -2455,6 +2493,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
placeholder={t('email_composer.body_placeholder', 'Write your message...')}
|
||||
onChange={setBodyHtml}
|
||||
onSelectionChange={setSelState}
|
||||
onViewportShift={onEditorViewportShift}
|
||||
/>
|
||||
)}
|
||||
</ScrollView>
|
||||
@@ -2861,7 +2900,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
)}
|
||||
<View style={styles.switchRow}>
|
||||
<Text style={styles.switchLabel}>{t('email_composer.from_override.toggle_off', 'Override')}</Text>
|
||||
<Switch
|
||||
<ToggleSwitch
|
||||
value={overrideEnabled}
|
||||
onValueChange={setOverrideEnabled}
|
||||
accessibilityLabel={t('email_composer.from_override.toggle_off', 'Override')}
|
||||
@@ -2914,6 +2953,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
||||
selectedIdentityId={selectedIdentityId}
|
||||
onPick={(identity) => { setSelectedIdentityId(identity.id); setFromOverride(null); }}
|
||||
/>
|
||||
|
||||
<AttachmentPreviewModal {...filePreview.modalProps} />
|
||||
</SafeAreaView>
|
||||
);
|
||||
if (!isSplit) return composer;
|
||||
|
||||
@@ -1982,7 +1982,7 @@ function makeStyles(c: ThemePalette) {
|
||||
sectionBody: { gap: spacing.sm, paddingLeft: spacing.lg + spacing.xs },
|
||||
|
||||
field: { gap: 4 },
|
||||
fieldLabel: { ...typography.caption, color: c.textMuted },
|
||||
fieldLabel: { ...typography.caption, color: c.mutedForeground },
|
||||
row2: { flexDirection: 'row', gap: spacing.sm },
|
||||
|
||||
input: {
|
||||
@@ -1990,7 +1990,8 @@ function makeStyles(c: ThemePalette) {
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: radius.sm,
|
||||
backgroundColor: c.surface,
|
||||
// The shared Input: transparent, bordered (webmail contact form).
|
||||
backgroundColor: 'transparent',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
...typography.body,
|
||||
|
||||
@@ -758,7 +758,7 @@ function makeStyles(c: ThemePalette) {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: c.borderLight,
|
||||
},
|
||||
sectionHeaderText: { ...typography.small, color: c.primary },
|
||||
sectionHeaderText: { ...typography.captionMedium, color: c.mutedForeground },
|
||||
|
||||
separator: { height: 1, backgroundColor: c.borderLight, marginLeft: 68 },
|
||||
|
||||
|
||||
@@ -416,7 +416,9 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
<Text style={styles.headerTitle} numberOfLines={1} accessibilityRole="header">{tabLabel}</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView ref={paneScrollRef} style={styles.scrollArea} contentContainerStyle={styles.detailContent}>
|
||||
{/* Keyed per page: React would otherwise reuse the list's ScrollView
|
||||
and open the page at the list's scroll offset. */}
|
||||
<ScrollView key={`page-${selectedTab}`} ref={paneScrollRef} style={styles.scrollArea} contentContainerStyle={styles.detailContent}>
|
||||
<View ref={paneContentRef}>
|
||||
<SearchHighlightContext.Provider value={searchHighlight}>
|
||||
{scopeBanner}
|
||||
@@ -458,6 +460,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
key="list"
|
||||
style={styles.scrollArea}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../../api/calendar', () => ({
|
||||
getCalendars: vi.fn(),
|
||||
updateCalendar: vi.fn(),
|
||||
createCalendar: vi.fn(),
|
||||
isCalendarAccessDenied: vi.fn(() => false),
|
||||
noteCalendarAccessError: vi.fn(() => false),
|
||||
resetCalendarAccessDenied: vi.fn(),
|
||||
supportsSyntheticCalendarIds: vi.fn(async () => false),
|
||||
resetSyntheticIdSupport: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: { accountId: 'acc-1', isConnected: true },
|
||||
}));
|
||||
|
||||
vi.mock('../../device-sync/app/request', () => ({ requestDeviceSync: vi.fn() }));
|
||||
|
||||
import * as calendarApi from '../../api/calendar';
|
||||
import { useCalendarStore } from '../calendar-store';
|
||||
import type { Calendar } from '../../api/types';
|
||||
|
||||
const mockGetCalendars = calendarApi.getCalendars as ReturnType<typeof vi.fn>;
|
||||
const mockUpdateCalendar = calendarApi.updateCalendar as ReturnType<typeof vi.fn>;
|
||||
|
||||
const cal = (overrides: Partial<Calendar>): Calendar => ({ id: 'c', name: 'Cal', sortOrder: 0, ...overrides });
|
||||
const names = () => useCalendarStore.getState().calendars.map((c) => c.name);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useCalendarStore.setState({ calendars: [], error: null });
|
||||
});
|
||||
|
||||
describe('calendar order', () => {
|
||||
it('fetchCalendars lists the calendars by sortOrder, not in server order', async () => {
|
||||
mockGetCalendars.mockResolvedValue([
|
||||
cal({ id: 'b', name: 'Default', sortOrder: 20 }),
|
||||
cal({ id: 'n', name: 'Holidays', sortOrder: 30 }),
|
||||
cal({ id: 'e', name: 'Work', sortOrder: 1 }),
|
||||
]);
|
||||
|
||||
await useCalendarStore.getState().fetchCalendars();
|
||||
|
||||
expect(names()).toEqual(['Work', 'Default', 'Holidays']);
|
||||
});
|
||||
|
||||
it('reorderCalendars writes only the sortOrders that change, to the owning account', async () => {
|
||||
useCalendarStore.setState({
|
||||
calendars: [
|
||||
cal({ id: 'a', name: 'A', sortOrder: 1 }),
|
||||
cal({ id: 'b', name: 'B', sortOrder: 2 }),
|
||||
cal({ id: 'x:c', originalId: 'c', name: 'C', sortOrder: 0, isShared: true, accountId: 'x' }),
|
||||
],
|
||||
});
|
||||
mockUpdateCalendar.mockResolvedValue(undefined);
|
||||
|
||||
const done = useCalendarStore.getState().reorderCalendars(['b', 'a']);
|
||||
expect(names()).toEqual(['B', 'A', 'C']);
|
||||
await done;
|
||||
|
||||
expect(mockUpdateCalendar.mock.calls).toEqual([
|
||||
['b', { sortOrder: 1 }, undefined],
|
||||
['a', { sortOrder: 2 }, undefined],
|
||||
]);
|
||||
});
|
||||
|
||||
it('after a failed write, shows the order the server holds and rethrows', async () => {
|
||||
useCalendarStore.setState({
|
||||
calendars: [
|
||||
cal({ id: 'a', name: 'A', sortOrder: 1 }),
|
||||
cal({ id: 'b', name: 'B', sortOrder: 2 }),
|
||||
cal({ id: 'c', name: 'C', sortOrder: 3 }),
|
||||
],
|
||||
});
|
||||
mockUpdateCalendar.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('forbidden'));
|
||||
|
||||
await expect(useCalendarStore.getState().reorderCalendars(['c', 'a', 'b'])).rejects.toThrow('forbidden');
|
||||
|
||||
expect(useCalendarStore.getState().calendars.map((c) => [c.id, c.sortOrder])).toEqual([
|
||||
['a', 1], ['c', 1], ['b', 2],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
import { findTasksOnlyCalendarIds, isTaskLikeObject } from '../lib/calendar-component-detection';
|
||||
import { mergeRangeEvents, mergedSpanLimit, planRangeLoad, sameRange } from '../lib/calendar-range-cache';
|
||||
import { requestDeviceSync } from '../device-sync/app/request';
|
||||
import { sortCalendars, sortOrderUpdates } from '../lib/calendar-sort';
|
||||
import { CALENDAR_AUTHORITY } from '../device-sync/types';
|
||||
|
||||
// extendRange merges up to twice the requested range (at least half a year,
|
||||
@@ -266,6 +267,8 @@ export interface CalendarState {
|
||||
importEvents: (events: Partial<CalendarEvent>[], calendarId: string) => Promise<ImportResult>;
|
||||
createCalendar: (name: string, color?: string, description?: string) => Promise<Calendar>;
|
||||
updateCalendar: (id: string, updates: CalendarUpdates) => Promise<void>;
|
||||
// Store `orderedIds` in that order by renumbering their sortOrder.
|
||||
reorderCalendars: (orderedIds: string[]) => Promise<void>;
|
||||
removeCalendar: (id: string) => Promise<void>;
|
||||
clearCalendarEvents: (id: string) => Promise<number>;
|
||||
shareCalendar: (id: string, principalId: string, rights: CalendarRights | null) => Promise<void>;
|
||||
@@ -572,7 +575,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
if (calendarsInFlight) return calendarsInFlight;
|
||||
calendarsInFlight = (async () => {
|
||||
try {
|
||||
const calendars = (await fetchCalendars()) ?? [];
|
||||
const calendars = sortCalendars((await fetchCalendars()) ?? []);
|
||||
set({ calendars });
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : 'Failed to load calendars' });
|
||||
@@ -1013,7 +1016,7 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
|
||||
createCalendar: async (name, color, description) => {
|
||||
const created = await apiCreateCalendar(name, color, description);
|
||||
set({ calendars: [...get().calendars, created] });
|
||||
set({ calendars: sortCalendars([...get().calendars, created]) });
|
||||
requestDeviceSync(CALENDAR_AUTHORITY);
|
||||
return created;
|
||||
},
|
||||
@@ -1022,11 +1025,36 @@ export const useCalendarStore = create<CalendarState>()(
|
||||
const cal = get().calendars.find((c) => c.id === id);
|
||||
await apiUpdateCalendar(cal?.originalId || id, updates, cal?.accountId);
|
||||
set({
|
||||
calendars: get().calendars.map((c) => (c.id === id ? { ...c, ...updates } as Calendar : c)),
|
||||
calendars: sortCalendars(get().calendars.map((c) => (c.id === id ? { ...c, ...updates } as Calendar : c))),
|
||||
});
|
||||
requestDeviceSync(CALENDAR_AUTHORITY);
|
||||
},
|
||||
|
||||
reorderCalendars: async (orderedIds) => {
|
||||
const updates = sortOrderUpdates(get().calendars, orderedIds);
|
||||
if (updates.length === 0) return;
|
||||
const before = get().calendars;
|
||||
const withSortOrders = (sortOrders: Map<string, number>) => sortCalendars(before.map((c) =>
|
||||
sortOrders.has(c.id) ? { ...c, sortOrder: sortOrders.get(c.id)! } : c));
|
||||
// Optimistic, so the list moves at once.
|
||||
set({ calendars: withSortOrders(new Map(updates.map((u) => [u.id, u.sortOrder]))) });
|
||||
const saved = new Map<string, number>();
|
||||
try {
|
||||
for (const { id, sortOrder } of updates) {
|
||||
const cal = before.find((c) => c.id === id);
|
||||
await apiUpdateCalendar(cal?.originalId || id, { sortOrder }, cal?.accountId);
|
||||
saved.set(id, sortOrder);
|
||||
}
|
||||
} catch (err) {
|
||||
// Show the order the server now holds: the moves saved before the
|
||||
// failure stay, the rest are undone.
|
||||
set({ calendars: withSortOrders(saved) });
|
||||
throw err;
|
||||
} finally {
|
||||
if (saved.size > 0) requestDeviceSync(CALENDAR_AUTHORITY);
|
||||
}
|
||||
},
|
||||
|
||||
removeCalendar: async (id) => {
|
||||
const cal = get().calendars.find((c) => c.id === id);
|
||||
await apiDeleteCalendar(cal?.originalId || id, cal?.accountId);
|
||||
|
||||
Reference in New Issue
Block a user