feat: ask for a description when an image goes into a message

This commit is contained in:
Linus Rath
2026-10-31 04:04:35 +01:00
parent 2c1f266fba
commit 303dea01e9
6 changed files with 194 additions and 8 deletions
+8 -2
View File
@@ -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<RichTextEditorHandle, Props>(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)})`);
+9
View File
@@ -125,6 +125,15 @@ describe('htmlToPlainText with mail markup (the sent text part)', () => {
it('keeps the signature delimiter', () => {
expect(mail('<p>Hi</p><p data-signature-block="separator">-- </p><p>Jane</p>')).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('<p>Look <img src="cid:a" alt="a red &amp; blue bike"> here</p>')).toBe('Look [Image: a red & blue bike] here');
expect(withAlt('<p>A<img src="cid:b" alt="">B<img src="cid:c">C</p>')).toBe('ABC');
expect(withAlt('<p><img alt="x <y>" src="cid:d"></p>')).toBe('[Image: x <y>]');
// Without the option images stay out, as before.
expect(mail('<p>A<img src="cid:a" alt="bike">B</p>')).toBe('AB');
});
});
describe('rewriteInlineImages', () => {
+62
View File
@@ -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<string, string> },
{ attrs: { 'data-cid': 'b@x' } as Record<string, string> },
].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<string, unknown> = {
ReactNativeWebView: { postMessage: () => undefined },
addEventListener: () => undefined,
getSelection: () => null,
};
const script = extractScript(buildEditorHtml({ initialHtml: '<p><br></p>', placeholder: '', c: LIGHT_COLORS }));
new Function('window', 'document', script)(window, document);
return { rne: window.__rne as Record<string, (...args: unknown[]) => 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('<img src="data:image/png;base64,AA" data-cid="a@x" />');
page.rne.insertImage('data:image/png;base64,AA', 'b@x', 'A "red" bike');
expect(page.inserted[1]).toBe('<img src="data:image/png;base64,AA" alt="A &quot;red&quot; bike" data-cid="b@x" />');
});
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 \}\)/);
});
});
+21
View File
@@ -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 = /<blockquote\b[^>]*>((?:(?!<blockquote\b)[\s\S])
*/
export function htmlToPlainText(html: string, options: HtmlToPlainTextOptions = {}): string {
if (!html) return '';
if (options.imageAlt) html = writeImageAlt(html, options.imageAlt);
if (!options.mailMarkup) return blocksToPlainText(html, false);
// Quotes from the innermost out: each becomes its text with "> " 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 `<img>` 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(/<img\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi, (tag) => {
const m = tag.match(/\salt\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i);
const alt = (m?.[2] ?? m?.[3] ?? m?.[4] ?? '')
.replace(/&quot;/gi, '"').replace(/&#39;/gi, "'").replace(/&lt;/gi, '<').replace(/&gt;/gi, '>').replace(/&amp;/gi, '&')
.replace(/\s+/g, ' ')
.trim();
if (!alt) return '';
return imageAlt(alt).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
});
}
function blocksToPlainText(html: string, mailMarkup: boolean): string {
let s = html;
// Drop non-content blocks entirely.
+12 -2
View File
@@ -523,9 +523,11 @@ export function buildEditorHtml(opts: {
insertImage: function (src, cid, alt) {
editor.focus();
var safeSrc = (src || '').replace(/"/g, '&quot;');
var safeAlt = (alt || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;');
// 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, '&amp;').replace(/"/g, '&quot;') : null;
var safeCid = cid ? cid.replace(/"/g, '&quot;') : '';
var html = '<img src="' + safeSrc + '" alt="' + safeAlt + '"' +
var html = '<img src="' + safeSrc + '"' + (safeAlt === null ? '' : ' alt="' + safeAlt + '"') +
(safeCid ? ' data-cid="' + safeCid + '"' : '') + ' />';
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();
+82 -4
View File
@@ -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 = `<div>${safeHtml}</div>`;
// 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<string | null> => {
@@ -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<Array<{ cid: string; src: string }>>([]);
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) {
</View>
</Modal>
<Modal supportedOrientations={MODAL_ORIENTATIONS}
visible={!!altTarget}
transparent
animationType="fade"
onRequestClose={closeAltPrompt}
>
<View style={styles.modalBackdrop}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>{t('email_composer.image_alt.title', 'Describe this image')}</Text>
<Text style={styles.modalLabel}>
{t('email_composer.image_alt.hint', "Say what the image shows for people who can't see it.")}
</Text>
{!!altTarget && (
<Image source={{ uri: altTarget.src }} style={styles.altPreview} resizeMode="contain" accessible={false} />
)}
<TextInput
style={styles.modalInput}
value={altDecorative ? '' : altText}
onChangeText={setAltText}
editable={!altDecorative}
placeholder={t('email_composer.image_alt.label', 'Image description')}
accessibilityLabel={t('email_composer.image_alt.label', 'Image description')}
placeholderTextColor={c.textMuted}
autoFocus
onSubmitEditing={saveAltPrompt}
/>
<View style={styles.switchRow}>
<Text style={styles.switchLabel}>{t('email_composer.image_alt.decorative', 'Decorative image (no description)')}</Text>
<ToggleSwitch
value={altDecorative}
onValueChange={setAltDecorative}
accessibilityLabel={t('email_composer.image_alt.decorative', 'Decorative image (no description)')}
/>
</View>
<View style={styles.modalActions}>
<Pressable style={styles.modalCancel} onPress={closeAltPrompt} hitSlop={4}>
<Text style={styles.modalCancelText}>{t('email_composer.image_alt.skip', 'Skip')}</Text>
</Pressable>
<Pressable
style={[styles.modalConfirm, !altDecorative && !altText.trim() && styles.sendButtonDisabled]}
onPress={saveAltPrompt}
disabled={!altDecorative && !altText.trim()}
hitSlop={4}
>
<Text style={styles.modalConfirmText}>{t('common.save', 'Save')}</Text>
</Pressable>
</View>
</View>
</View>
</Modal>
{/* Text / background colour palette */}
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!colorTarget} transparent animationType="fade" onRequestClose={() => setColorTarget(null)}>
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => 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: {