mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-03 00:06: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;
|
exec(command: RichTextCommand): void;
|
||||||
insertLink(url: string, label?: string): void;
|
insertLink(url: string, label?: string): void;
|
||||||
unsetLink(): 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). */
|
/** Insert an HTML fragment at the caret (template bodies, tables). */
|
||||||
insertHtml(html: string): void;
|
insertHtml(html: string): void;
|
||||||
setHtml(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()`);
|
call(`window.__rne && window.__rne.unsetLink()`);
|
||||||
},
|
},
|
||||||
insertImage: (src, cid, alt) => {
|
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) => {
|
insertHtml: (html) => {
|
||||||
call(`window.__rne && window.__rne.insertHtml(${JSON.stringify(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', () => {
|
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');
|
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', () => {
|
describe('rewriteInlineImages', () => {
|
||||||
|
|||||||
@@ -435,3 +435,65 @@ describe('collapsed reply quote (webmail #1405)', () => {
|
|||||||
expect(source).toMatch(/collapseQuote=\{collapseReplyQuote && editorSeedHtml === initialBodyHtml\}/);
|
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.
|
* quote. Use for the text/plain part of a sent message.
|
||||||
*/
|
*/
|
||||||
mailMarkup?: boolean;
|
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.
|
// 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 {
|
export function htmlToPlainText(html: string, options: HtmlToPlainTextOptions = {}): string {
|
||||||
if (!html) return '';
|
if (!html) return '';
|
||||||
|
if (options.imageAlt) html = writeImageAlt(html, options.imageAlt);
|
||||||
if (!options.mailMarkup) return blocksToPlainText(html, false);
|
if (!options.mailMarkup) return blocksToPlainText(html, false);
|
||||||
// Quotes from the innermost out: each becomes its text with "> " in front
|
// Quotes from the innermost out: each becomes its text with "> " in front
|
||||||
// of every line, so a quote in a quote reads "> > ".
|
// 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));
|
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 {
|
function blocksToPlainText(html: string, mailMarkup: boolean): string {
|
||||||
let s = html;
|
let s = html;
|
||||||
// Drop non-content blocks entirely.
|
// Drop non-content blocks entirely.
|
||||||
|
|||||||
+12
-2
@@ -523,9 +523,11 @@ export function buildEditorHtml(opts: {
|
|||||||
insertImage: function (src, cid, alt) {
|
insertImage: function (src, cid, alt) {
|
||||||
editor.focus();
|
editor.focus();
|
||||||
var safeSrc = (src || '').replace(/"/g, '"');
|
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 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 + '"' : '') + ' />';
|
(safeCid ? ' data-cid="' + safeCid + '"' : '') + ' />';
|
||||||
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
try { document.execCommand('insertHTML', false, html); } catch (e) {}
|
||||||
reportChange();
|
reportChange();
|
||||||
@@ -539,6 +541,14 @@ export function buildEditorHtml(opts: {
|
|||||||
},
|
},
|
||||||
focus: function () { editor.focus(); },
|
focus: function () { editor.focus(); },
|
||||||
focusEnd: function () { focusEnd(editor); },
|
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();
|
reportHeight();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {
|
import {
|
||||||
View, Text, StyleSheet, TextInput, Pressable, ScrollView,
|
View, Text, StyleSheet, TextInput, Pressable, ScrollView,
|
||||||
Keyboard, Platform, ActivityIndicator, Alert, Modal, useWindowDimensions,
|
Keyboard, Platform, ActivityIndicator, Alert, Modal, useWindowDimensions, Image,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { usePreventRemove } from '@react-navigation/native';
|
import { usePreventRemove } from '@react-navigation/native';
|
||||||
@@ -1336,7 +1336,10 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
htmlBody = `<div>${safeHtml}</div>`;
|
htmlBody = `<div>${safeHtml}</div>`;
|
||||||
// The text part marks the quote with "> ", numbers ordered lists and
|
// The text part marks the quote with "> ", numbers ordered lists and
|
||||||
// keeps the "-- " signature delimiter, as plain-text mail does.
|
// 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
|
const inlineFromBody = usedCids
|
||||||
@@ -1392,7 +1395,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
requestReadReceipt,
|
requestReadReceipt,
|
||||||
envelopeMailFrom,
|
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).
|
// Save one draft version (create, then destroy the previous one - #849).
|
||||||
const saveDraftOnce = async (opts: { live: boolean }): Promise<string | null> => {
|
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'));
|
Alert.alert(t('email_composer.attach', 'Attach'), t('email_composer.image_load_failed', 'Could not load image'));
|
||||||
continue;
|
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({
|
const entry = addUploadEntry({
|
||||||
name: fallbackName,
|
name: fallbackName,
|
||||||
type: mime,
|
type: mime,
|
||||||
@@ -2003,6 +2008,27 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
setPlainTextMode(true);
|
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) ──────────────────────────────────────────────
|
// ── Link prompt (Modal) ──────────────────────────────────────────────
|
||||||
const [linkPromptVisible, setLinkPromptVisible] = React.useState(false);
|
const [linkPromptVisible, setLinkPromptVisible] = React.useState(false);
|
||||||
const [linkPromptValue, setLinkPromptValue] = React.useState('');
|
const [linkPromptValue, setLinkPromptValue] = React.useState('');
|
||||||
@@ -3029,6 +3055,57 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
</View>
|
</View>
|
||||||
</Modal>
|
</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 */}
|
{/* Text / background colour palette */}
|
||||||
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!colorTarget} transparent animationType="fade" onRequestClose={() => setColorTarget(null)}>
|
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible={!!colorTarget} transparent animationType="fade" onRequestClose={() => setColorTarget(null)}>
|
||||||
<Pressable accessible={false} style={styles.modalBackdrop} onPress={() => 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 },
|
modalConfirmText: { ...typography.bodyMedium, color: c.primaryForeground },
|
||||||
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||||
|
altPreview: { width: '100%', height: 96, borderRadius: radius.sm },
|
||||||
switchLabel: { ...typography.body, color: c.text },
|
switchLabel: { ...typography.body, color: c.text },
|
||||||
tagRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
|
tagRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
|
||||||
tagChip: {
|
tagChip: {
|
||||||
|
|||||||
Reference in New Issue
Block a user