mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
feat: ask for a description when an image goes into a message
This commit is contained in:
@@ -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)})`);
|
||||
|
||||
@@ -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 & 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', () => {
|
||||
|
||||
@@ -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 "red" 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 \}\)/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(/"/gi, '"').replace(/'/gi, "'").replace(/</gi, '<').replace(/>/gi, '>').replace(/&/gi, '&')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!alt) return '';
|
||||
return imageAlt(alt).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
});
|
||||
}
|
||||
|
||||
function blocksToPlainText(html: string, mailMarkup: boolean): string {
|
||||
let s = html;
|
||||
// Drop non-content blocks entirely.
|
||||
|
||||
+12
-2
@@ -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 = '<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();
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user