diff --git a/src/components/RichTextEditor.tsx b/src/components/RichTextEditor.tsx index 79cee3e4..006c4eca 100644 --- a/src/components/RichTextEditor.tsx +++ b/src/components/RichTextEditor.tsx @@ -54,7 +54,10 @@ export interface RichTextEditorHandle { exec(command: RichTextCommand): void; insertLink(url: string, label?: string): void; unsetLink(): void; - insertImage(src: string, cid?: string, alt?: string): void; + /** `alt` null or left out: no alt attribute (not described yet). */ + insertImage(src: string, cid?: string, alt?: string | null): void; + /** Describe the inline image with this cid; "" marks it decorative. */ + setImageAlt(cid: string, alt: string): void; /** Insert an HTML fragment at the caret (template bodies, tables). */ insertHtml(html: string): void; setHtml(html: string): void; @@ -178,7 +181,10 @@ const RichTextEditor = React.forwardRef(function Ri call(`window.__rne && window.__rne.unsetLink()`); }, insertImage: (src, cid, alt) => { - call(`window.__rne && window.__rne.insertImage(${JSON.stringify(src)}, ${JSON.stringify(cid ?? '')}, ${JSON.stringify(alt ?? '')})`); + call(`window.__rne && window.__rne.insertImage(${JSON.stringify(src)}, ${JSON.stringify(cid ?? '')}, ${JSON.stringify(alt ?? null)})`); + }, + setImageAlt: (cid, alt) => { + call(`window.__rne && window.__rne.setImageAlt(${JSON.stringify(cid)}, ${JSON.stringify(alt)})`); }, insertHtml: (html) => { call(`window.__rne && window.__rne.insertHtml(${JSON.stringify(html)})`); diff --git a/src/lib/__tests__/compose-html.test.ts b/src/lib/__tests__/compose-html.test.ts index 65d99f51..1915fdb5 100644 --- a/src/lib/__tests__/compose-html.test.ts +++ b/src/lib/__tests__/compose-html.test.ts @@ -125,6 +125,15 @@ describe('htmlToPlainText with mail markup (the sent text part)', () => { it('keeps the signature delimiter', () => { expect(mail('

Hi

--

Jane

')).toBe('Hi\n\n-- \n\nJane'); }); + + it('writes a described image as its description and leaves decorative and undescribed ones out', () => { + const withAlt = (html: string) => htmlToPlainText(html, { mailMarkup: true, imageAlt: (alt) => `[Image: ${alt}]` }); + expect(withAlt('

Look a red & blue bike here

')).toBe('Look [Image: a red & blue bike] here'); + expect(withAlt('

ABC

')).toBe('ABC'); + expect(withAlt('

x <y>

')).toBe('[Image: x ]'); + // Without the option images stay out, as before. + expect(mail('

AbikeB

')).toBe('AB'); + }); }); describe('rewriteInlineImages', () => { diff --git a/src/lib/__tests__/editor-html.test.ts b/src/lib/__tests__/editor-html.test.ts index 95378f2c..cf0835d6 100644 --- a/src/lib/__tests__/editor-html.test.ts +++ b/src/lib/__tests__/editor-html.test.ts @@ -435,3 +435,65 @@ describe('collapsed reply quote (webmail #1405)', () => { expect(source).toMatch(/collapseQuote=\{collapseReplyQuote && editorSeedHtml === initialBodyHtml\}/); }); }); + +describe('image descriptions (webmail 53cb49d3c)', () => { + function boot() { + const inserted: string[] = []; + const images = [ + { attrs: { 'data-cid': 'a@x' } as Record }, + { attrs: { 'data-cid': 'b@x' } as Record }, + ].map((img) => ({ + ...img, + getAttribute(n: string) { return this.attrs[n] ?? null; }, + setAttribute(n: string, v: string) { this.attrs[n] = v; }, + })); + const editor = { + innerHTML: '', + get textContent() { return 'x'; }, + scrollHeight: 100, + querySelector: () => null, + setAttribute: () => undefined, + focus: () => undefined, + addEventListener: () => undefined, + getElementsByTagName: () => images, + }; + const document = { + activeElement: editor as unknown, + getElementById: () => editor, + addEventListener: () => undefined, + queryCommandState: () => false, + execCommand: (_cmd: string, _ui: boolean, html: string) => { inserted.push(html); return true; }, + }; + const window: Record = { + ReactNativeWebView: { postMessage: () => undefined }, + addEventListener: () => undefined, + getSelection: () => null, + }; + const script = extractScript(buildEditorHtml({ initialHtml: '


', placeholder: '', c: LIGHT_COLORS })); + new Function('window', 'document', script)(window, document); + return { rne: window.__rne as Record void>, inserted, images }; + } + + it('inserts an image without alt text until it is described, never with the file name', () => { + const page = boot(); + page.rne.insertImage('data:image/png;base64,AA', 'a@x', null); + expect(page.inserted[0]).toBe(''); + page.rne.insertImage('data:image/png;base64,AA', 'b@x', 'A "red" bike'); + expect(page.inserted[1]).toBe('A "red" bike'); + }); + + it('describes the inline image with that cid, or marks it decorative', () => { + const page = boot(); + page.rne.setImageAlt('b@x', 'a red bike'); + expect(page.images.map((i) => i.attrs.alt)).toEqual([undefined, 'a red bike']); + page.rne.setImageAlt('a@x', ''); + expect(page.images[0].attrs.alt).toBe(''); + }); + + it('asks for a description when the composer inserts an image, and writes it into the text part', () => { + const source = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8'); + expect(source).toMatch(/editorRef\.current\?\.insertImage\(dataUrl, cid, null\);\s+setAltQueue\(\(q\) => \[\.\.\.q, \{ cid, src: dataUrl \}\]\);/); + expect(source).toMatch(/editorRef\.current\?\.setImageAlt\(altTarget\.cid, alt\);/); + expect(source).toMatch(/imageAlt: \(alt\) => t\('email_composer\.image_alt\.plain_text', '\[Image: \{alt\}\]', \{ alt \}\)/); + }); +}); diff --git a/src/lib/compose-html.ts b/src/lib/compose-html.ts index 6848f645..a0135d73 100644 --- a/src/lib/compose-html.ts +++ b/src/lib/compose-html.ts @@ -88,6 +88,12 @@ export interface HtmlToPlainTextOptions { * quote. Use for the text/plain part of a sent message. */ mailMarkup?: boolean; + /** + * Write an image as this text ("[Image: a red bicycle]"), from its alt + * text. Images without alt text, and decorative ones (alt=""), are left + * out. Without it, images are left out. + */ + imageAlt?: (alt: string) => string; } // Private-use characters that stand in for converted quotes until the end. @@ -105,6 +111,7 @@ const INNERMOST_BLOCKQUOTE_RE = /]*>((?:(?! " in front // of every line, so a quote in a quote reads "> > ". @@ -121,6 +128,20 @@ export function htmlToPlainText(html: string, options: HtmlToPlainTextOptions = return expand(blocksToPlainText(s, true)); } +/** Each described `` replaced by its text, escaped as HTML text. */ +function writeImageAlt(html: string, imageAlt: (alt: string) => string): string { + // Quoted attribute values may hold a raw `>`. + return html.replace(/"']|"[^"]*"|'[^']*')*>/gi, (tag) => { + const m = tag.match(/\salt\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i); + const alt = (m?.[2] ?? m?.[3] ?? m?.[4] ?? '') + .replace(/"/gi, '"').replace(/'/gi, "'").replace(/</gi, '<').replace(/>/gi, '>').replace(/&/gi, '&') + .replace(/\s+/g, ' ') + .trim(); + if (!alt) return ''; + return imageAlt(alt).replace(/&/g, '&').replace(//g, '>'); + }); +} + function blocksToPlainText(html: string, mailMarkup: boolean): string { let s = html; // Drop non-content blocks entirely. diff --git a/src/lib/editor-html.ts b/src/lib/editor-html.ts index 70ca846a..40ba1e33 100644 --- a/src/lib/editor-html.ts +++ b/src/lib/editor-html.ts @@ -523,9 +523,11 @@ export function buildEditorHtml(opts: { insertImage: function (src, cid, alt) { editor.focus(); var safeSrc = (src || '').replace(/"/g, '"'); - var safeAlt = (alt || '').replace(/&/g, '&').replace(/"/g, '"'); + // No alt attribute at all until the user describes the image: alt="" + // would mark it decorative, and the file name tells nobody anything. + var safeAlt = typeof alt === 'string' ? alt.replace(/&/g, '&').replace(/"/g, '"') : null; var safeCid = cid ? cid.replace(/"/g, '"') : ''; - var html = '' + safeAlt + ''; try { document.execCommand('insertHTML', false, html); } catch (e) {} reportChange(); @@ -539,6 +541,14 @@ export function buildEditorHtml(opts: { }, focus: function () { editor.focus(); }, focusEnd: function () { focusEnd(editor); }, + // The description of the inline image with this cid; "" is decorative. + setImageAlt: function (cid, alt) { + var images = editor.getElementsByTagName('img'); + for (var i = 0; i < images.length; i++) { + if (images[i].getAttribute('data-cid') === cid) images[i].setAttribute('alt', alt); + } + reportChange(); + }, }; reportHeight(); diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index aeb50e9a..36185d1d 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { View, Text, StyleSheet, TextInput, Pressable, ScrollView, - Keyboard, Platform, ActivityIndicator, Alert, Modal, useWindowDimensions, + Keyboard, Platform, ActivityIndicator, Alert, Modal, useWindowDimensions, Image, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { usePreventRemove } from '@react-navigation/native'; @@ -1336,7 +1336,10 @@ export default function ComposeScreen({ route, navigation }: Props) { htmlBody = `
${safeHtml}
`; // The text part marks the quote with "> ", numbers ordered lists and // keeps the "-- " signature delimiter, as plain-text mail does. - textBody = htmlToPlainText(safeHtml, { mailMarkup: true }); + textBody = htmlToPlainText(safeHtml, { + mailMarkup: true, + imageAlt: (alt) => t('email_composer.image_alt.plain_text', '[Image: {alt}]', { alt }), + }); } const inlineFromBody = usedCids @@ -1392,7 +1395,7 @@ export default function ComposeScreen({ route, navigation }: Props) { requestReadReceipt, envelopeMailFrom, }; - }, [senderAddress, plainTextMode, plainBody, attachments, replyTo, draft, mode, finalTo, finalCc, finalBcc, subject, requestReadReceipt]); + }, [senderAddress, plainTextMode, plainBody, attachments, replyTo, draft, mode, finalTo, finalCc, finalBcc, subject, requestReadReceipt, t]); // Save one draft version (create, then destroy the previous one - #849). const saveDraftOnce = async (opts: { live: boolean }): Promise => { @@ -1937,7 +1940,9 @@ export default function ComposeScreen({ route, navigation }: Props) { Alert.alert(t('email_composer.attach', 'Attach'), t('email_composer.image_load_failed', 'Could not load image')); continue; } - editorRef.current?.insertImage(dataUrl, cid, fallbackName); + // Not the file name as alt text: the user is asked what it shows. + editorRef.current?.insertImage(dataUrl, cid, null); + setAltQueue((q) => [...q, { cid, src: dataUrl }]); const entry = addUploadEntry({ name: fallbackName, type: mime, @@ -2003,6 +2008,27 @@ export default function ComposeScreen({ route, navigation }: Props) { setPlainTextMode(true); }; + // ── Image description (Modal) ──────────────────────────────────────── + // Asked right after an image goes in, one image at a time (webmail + // image-alt-dialog, 53cb49d3c). "Decorative" writes alt="" so screen + // readers skip it; Skip leaves it undescribed. + const [altQueue, setAltQueue] = React.useState>([]); + const altTarget = altQueue[0] ?? null; + const [altText, setAltText] = React.useState(''); + const [altDecorative, setAltDecorative] = React.useState(false); + React.useEffect(() => { + setAltText(''); + setAltDecorative(false); + }, [altTarget?.cid]); + const closeAltPrompt = () => setAltQueue((q) => q.slice(1)); + const saveAltPrompt = () => { + if (!altTarget) return; + const alt = altDecorative ? '' : altText.trim(); + if (!altDecorative && !alt) return; + editorRef.current?.setImageAlt(altTarget.cid, alt); + closeAltPrompt(); + }; + // ── Link prompt (Modal) ────────────────────────────────────────────── const [linkPromptVisible, setLinkPromptVisible] = React.useState(false); const [linkPromptValue, setLinkPromptValue] = React.useState(''); @@ -3029,6 +3055,57 @@ export default function ComposeScreen({ route, navigation }: Props) { + + + + {t('email_composer.image_alt.title', 'Describe this image')} + + {t('email_composer.image_alt.hint', "Say what the image shows for people who can't see it.")} + + {!!altTarget && ( + + )} + + + {t('email_composer.image_alt.decorative', 'Decorative image (no description)')} + + + + + {t('email_composer.image_alt.skip', 'Skip')} + + + {t('common.save', 'Save')} + + + + + + {/* Text / background colour palette */} setColorTarget(null)}> setColorTarget(null)}> @@ -3605,6 +3682,7 @@ function makeStyles(c: ThemePalette) { }, modalConfirmText: { ...typography.bodyMedium, color: c.primaryForeground }, switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, + altPreview: { width: '100%', height: 96, borderRadius: radius.sm }, switchLabel: { ...typography.body, color: c.text }, tagRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 }, tagChip: {