mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
feat: offer the reading fonts, including OpenDyslexic, for the app and plain-text mail
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,107 @@
|
|||||||
|
The reading fonts in this folder (Settings > Accessibility > Font) are
|
||||||
|
licensed under the SIL Open Font License, Version 1.1, copied below.
|
||||||
|
|
||||||
|
AtkinsonHyperlegibleNext*.ttf:
|
||||||
|
Copyright 2020-2024 The Atkinson Hyperlegible Next Project Authors
|
||||||
|
(https://github.com/googlefonts/atkinson-hyperlegible-next)
|
||||||
|
|
||||||
|
Lexend*.ttf:
|
||||||
|
Copyright 2018 The Lexend Project Authors (https://github.com/googlefonts/lexend),
|
||||||
|
with Reserved Font Name "RevReading Lexend".
|
||||||
|
|
||||||
|
OpenDyslexic*.ttf:
|
||||||
|
Copyright (c) 2019-07-29, Abbie Gonzalez (https://abbiecod.es|support@abbiecod.es),
|
||||||
|
with Reserved Font Name OpenDyslexic.
|
||||||
|
Copyright (c) 12/2012 - 2019
|
||||||
|
|
||||||
|
The files are built by scripts/build-reading-fonts.py from the woff2 files
|
||||||
|
the webmail bundles (from Fontsource, https://fontsource.org): the variable
|
||||||
|
Atkinson Hyperlegible Next and Lexend fonts as static regular and bold
|
||||||
|
instances, OpenDyslexic unchanged apart from the woff2 decompression.
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1,3 +1,6 @@
|
|||||||
|
// First: wraps react-native's Text for the reading font before any module
|
||||||
|
// copies it (src/theme/reading-font-text).
|
||||||
|
import './src/theme/reading-font-install';
|
||||||
import { createElement } from 'react';
|
import { createElement } from 'react';
|
||||||
import { AppRegistry } from 'react-native';
|
import { AppRegistry } from 'react-native';
|
||||||
import { registerRootComponent } from 'expo';
|
import { registerRootComponent } from 'expo';
|
||||||
|
|||||||
@@ -1384,6 +1384,16 @@
|
|||||||
"appearance": "Darstellung",
|
"appearance": "Darstellung",
|
||||||
"general": "Allgemein",
|
"general": "Allgemein",
|
||||||
"privacy": "Datenschutz & Sicherheit"
|
"privacy": "Datenschutz & Sicherheit"
|
||||||
|
},
|
||||||
|
"accessibility": {
|
||||||
|
"presets": {
|
||||||
|
"dyslexia": {
|
||||||
|
"description_mobile": "Atkinson Hyperlegible als Schrift, auch für Nachrichten als reiner Text."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"font": {
|
||||||
|
"description_ios": "Atkinson Hyperlegible und Lexend sind so gestaltet, dass sich die Buchstaben gut unterscheiden; OpenDyslexic hilft manchen Menschen mit Legasthenie. Auf diesem Gerät gilt die Schrift für Nachrichten als reiner Text."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sharing": {
|
"sharing": {
|
||||||
|
|||||||
+7
-1
@@ -963,10 +963,16 @@
|
|||||||
"updates": "Updates"
|
"updates": "Updates"
|
||||||
},
|
},
|
||||||
"accessibility": {
|
"accessibility": {
|
||||||
|
"font": {
|
||||||
|
"description_ios": "Atkinson Hyperlegible and Lexend are designed to be easy to tell apart; OpenDyslexic helps some people with dyslexia. On this device the font is used for plain-text messages."
|
||||||
|
},
|
||||||
"presets": {
|
"presets": {
|
||||||
"applied_mobile": "{name} changed {count, plural, one {# setting} other {# settings}}",
|
"applied_mobile": "{name} changed {count, plural, one {# setting} other {# settings}}",
|
||||||
"apply": "Apply",
|
"apply": "Apply",
|
||||||
"apply_named": "Apply {name}",
|
"apply_named": "Apply {name}",
|
||||||
|
"dyslexia": {
|
||||||
|
"description_mobile": "Atkinson Hyperlegible as the font, also for plain-text messages."
|
||||||
|
},
|
||||||
"low-vision": {
|
"low-vision": {
|
||||||
"description_mobile": "Text at 150% and labels under the toolbar icons."
|
"description_mobile": "Text at 150% and labels under the toolbar icons."
|
||||||
},
|
},
|
||||||
@@ -1016,7 +1022,6 @@
|
|||||||
"accessibility_link": {
|
"accessibility_link": {
|
||||||
"description_mobile": "Text size, reduced motion and the accessibility presets are on the Accessibility page."
|
"description_mobile": "Text size, reduced motion and the accessibility presets are on the Accessibility page."
|
||||||
},
|
},
|
||||||
"description_mobile": "Customize how the app looks",
|
|
||||||
"cross_all": {
|
"cross_all": {
|
||||||
"label_mobile": "All mail"
|
"label_mobile": "All mail"
|
||||||
},
|
},
|
||||||
@@ -1026,6 +1031,7 @@
|
|||||||
"cross_unread": {
|
"cross_unread": {
|
||||||
"label_mobile": "All unread"
|
"label_mobile": "All unread"
|
||||||
},
|
},
|
||||||
|
"description_mobile": "Customize how the app looks",
|
||||||
"language": {
|
"language": {
|
||||||
"restart_for_direction": "Restart the app to apply the new text direction.",
|
"restart_for_direction": "Restart the app to apply the new text direction.",
|
||||||
"system_default": "System default"
|
"system_default": "System default"
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""Build the reading fonts the app bundles, from the webmail's copies.
|
||||||
|
|
||||||
|
Settings > Accessibility > Font offers the webmail's three reading fonts
|
||||||
|
(app/accessibility.css): Atkinson Hyperlegible Next, Lexend and OpenDyslexic,
|
||||||
|
all under the SIL Open Font License. The webmail ships them as woff2 subsets
|
||||||
|
(latin and latin-ext). The app needs them twice:
|
||||||
|
|
||||||
|
- Android draws UI text with fonts from android/app/src/main/assets/fonts,
|
||||||
|
looked up by family name, with a `_bold` file for bold text. Android can't
|
||||||
|
read woff2 or variable weights there, so each family becomes a static
|
||||||
|
Regular (400) and Bold (700) TTF, latin and latin-ext merged.
|
||||||
|
- A plain-text mail body renders in a WebView, which reads the woff2 files
|
||||||
|
directly; they go into src/theme/reading-fonts-data.ts as base64 so the
|
||||||
|
sandboxed page (CSP font-src data:) can load them without file access.
|
||||||
|
|
||||||
|
Run from the app root with the webmail checked out two levels up (or
|
||||||
|
WEBMAIL_DIR set to it):
|
||||||
|
python scripts/build-reading-fonts.py
|
||||||
|
Needs fontTools with brotli (pip install fonttools brotli).
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from fontTools.merge import Merger
|
||||||
|
from fontTools.ttLib import TTFont
|
||||||
|
from fontTools.varLib.instancer import instantiateVariableFont
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
# The webmail checkout (the app lives in its repos/react-native); WEBMAIL_DIR
|
||||||
|
# points elsewhere, e.g. from a separate worktree.
|
||||||
|
WEBMAIL_FONTS = os.path.join(os.environ.get('WEBMAIL_DIR') or os.path.join(ROOT, '..', '..'), 'app', 'fonts')
|
||||||
|
ASSETS = os.path.join(ROOT, 'android', 'app', 'src', 'main', 'assets', 'fonts')
|
||||||
|
DATA = os.path.join(ROOT, 'src', 'theme', 'reading-fonts-data.ts')
|
||||||
|
|
||||||
|
# Android family name -> the webmail's woff2 subsets (latin first).
|
||||||
|
FAMILIES = {
|
||||||
|
'AtkinsonHyperlegibleNext': {
|
||||||
|
'variable': ['atkinson-hyperlegible-next-latin-wght-normal.woff2',
|
||||||
|
'atkinson-hyperlegible-next-latin-ext-wght-normal.woff2'],
|
||||||
|
},
|
||||||
|
'Lexend': {
|
||||||
|
'variable': ['lexend-latin-wght-normal.woff2', 'lexend-latin-ext-wght-normal.woff2'],
|
||||||
|
},
|
||||||
|
'OpenDyslexic': {
|
||||||
|
400: ['opendyslexic-latin-400-normal.woff2'],
|
||||||
|
700: ['opendyslexic-latin-700-normal.woff2'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# The woff2 files a mail body loads: (export name, css family, weight range, files).
|
||||||
|
WEB_FACES = [
|
||||||
|
('ATKINSON', 'Atkinson Hyperlegible Next', '200 800', FAMILIES['AtkinsonHyperlegibleNext']['variable']),
|
||||||
|
('LEXEND', 'Lexend', '100 900', FAMILIES['Lexend']['variable']),
|
||||||
|
('OPENDYSLEXIC', 'OpenDyslexic', '400', FAMILIES['OpenDyslexic'][400]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load(name):
|
||||||
|
return TTFont(os.path.join(WEBMAIL_FONTS, name))
|
||||||
|
|
||||||
|
|
||||||
|
def static_instance(name, weight):
|
||||||
|
font = load(name)
|
||||||
|
if 'fvar' in font:
|
||||||
|
font = instantiateVariableFont(font, {'wght': weight}, updateFontNames=False)
|
||||||
|
font.flavor = None
|
||||||
|
return font
|
||||||
|
|
||||||
|
|
||||||
|
def merge(fonts, out_path):
|
||||||
|
paths = []
|
||||||
|
for i, font in enumerate(fonts):
|
||||||
|
tmp = out_path + f'.part{i}.ttf'
|
||||||
|
font.save(tmp)
|
||||||
|
paths.append(tmp)
|
||||||
|
if len(paths) == 1:
|
||||||
|
os.replace(paths[0], out_path)
|
||||||
|
return
|
||||||
|
merged = Merger().merge(paths)
|
||||||
|
merged.save(out_path)
|
||||||
|
for p in paths:
|
||||||
|
os.remove(p)
|
||||||
|
|
||||||
|
|
||||||
|
def build_assets():
|
||||||
|
os.makedirs(ASSETS, exist_ok=True)
|
||||||
|
for family, spec in FAMILIES.items():
|
||||||
|
for weight, suffix in ((400, ''), (700, '_bold')):
|
||||||
|
files = spec.get('variable') or spec[weight]
|
||||||
|
fonts = [static_instance(f, weight) for f in files]
|
||||||
|
out = os.path.join(ASSETS, family + suffix + '.ttf')
|
||||||
|
merge(fonts, out)
|
||||||
|
print('wrote', os.path.relpath(out, ROOT), os.path.getsize(out))
|
||||||
|
|
||||||
|
|
||||||
|
def unicode_ranges():
|
||||||
|
"""woff2 file name -> its unicode-range in the webmail's app/accessibility.css."""
|
||||||
|
css_path = os.path.join(WEBMAIL_FONTS, '..', 'accessibility.css')
|
||||||
|
with open(css_path, encoding='utf-8') as fh:
|
||||||
|
css = fh.read()
|
||||||
|
out = {}
|
||||||
|
for block in re.findall(r'@font-face\s*\{([^}]*)\}', css):
|
||||||
|
src = re.search(r"fonts/([^\"')]+\.woff2)", block)
|
||||||
|
rng = re.search(r'unicode-range:\s*([^;]+);', block)
|
||||||
|
if src:
|
||||||
|
out[src.group(1)] = rng.group(1).strip() if rng else ''
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_data():
|
||||||
|
ranges = unicode_ranges()
|
||||||
|
lines = [
|
||||||
|
"// Generated by scripts/build-reading-fonts.py from the webmail's",
|
||||||
|
'// app/fonts (SIL Open Font License 1.1, see',
|
||||||
|
'// android/app/src/main/assets/fonts/OFL.txt). Do not edit by hand. The',
|
||||||
|
'// woff2 files a plain-text mail body loads as data: URIs, with the',
|
||||||
|
"// unicode ranges of the webmail's app/accessibility.css.",
|
||||||
|
'',
|
||||||
|
'export interface ReadingFontFace {',
|
||||||
|
' /** CSS font-family name, as the webmail uses it. */',
|
||||||
|
' family: string;',
|
||||||
|
' /** font-weight descriptor (a range for variable fonts). */',
|
||||||
|
' weight: string;',
|
||||||
|
' /** One per unicode subset: base64 woff2 and its unicode-range ("" = all). */',
|
||||||
|
' subsets: Array<{ woff2: string; unicodeRange: string }>;',
|
||||||
|
'}',
|
||||||
|
'',
|
||||||
|
]
|
||||||
|
for export, family, weight, files in WEB_FACES:
|
||||||
|
lines.append(f'export const {export}: ReadingFontFace = {{')
|
||||||
|
lines.append(f" family: '{family}',")
|
||||||
|
lines.append(f" weight: '{weight}',")
|
||||||
|
lines.append(' subsets: [')
|
||||||
|
for name in files:
|
||||||
|
if name not in ranges:
|
||||||
|
sys.exit(f"{name} is not in the webmail's accessibility.css")
|
||||||
|
with open(os.path.join(WEBMAIL_FONTS, name), 'rb') as fh:
|
||||||
|
blob = base64.b64encode(fh.read()).decode('ascii')
|
||||||
|
lines.append(' {')
|
||||||
|
lines.append(f" unicodeRange: '{ranges[name]}',")
|
||||||
|
lines.append(f" woff2: '{blob}',")
|
||||||
|
lines.append(' },')
|
||||||
|
lines.append(' ],')
|
||||||
|
lines.append('};')
|
||||||
|
lines.append('')
|
||||||
|
with open(DATA, 'w', encoding='utf-8', newline='\n') as fh:
|
||||||
|
fh.write('\n'.join(lines))
|
||||||
|
print('wrote', os.path.relpath(DATA, ROOT), os.path.getsize(DATA))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if not os.path.isdir(WEBMAIL_FONTS):
|
||||||
|
sys.exit(f'No webmail fonts at {WEBMAIL_FONTS}')
|
||||||
|
build_assets()
|
||||||
|
build_data()
|
||||||
@@ -17,6 +17,10 @@ const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
|
|||||||
// Code that isn't an npm dependency but ships in the app.
|
// Code that isn't an npm dependency but ships in the app.
|
||||||
const VENDORED = [
|
const VENDORED = [
|
||||||
{ name: 'Tabler Icons', version: '', license: 'MIT', url: 'https://tabler.io/icons' },
|
{ name: 'Tabler Icons', version: '', license: 'MIT', url: 'https://tabler.io/icons' },
|
||||||
|
// Reading fonts (scripts/build-reading-fonts.py).
|
||||||
|
{ name: 'Atkinson Hyperlegible Next', version: '', license: 'OFL-1.1', url: 'https://github.com/googlefonts/atkinson-hyperlegible-next' },
|
||||||
|
{ name: 'Lexend', version: '', license: 'OFL-1.1', url: 'https://github.com/googlefonts/lexend' },
|
||||||
|
{ name: 'OpenDyslexic', version: '', license: 'OFL-1.1', url: 'https://opendyslexic.org' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function repoUrl(meta) {
|
function repoUrl(meta) {
|
||||||
|
|||||||
@@ -444,6 +444,9 @@ export default function EmailBodyView({
|
|||||||
const syncTrustedSenders = isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts);
|
const syncTrustedSenders = isTrustedSendersSyncOn(trustedSendersAddressBook, hasContacts);
|
||||||
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
|
const emailAlwaysLightMode = useSettingsStore((s) => s.emailAlwaysLightMode);
|
||||||
const plainTextFont = useSettingsStore((s) => s.plainTextFont);
|
const plainTextFont = useSettingsStore((s) => s.plainTextFont);
|
||||||
|
// The reading font reaches a plain-text body ("Same as above"), as in the
|
||||||
|
// webmail; HTML mail keeps the sender's fonts.
|
||||||
|
const appFont = useSettingsStore((s) => s.appFont);
|
||||||
const messageSpacing = useSettingsStore((s) => s.messageSpacing);
|
const messageSpacing = useSettingsStore((s) => s.messageSpacing);
|
||||||
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
|
const plainTextMode = useSettingsStore((s) => s.plainTextMode);
|
||||||
// Body text follows the app's text size and the system font scale (webmail
|
// Body text follows the app's text size and the system font scale (webmail
|
||||||
@@ -611,9 +614,10 @@ export default function EmailBodyView({
|
|||||||
quoteLabels,
|
quoteLabels,
|
||||||
textFactor,
|
textFactor,
|
||||||
plainDarkColors,
|
plainDarkColors,
|
||||||
|
appFont,
|
||||||
}), [
|
}), [
|
||||||
jmapAccountId, email.id, rawHtml, text, email.preview, emptyLabel, shouldBlock, cidMap, renderAsDark,
|
jmapAccountId, email.id, rawHtml, text, email.preview, emptyLabel, shouldBlock, cidMap, renderAsDark,
|
||||||
messageSpacing, plainTextFont, quoteLabels, textFactor, plainDarkColors,
|
messageSpacing, plainTextFont, quoteLabels, textFactor, plainDarkColors, appFont,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const source = React.useMemo(
|
const source = React.useMemo(
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View } from 'react-native';
|
import { Platform, View } from 'react-native';
|
||||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
import { spacing } from '../../theme/tokens';
|
import { spacing } from '../../theme/tokens';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
import { FONT_SIZES, useSettingsStore, type FontSize } from '../../stores/settings-store';
|
import { APP_FONTS, FONT_SIZES, useSettingsStore, type AppFont, type FontSize } from '../../stores/settings-store';
|
||||||
|
import { uiFontsBundled } from '../../theme/reading-fonts';
|
||||||
import { toast } from '../../stores/toast-store';
|
import { toast } from '../../stores/toast-store';
|
||||||
import {
|
import {
|
||||||
ACCESSIBILITY_PRESET_IDS,
|
ACCESSIBILITY_PRESET_IDS,
|
||||||
@@ -15,6 +16,14 @@ import {
|
|||||||
} from '../../lib/accessibility-presets';
|
} from '../../lib/accessibility-presets';
|
||||||
import { TOAST_DURATION_CHOICES } from '../../lib/toast-duration';
|
import { TOAST_DURATION_CHOICES } from '../../lib/toast-duration';
|
||||||
|
|
||||||
|
// Font names are names: the same in every language (webmail
|
||||||
|
// accessibility-settings.tsx).
|
||||||
|
const FONT_NAMES: Record<Exclude<AppFont, 'default'>, string> = {
|
||||||
|
atkinson: 'Atkinson Hyperlegible',
|
||||||
|
lexend: 'Lexend',
|
||||||
|
opendyslexic: 'OpenDyslexic',
|
||||||
|
};
|
||||||
|
|
||||||
// Percentages read the same in every language the app has.
|
// Percentages read the same in every language the app has.
|
||||||
const TEXT_SIZE_LABELS: Record<FontSize, string> = {
|
const TEXT_SIZE_LABELS: Record<FontSize, string> = {
|
||||||
small: '87.5%',
|
small: '87.5%',
|
||||||
@@ -41,6 +50,11 @@ function presetCopy(id: AccessibilityPresetId, t: Translate): { label: string; d
|
|||||||
label: t('settings.accessibility.presets.low-vision.label', 'Low vision'),
|
label: t('settings.accessibility.presets.low-vision.label', 'Low vision'),
|
||||||
description: t('settings.accessibility.presets.low-vision.description_mobile', 'Text at 150% and labels under the toolbar icons.'),
|
description: t('settings.accessibility.presets.low-vision.description_mobile', 'Text at 150% and labels under the toolbar icons.'),
|
||||||
};
|
};
|
||||||
|
case 'dyslexia':
|
||||||
|
return {
|
||||||
|
label: t('settings.accessibility.presets.dyslexia.label', 'Dyslexia'),
|
||||||
|
description: t('settings.accessibility.presets.dyslexia.description_mobile', 'Atkinson Hyperlegible as the font, also for plain-text messages.'),
|
||||||
|
};
|
||||||
case 'motion':
|
case 'motion':
|
||||||
return {
|
return {
|
||||||
label: t('settings.accessibility.presets.motion.label', 'Motion and seizure safe'),
|
label: t('settings.accessibility.presets.motion.label', 'Motion and seizure safe'),
|
||||||
@@ -61,6 +75,7 @@ export function AccessibilitySettings() {
|
|||||||
const hydrate = useSettingsStore((s) => s.hydrate);
|
const hydrate = useSettingsStore((s) => s.hydrate);
|
||||||
const update = useSettingsStore((s) => s.updateSetting);
|
const update = useSettingsStore((s) => s.updateSetting);
|
||||||
const fontSize = useSettingsStore((s) => s.fontSize);
|
const fontSize = useSettingsStore((s) => s.fontSize);
|
||||||
|
const appFont = useSettingsStore((s) => s.appFont);
|
||||||
const animationsEnabled = useSettingsStore((s) => s.animationsEnabled);
|
const animationsEnabled = useSettingsStore((s) => s.animationsEnabled);
|
||||||
const toastDuration = useSettingsStore((s) => s.toastDuration);
|
const toastDuration = useSettingsStore((s) => s.toastDuration);
|
||||||
const sendConfirmation = useSettingsStore((s) => s.sendConfirmation);
|
const sendConfirmation = useSettingsStore((s) => s.sendConfirmation);
|
||||||
@@ -130,7 +145,6 @@ export function AccessibilitySettings() {
|
|||||||
<SettingItem
|
<SettingItem
|
||||||
label={t('settings.accessibility.text_size.label', 'Text size')}
|
label={t('settings.accessibility.text_size.label', 'Text size')}
|
||||||
description={t('settings.accessibility.text_size.description_mobile', "Scales the app's text on top of your phone's text size. 100% is your phone's size.")}
|
description={t('settings.accessibility.text_size.description_mobile', "Scales the app's text on top of your phone's text size. 100% is your phone's size.")}
|
||||||
noBorder
|
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
value={fontSize}
|
value={fontSize}
|
||||||
@@ -138,6 +152,22 @@ export function AccessibilitySettings() {
|
|||||||
options={FONT_SIZES.map((size) => ({ value: size, label: TEXT_SIZE_LABELS[size] }))}
|
options={FONT_SIZES.map((size) => ({ value: size, label: TEXT_SIZE_LABELS[size] }))}
|
||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
<SettingItem
|
||||||
|
label={t('settings.accessibility.font.label', 'Font')}
|
||||||
|
description={uiFontsBundled(Platform.OS)
|
||||||
|
? t('settings.accessibility.font.description', 'Atkinson Hyperlegible and Lexend are designed to be easy to tell apart; OpenDyslexic helps some people with dyslexia. They cover Latin script.')
|
||||||
|
: t('settings.accessibility.font.description_ios', 'Atkinson Hyperlegible and Lexend are designed to be easy to tell apart; OpenDyslexic helps some people with dyslexia. On this device the font is used for plain-text messages.')}
|
||||||
|
noBorder
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={appFont}
|
||||||
|
onChange={(v) => update('appFont', v as AppFont)}
|
||||||
|
options={APP_FONTS.map((font) => ({
|
||||||
|
value: font,
|
||||||
|
label: font === 'default' ? t('settings.accessibility.font.default', 'Theme font') : FONT_NAMES[font],
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection title={t('settings.accessibility.sections.motion', 'Motion')}>
|
<SettingsSection title={t('settings.accessibility.sections.motion', 'Motion')}>
|
||||||
|
|||||||
@@ -50,6 +50,15 @@ describe('bodyDocument', () => {
|
|||||||
expect(bodyDocument(input({ cidMap: { a: 'data:image/png;base64,AA' } }))).not.toBe(first);
|
expect(bodyDocument(input({ cidMap: { a: 'data:image/png;base64,AA' } }))).not.toBe(first);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('draws a plain-text body in the reading font and rebuilds when it changes', () => {
|
||||||
|
const text = input({ rawHtml: null, text: 'Hello' });
|
||||||
|
const plain = bodyDocument(text);
|
||||||
|
const lexend = bodyDocument({ ...text, appFont: 'lexend' });
|
||||||
|
expect(lexend).not.toBe(plain);
|
||||||
|
expect(lexend.html).toContain('font-family: "Lexend"');
|
||||||
|
expect(plain.html).not.toContain('Lexend');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps messages apart by key', () => {
|
it('keeps messages apart by key', () => {
|
||||||
const a = bodyDocument(input({ key: '|e1' }));
|
const a = bodyDocument(input({ key: '|e1' }));
|
||||||
const b = bodyDocument(input({ key: 'group|e1' }));
|
const b = bodyDocument(input({ key: 'group|e1' }));
|
||||||
|
|||||||
@@ -388,6 +388,22 @@ describe('wrapPlainTextEmail', () => {
|
|||||||
it('lets the content pick its text direction', () => {
|
it('lets the content pick its text direction', () => {
|
||||||
expect(wrapPlainTextEmail('مرحبا بالعالم.')).toContain('<body dir="auto">');
|
expect(wrapPlainTextEmail('مرحبا بالعالم.')).toContain('<body dir="auto">');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Settings > Accessibility > Font: a plain body sits in the app document in
|
||||||
|
// the webmail, so it is drawn in the reading font ("Same as above").
|
||||||
|
it('draws the body in the reading font, which the page loads from data:', () => {
|
||||||
|
const html = wrapPlainTextEmail('x', { font: 'sans', readingFont: 'opendyslexic' });
|
||||||
|
expect(html).toContain('font-family: "OpenDyslexic", -apple-system');
|
||||||
|
expect(html).toMatch(/@font-face \{ font-family: "OpenDyslexic";[^}]*src: url\("data:font\/woff2;base64,/);
|
||||||
|
expect(html).toContain('font-src data:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps monospace and the theme font as they were', () => {
|
||||||
|
const mono = wrapPlainTextEmail('x', { font: 'mono', readingFont: 'opendyslexic' });
|
||||||
|
expect(mono).toContain('font-family: ui-monospace');
|
||||||
|
expect(mono).not.toContain('OpenDyslexic');
|
||||||
|
expect(wrapPlainTextEmail('x', { font: 'sans', readingFont: 'default' })).not.toContain('@font-face');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('hasNativeDarkMode', () => {
|
describe('hasNativeDarkMode', () => {
|
||||||
|
|||||||
@@ -6,14 +6,15 @@
|
|||||||
//
|
//
|
||||||
// The phone takes the presets whose settings it has: screen reader (no
|
// The phone takes the presets whose settings it has: screen reader (no
|
||||||
// conversation grouping), low vision (150% text, labels under the toolbar
|
// conversation grouping), low vision (150% text, labels under the toolbar
|
||||||
// icons) and motion (no animations). The webmail's others need settings the
|
// icons), dyslexia (the reading font, also for plain-text mail) and motion
|
||||||
// app doesn't have (reading fonts, spacing, focus outlines, hover, sounds).
|
// (no animations). The webmail's others need settings the app doesn't have
|
||||||
|
// (spacing, focus outlines, hover, sounds).
|
||||||
|
|
||||||
import type { PersistedSettings } from '../stores/settings-store';
|
import type { PersistedSettings } from '../stores/settings-store';
|
||||||
|
|
||||||
export type AccessibilityPresetId = 'screen-reader' | 'low-vision' | 'motion';
|
export type AccessibilityPresetId = 'screen-reader' | 'low-vision' | 'dyslexia' | 'motion';
|
||||||
|
|
||||||
export const ACCESSIBILITY_PRESET_IDS: readonly AccessibilityPresetId[] = ['screen-reader', 'low-vision', 'motion'];
|
export const ACCESSIBILITY_PRESET_IDS: readonly AccessibilityPresetId[] = ['screen-reader', 'low-vision', 'dyslexia', 'motion'];
|
||||||
|
|
||||||
type PresetValues = Partial<PersistedSettings>;
|
type PresetValues = Partial<PersistedSettings>;
|
||||||
|
|
||||||
@@ -27,6 +28,12 @@ export const ACCESSIBILITY_PRESETS: Record<AccessibilityPresetId, PresetValues>
|
|||||||
fontSize: 'xx-large',
|
fontSize: 'xx-large',
|
||||||
showToolbarLabels: true,
|
showToolbarLabels: true,
|
||||||
},
|
},
|
||||||
|
// The webmail's font part (its spacing, reading width and mail style
|
||||||
|
// options have no phone counterpart yet).
|
||||||
|
dyslexia: {
|
||||||
|
appFont: 'atkinson',
|
||||||
|
plainTextFont: 'sans',
|
||||||
|
},
|
||||||
motion: {
|
motion: {
|
||||||
animationsEnabled: false,
|
animationsEnabled: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from './email-html';
|
} from './email-html';
|
||||||
import { collapsePlainTextQuotes, type QuoteCollapseLabels } from './quote-collapse';
|
import { collapsePlainTextQuotes, type QuoteCollapseLabels } from './quote-collapse';
|
||||||
import { mailBaseFontPx } from './mail-reading';
|
import { mailBaseFontPx } from './mail-reading';
|
||||||
|
import type { AppFont } from '../theme/reading-fonts';
|
||||||
|
|
||||||
export interface BodyDocumentInput {
|
export interface BodyDocumentInput {
|
||||||
/** The message (account and id); the sources below are compared as well. */
|
/** The message (account and id); the sources below are compared as well. */
|
||||||
@@ -33,6 +34,8 @@ export interface BodyDocumentInput {
|
|||||||
textFactor?: number;
|
textFactor?: number;
|
||||||
/** The app theme's colours for a plain-text body in dark mode. */
|
/** The app theme's colours for a plain-text body in dark mode. */
|
||||||
plainDarkColors?: PlainTextColors;
|
plainDarkColors?: PlainTextColors;
|
||||||
|
/** The reading font (Settings > Accessibility > Font) for a plain-text body. */
|
||||||
|
appFont?: AppFont;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BodyDocument {
|
export interface BodyDocument {
|
||||||
@@ -68,6 +71,7 @@ export function buildBodyDocument(input: BodyDocumentInput): BodyDocument {
|
|||||||
font: input.plainTextFont,
|
font: input.plainTextFont,
|
||||||
fontPx: mailBaseFontPx(input.textFactor ?? 1),
|
fontPx: mailBaseFontPx(input.textFactor ?? 1),
|
||||||
darkColors: input.plainDarkColors,
|
darkColors: input.plainDarkColors,
|
||||||
|
readingFont: input.appFont,
|
||||||
}),
|
}),
|
||||||
recolorDark: false,
|
recolorDark: false,
|
||||||
hasNativeDark: false,
|
hasNativeDark: false,
|
||||||
@@ -97,7 +101,7 @@ export function bodyDocument(input: BodyDocumentInput): BodyDocument {
|
|||||||
const key = JSON.stringify([
|
const key = JSON.stringify([
|
||||||
input.key, input.blockRemoteImages, input.isDark, input.messageSpacing, input.plainTextFont,
|
input.key, input.blockRemoteImages, input.isDark, input.messageSpacing, input.plainTextFont,
|
||||||
input.quoteLabels.show, input.quoteLabels.hide, input.emptyLabel, input.textFactor ?? 1,
|
input.quoteLabels.show, input.quoteLabels.hide, input.emptyLabel, input.textFactor ?? 1,
|
||||||
input.plainDarkColors ?? null,
|
input.plainDarkColors ?? null, input.appFont ?? 'default',
|
||||||
]);
|
]);
|
||||||
const hit = documents.get(key);
|
const hit = documents.get(key);
|
||||||
if (hit && sameSources(hit.input, input)) {
|
if (hit && sameSources(hit.input, input)) {
|
||||||
|
|||||||
+17
-4
@@ -22,6 +22,7 @@
|
|||||||
// sheet is scrubbed of url()/@import/@font-face references instead.
|
// sheet is scrubbed of url()/@import/@font-face references instead.
|
||||||
|
|
||||||
import { DARK_PENDING_CLASS, emailColorScheme, emailDarkModeCss, resolveColorSchemeMedia } from './email-dark-mode';
|
import { DARK_PENDING_CLASS, emailColorScheme, emailDarkModeCss, resolveColorSchemeMedia } from './email-dark-mode';
|
||||||
|
import { mailFontStack, readingFontFaceCss, type AppFont } from '../theme/reading-fonts';
|
||||||
|
|
||||||
const FORBIDDEN_TAGS = [
|
const FORBIDDEN_TAGS = [
|
||||||
'script', 'iframe', 'object', 'embed', 'form',
|
'script', 'iframe', 'object', 'embed', 'form',
|
||||||
@@ -935,10 +936,22 @@ function cssColor(value: string, fallback: string): string {
|
|||||||
|
|
||||||
export function wrapPlainTextEmail(
|
export function wrapPlainTextEmail(
|
||||||
innerHtml: string,
|
innerHtml: string,
|
||||||
options: { isDark?: boolean; font?: PlainTextFont; fontPx?: number; darkColors?: PlainTextColors } = {},
|
options: {
|
||||||
|
isDark?: boolean;
|
||||||
|
font?: PlainTextFont;
|
||||||
|
fontPx?: number;
|
||||||
|
darkColors?: PlainTextColors;
|
||||||
|
/**
|
||||||
|
* The app's reading font (Settings > Accessibility > Font). "Same as
|
||||||
|
* above" (sans) draws the body in it, as the webmail does for a plain
|
||||||
|
* body in the app document; monospace stays monospace.
|
||||||
|
*/
|
||||||
|
readingFont?: AppFont;
|
||||||
|
} = {},
|
||||||
): string {
|
): string {
|
||||||
const { isDark = true, font = 'sans', fontPx = 14, darkColors } = options;
|
const { isDark = true, font = 'sans', fontPx = 14, darkColors, readingFont } = options;
|
||||||
const cleaned = stripDangerousTags(innerHtml);
|
const cleaned = stripDangerousTags(innerHtml);
|
||||||
|
const reading = font === 'sans' ? readingFont : undefined;
|
||||||
|
|
||||||
// In dark mode the body takes the app theme's ground, text and link colours
|
// In dark mode the body takes the app theme's ground, text and link colours
|
||||||
// when they are given (a built-in dark theme isn't #0a0a0a); light mode
|
// when they are given (a built-in dark theme isn't #0a0a0a); light mode
|
||||||
@@ -948,9 +961,9 @@ export function wrapPlainTextEmail(
|
|||||||
const link = isDark ? cssColor(darkColors?.link ?? '', '#60a5fa') : '#1a73e8';
|
const link = isDark ? cssColor(darkColors?.link ?? '', '#60a5fa') : '#1a73e8';
|
||||||
const fontFamily = font === 'mono'
|
const fontFamily = font === 'mono'
|
||||||
? 'ui-monospace, Menlo, Consolas, "SF Mono", monospace'
|
? 'ui-monospace, Menlo, Consolas, "SF Mono", monospace'
|
||||||
: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
: mailFontStack(reading);
|
||||||
|
|
||||||
const styles = `
|
const styles = `${readingFontFaceCss(reading)}
|
||||||
html, body { background: ${bg}; }
|
html, body { background: ${bg}; }
|
||||||
/* Pinch-zoomed content overflows sideways; it is panned via the injected
|
/* Pinch-zoomed content overflows sideways; it is panned via the injected
|
||||||
transform, never via native horizontal scrolling. */
|
transform, never via native horizontal scrolling. */
|
||||||
|
|||||||
@@ -213,6 +213,24 @@
|
|||||||
"version": "",
|
"version": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"url": "https://tabler.io/icons"
|
"url": "https://tabler.io/icons"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Atkinson Hyperlegible Next",
|
||||||
|
"version": "",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"url": "https://github.com/googlefonts/atkinson-hyperlegible-next"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Lexend",
|
||||||
|
"version": "",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"url": "https://github.com/googlefonts/lexend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "OpenDyslexic",
|
||||||
|
"version": "",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"url": "https://opendyslexic.org"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,8 +63,10 @@ export const SETTINGS_SEARCH_PATHS: Record<SettingsTabId, string[]> = {
|
|||||||
accessibility: [
|
accessibility: [
|
||||||
'settings.accessibility.presets.screen-reader',
|
'settings.accessibility.presets.screen-reader',
|
||||||
'settings.accessibility.presets.low-vision',
|
'settings.accessibility.presets.low-vision',
|
||||||
|
'settings.accessibility.presets.dyslexia',
|
||||||
'settings.accessibility.presets.motion',
|
'settings.accessibility.presets.motion',
|
||||||
'settings.accessibility.text_size',
|
'settings.accessibility.text_size',
|
||||||
|
'settings.accessibility.font',
|
||||||
'settings.accessibility.reduce_motion',
|
'settings.accessibility.reduce_motion',
|
||||||
'settings.accessibility.toast_duration',
|
'settings.accessibility.toast_duration',
|
||||||
'settings.accessibility.confirm_send',
|
'settings.accessibility.confirm_send',
|
||||||
@@ -267,7 +269,7 @@ export const SETTINGS_SEARCH_KEYWORDS: Record<SettingsTabId, string> = {
|
|||||||
recovery: 'recover restore undelete deleted trash emptied lost messages',
|
recovery: 'recover restore undelete deleted trash emptied lost messages',
|
||||||
language: 'locale region date time format translation',
|
language: 'locale region date time format translation',
|
||||||
notifications: 'alert push badge unifiedpush relay device reminder',
|
notifications: 'alert push badge unifiedpush relay device reminder',
|
||||||
accessibility: 'a11y screen reader talkback voiceover low vision text size font bigger larger motion animation preset undo timeout duration confirm',
|
accessibility: 'a11y screen reader talkback voiceover low vision text size font bigger larger motion animation preset undo timeout duration confirm dyslexia dyslexic typeface atkinson lexend opendyslexic',
|
||||||
appearance: 'theme dark light font size text color animation density',
|
appearance: 'theme dark light font size text color animation density',
|
||||||
layout: 'swipe gesture quick actions toolbar order sort unified folder count avatar',
|
layout: 'swipe gesture quick actions toolbar order sort unified folder count avatar',
|
||||||
reading: 'mark read preview thread conversation archive delete attachment open monospace mono font plain text receipt',
|
reading: 'mark read preview thread conversation archive delete attachment open monospace mono font plain text receipt',
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export type MailAttachmentAction = 'preview' | 'download';
|
|||||||
export type AttachmentPosition = 'beside-sender' | 'below-header';
|
export type AttachmentPosition = 'beside-sender' | 'below-header';
|
||||||
export type PlainTextFont = 'sans' | 'mono';
|
export type PlainTextFont = 'sans' | 'mono';
|
||||||
export type MessageSpacing = 'auto' | 'always' | 'edge';
|
export type MessageSpacing = 'auto' | 'always' | 'edge';
|
||||||
|
// a11y-reading: the webmail's reading fonts (lib/accessibility-settings.ts AppFont).
|
||||||
|
export type AppFont = 'default' | 'atkinson' | 'lexend' | 'opendyslexic';
|
||||||
|
export const APP_FONTS: readonly AppFont[] = ['default', 'atkinson', 'lexend', 'opendyslexic'];
|
||||||
export type ReadReceiptResponse = 'ask' | 'always' | 'never';
|
export type ReadReceiptResponse = 'ask' | 'always' | 'never';
|
||||||
export type PostExportAction = 'keep' | 'archive' | 'trash';
|
export type PostExportAction = 'keep' | 'archive' | 'trash';
|
||||||
export type SwipeAction =
|
export type SwipeAction =
|
||||||
@@ -159,6 +162,11 @@ export interface PersistedSettings {
|
|||||||
emailAlwaysLightMode: boolean;
|
emailAlwaysLightMode: boolean;
|
||||||
activeThemeId: string | null;
|
activeThemeId: string | null;
|
||||||
|
|
||||||
|
// a11y-reading: Settings > Accessibility (webmail lib/accessibility-
|
||||||
|
// settings.ts, same keys for export/import). The reading font for the
|
||||||
|
// app's text and plain-text mail (theme/reading-fonts).
|
||||||
|
appFont: AppFont;
|
||||||
|
|
||||||
// Composing
|
// Composing
|
||||||
autoSelectReplyIdentity: boolean;
|
autoSelectReplyIdentity: boolean;
|
||||||
replyIdentityMatch: ReplyIdentityMatch;
|
replyIdentityMatch: ReplyIdentityMatch;
|
||||||
@@ -359,6 +367,8 @@ const DEFAULT_PERSISTED: PersistedSettings = {
|
|||||||
animationsEnabled: true,
|
animationsEnabled: true,
|
||||||
emailAlwaysLightMode: false,
|
emailAlwaysLightMode: false,
|
||||||
activeThemeId: null,
|
activeThemeId: null,
|
||||||
|
// a11y-reading:
|
||||||
|
appFont: 'default',
|
||||||
|
|
||||||
autoSelectReplyIdentity: false,
|
autoSelectReplyIdentity: false,
|
||||||
replyIdentityMatch: 'domain',
|
replyIdentityMatch: 'domain',
|
||||||
@@ -617,6 +627,8 @@ const VALIDATORS: Partial<Record<keyof PersistedSettings, (v: unknown) => boolea
|
|||||||
// Levels are sanitized by the consumer (lib/message-list-order sanitizeSortLevels).
|
// Levels are sanitized by the consumer (lib/message-list-order sanitizeSortLevels).
|
||||||
messageListOrder: (v) => Array.isArray(v),
|
messageListOrder: (v) => Array.isArray(v),
|
||||||
fontSize: oneOf(FONT_SIZES),
|
fontSize: oneOf(FONT_SIZES),
|
||||||
|
// a11y-reading:
|
||||||
|
appFont: oneOf(APP_FONTS),
|
||||||
density: oneOf(['extra-compact', 'compact', 'regular', 'comfortable']),
|
density: oneOf(['extra-compact', 'compact', 'regular', 'comfortable']),
|
||||||
attachmentReminderKeywords: stringArray,
|
attachmentReminderKeywords: stringArray,
|
||||||
// Same set the webmail accepts (stores/settings-store.ts importSettings).
|
// Same set the webmail accepts (stores/settings-store.ts importSettings).
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// Reading fonts (Settings > Accessibility > Font, webmail
|
||||||
|
// lib/accessibility-settings.ts AppFont, app/accessibility.css): the names,
|
||||||
|
// the bundled Android files, the @font-face rules a mail body gets, and the
|
||||||
|
// wrapper that puts the font on the app's Text.
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
// The wrapper subscribes with useSyncExternalStore; outside a React render
|
||||||
|
// the test reads the snapshot directly.
|
||||||
|
vi.mock('react', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<Record<string, unknown>>()),
|
||||||
|
useSyncExternalStore: (_subscribe: unknown, getSnapshot: () => unknown) => getSnapshot(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
appFontAssetFamily,
|
||||||
|
appFontFamilyName,
|
||||||
|
mailFontStack,
|
||||||
|
readingFontFaceCss,
|
||||||
|
uiFontFamily,
|
||||||
|
uiFontsBundled,
|
||||||
|
} from '../reading-fonts';
|
||||||
|
import {
|
||||||
|
getUiFontFamily,
|
||||||
|
setUiFontFamily,
|
||||||
|
withFontFamily,
|
||||||
|
wrapModuleDefaults,
|
||||||
|
wrapWithReadingFont,
|
||||||
|
} from '../reading-font-text';
|
||||||
|
import { APP_FONTS, mergeWithDefaults } from '../../stores/settings-store';
|
||||||
|
import { presetChanges } from '../../lib/accessibility-presets';
|
||||||
|
|
||||||
|
const ASSETS = join(__dirname, '..', '..', '..', 'android', 'app', 'src', 'main', 'assets', 'fonts');
|
||||||
|
|
||||||
|
describe('reading fonts', () => {
|
||||||
|
it('offers the webmail fonts under its names and keys', () => {
|
||||||
|
expect(APP_FONTS).toEqual(['default', 'atkinson', 'lexend', 'opendyslexic']);
|
||||||
|
expect(appFontFamilyName('atkinson')).toBe('Atkinson Hyperlegible Next');
|
||||||
|
expect(appFontFamilyName('lexend')).toBe('Lexend');
|
||||||
|
expect(appFontFamilyName('opendyslexic')).toBe('OpenDyslexic');
|
||||||
|
expect(appFontFamilyName('default')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bundles a regular and a bold file on Android for every font', () => {
|
||||||
|
for (const font of APP_FONTS) {
|
||||||
|
const family = appFontAssetFamily(font);
|
||||||
|
if (font === 'default') {
|
||||||
|
expect(family).toBeNull();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
expect(existsSync(join(ASSETS, `${family}.ttf`))).toBe(true);
|
||||||
|
expect(existsSync(join(ASSETS, `${family}_bold.ttf`))).toBe(true);
|
||||||
|
}
|
||||||
|
expect(existsSync(join(ASSETS, 'OFL.txt'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names a family for the app text only where the fonts are bundled', () => {
|
||||||
|
expect(uiFontsBundled('android')).toBe(true);
|
||||||
|
expect(uiFontFamily('opendyslexic', 'android')).toBe('OpenDyslexic');
|
||||||
|
expect(uiFontFamily('default', 'android')).toBeNull();
|
||||||
|
// iOS reports an unknown family as an error.
|
||||||
|
expect(uiFontFamily('opendyslexic', 'ios')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('puts the font first in a mail body and keeps the system stack behind it', () => {
|
||||||
|
expect(mailFontStack('lexend')).toMatch(/^"Lexend", -apple-system, /);
|
||||||
|
expect(mailFontStack('default')).toMatch(/^-apple-system, /);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives a mail document the font as data: woff2 with the webmail unicode ranges', () => {
|
||||||
|
const css = readingFontFaceCss('atkinson');
|
||||||
|
const faces = css.split('\n');
|
||||||
|
expect(faces).toHaveLength(2);
|
||||||
|
for (const face of faces) {
|
||||||
|
expect(face).toContain('font-family: "Atkinson Hyperlegible Next";');
|
||||||
|
expect(face).toContain('font-weight: 200 800;');
|
||||||
|
// "wOF2", the woff2 signature, in base64.
|
||||||
|
expect(face).toMatch(/src: url\("data:font\/woff2;base64,d09GMg[A-Za-z0-9+/=]+"\) format\("woff2"\);/);
|
||||||
|
expect(face).toMatch(/unicode-range: U\+/);
|
||||||
|
}
|
||||||
|
expect(readingFontFaceCss('opendyslexic')).toContain('font-family: "OpenDyslexic";');
|
||||||
|
expect(readingFontFaceCss('default')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a stored font and drops one the app does not know', () => {
|
||||||
|
expect(mergeWithDefaults({ appFont: 'lexend' }).appFont).toBe('lexend');
|
||||||
|
expect(mergeWithDefaults({ appFont: 'comic-sans' as never }).appFont).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has a dyslexia preset that picks the readable font for the app and plain-text mail', () => {
|
||||||
|
expect(presetChanges('dyslexia', { appFont: 'default', plainTextFont: 'mono' })).toEqual([
|
||||||
|
{ key: 'appFont', from: 'default', to: 'atkinson' },
|
||||||
|
{ key: 'plainTextFont', from: 'mono', to: 'sans' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the reading font on the app text', () => {
|
||||||
|
it('puts the family under the caller style, which still wins', () => {
|
||||||
|
const own = { color: 'red' };
|
||||||
|
expect(withFontFamily({ style: own }, 'Lexend')).toEqual({ style: [{ fontFamily: 'Lexend' }, own] });
|
||||||
|
expect(withFontFamily({}, 'Lexend')).toEqual({ style: { fontFamily: 'Lexend' } });
|
||||||
|
const props = { style: own };
|
||||||
|
expect(withFontFamily(props, null)).toBe(props);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the original component with the current family', () => {
|
||||||
|
const seen: unknown[] = [];
|
||||||
|
const Original = Object.assign((props: Record<string, unknown>) => { seen.push(props); return null; }, {
|
||||||
|
displayName: 'TextInput',
|
||||||
|
State: { currentlyFocusedInput: () => null },
|
||||||
|
});
|
||||||
|
const Wrapped = wrapWithReadingFont(Original);
|
||||||
|
// Statics such as TextInput.State stay reachable.
|
||||||
|
expect((Wrapped as unknown as typeof Original).State).toBe(Original.State);
|
||||||
|
expect(Wrapped.displayName).toBe('TextInput');
|
||||||
|
|
||||||
|
setUiFontFamily(null);
|
||||||
|
Wrapped({ style: { color: 'red' }, children: 'x' });
|
||||||
|
expect(seen.pop()).toEqual({ style: { color: 'red' }, children: 'x' });
|
||||||
|
|
||||||
|
setUiFontFamily('OpenDyslexic');
|
||||||
|
expect(getUiFontFamily()).toBe('OpenDyslexic');
|
||||||
|
Wrapped({ style: { color: 'red' }, children: 'x' });
|
||||||
|
expect(seen.pop()).toEqual({ style: [{ fontFamily: 'OpenDyslexic' }, { color: 'red' }], children: 'x' });
|
||||||
|
setUiFontFamily(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps each module default once and leaves non-functions alone', () => {
|
||||||
|
const text = { default: () => null };
|
||||||
|
const memo = { default: { $$typeof: Symbol.for('react.memo') } };
|
||||||
|
const original = text.default;
|
||||||
|
expect(wrapModuleDefaults([text, memo])).toBe(1);
|
||||||
|
expect(text.default).not.toBe(original);
|
||||||
|
const wrapped = text.default;
|
||||||
|
expect(wrapModuleDefaults([text])).toBe(0);
|
||||||
|
expect(text.default).toBe(wrapped);
|
||||||
|
expect(memo.default).toEqual({ $$typeof: Symbol.for('react.memo') });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Installs the reading font for the app's text (./reading-font-text). Imported
|
||||||
|
// first thing by index.ts, before any screen module copies `Text` out of
|
||||||
|
// react-native. Android only: iOS has no bundled reading fonts yet
|
||||||
|
// (./reading-fonts). The settings store is required after the wrapping, so
|
||||||
|
// nothing it loads can copy the plain Text first.
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
import { setUiFontFamily, wrapModuleDefaults } from './reading-font-text';
|
||||||
|
import { uiFontFamily, uiFontsBundled } from './reading-fonts';
|
||||||
|
|
||||||
|
if (uiFontsBundled(Platform.OS)) {
|
||||||
|
wrapModuleDefaults([
|
||||||
|
require('react-native/Libraries/Text/Text'),
|
||||||
|
require('react-native/Libraries/Components/TextInput/TextInput'),
|
||||||
|
]);
|
||||||
|
const { useSettingsStore } = require('../stores/settings-store') as typeof import('../stores/settings-store');
|
||||||
|
setUiFontFamily(uiFontFamily(useSettingsStore.getState().appFont, Platform.OS));
|
||||||
|
useSettingsStore.subscribe((state, prev) => {
|
||||||
|
if (state.appFont !== prev.appFont) setUiFontFamily(uiFontFamily(state.appFont, Platform.OS));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// The reading font for the app's own text (Settings > Accessibility > Font).
|
||||||
|
//
|
||||||
|
// The webmail sets one CSS variable on <html> and every piece of text follows
|
||||||
|
// (app/accessibility.css data-app-font). React Native has no inherited font:
|
||||||
|
// each Text takes its family from its own style, the app's styles name none,
|
||||||
|
// and Text.defaultProps is gone on the new architecture. So the app's Text
|
||||||
|
// and TextInput components are wrapped once at startup: the wrapper puts the
|
||||||
|
// chosen family under the caller's own style (an explicit fontFamily still
|
||||||
|
// wins) and re-renders when the setting changes.
|
||||||
|
//
|
||||||
|
// Wrapping happens before any screen module loads (index.ts imports
|
||||||
|
// ./reading-font-install first): with Metro's import support every module
|
||||||
|
// copies `Text` out of react-native when it is evaluated, so a module loaded
|
||||||
|
// earlier keeps the plain component. The wrapper stays installed for the
|
||||||
|
// whole run, also while the theme font is chosen; swapping the component
|
||||||
|
// later would remount every text field.
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
type Props = { style?: unknown } & Record<string, unknown>;
|
||||||
|
type FunctionComponent = ((props: Props) => unknown) & { displayName?: string };
|
||||||
|
|
||||||
|
let family: string | null = null;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
const subscribe = (listener: () => void) => {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => { listeners.delete(listener); };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The family the app's text is drawn in right now, or null for the theme font. */
|
||||||
|
export function getUiFontFamily(): string | null {
|
||||||
|
return family;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Changes the family; every wrapped Text re-renders. */
|
||||||
|
export function setUiFontFamily(next: string | null): void {
|
||||||
|
if (next === family) return;
|
||||||
|
family = next;
|
||||||
|
for (const listener of [...listeners]) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `props` with the family under the caller's own style; unchanged for null. */
|
||||||
|
export function withFontFamily<P extends { style?: unknown }>(props: P, fontFamily: string | null): P {
|
||||||
|
if (!fontFamily) return props;
|
||||||
|
return { ...props, style: props.style == null ? { fontFamily } : [{ fontFamily }, props.style] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const WRAPPED = Symbol.for('bulwark.readingFont');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component that renders `Original` with the reading font. `Original` is
|
||||||
|
* called as a function, not rendered as a child, so no extra component is
|
||||||
|
* added per text: its hooks run as part of the wrapper, always in the same
|
||||||
|
* order. Statics (TextInput.State) and the display name are kept.
|
||||||
|
*/
|
||||||
|
export function wrapWithReadingFont(Original: FunctionComponent): FunctionComponent {
|
||||||
|
const Wrapped = function ReadingFontText(props: Props) {
|
||||||
|
const current = useSyncExternalStore(subscribe, getUiFontFamily, getUiFontFamily);
|
||||||
|
return Original(withFontFamily(props, current));
|
||||||
|
} as FunctionComponent;
|
||||||
|
Object.assign(Wrapped, Original);
|
||||||
|
Wrapped.displayName = Original.displayName ?? Original.name;
|
||||||
|
(Wrapped as unknown as Record<symbol, unknown>)[WRAPPED] = true;
|
||||||
|
return Wrapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps the default export of each module (react-native's Text and
|
||||||
|
* TextInput) in place. Only plain function components are wrapped; anything
|
||||||
|
* else (a future memo or forwardRef object) is left alone. Returns how many
|
||||||
|
* were wrapped; a second call wraps nothing.
|
||||||
|
*/
|
||||||
|
export function wrapModuleDefaults(modules: Array<{ default?: unknown }>): number {
|
||||||
|
let wrapped = 0;
|
||||||
|
for (const mod of modules) {
|
||||||
|
const component = mod?.default;
|
||||||
|
if (typeof component !== 'function') continue;
|
||||||
|
if ((component as unknown as Record<symbol, unknown>)[WRAPPED]) continue;
|
||||||
|
try {
|
||||||
|
mod.default = wrapWithReadingFont(component as FunctionComponent);
|
||||||
|
wrapped++;
|
||||||
|
} catch {
|
||||||
|
// A read-only export: leave the component as it is.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return wrapped;
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,97 @@
|
|||||||
|
// The reading fonts on Settings > Accessibility > Font (webmail
|
||||||
|
// lib/accessibility-settings.ts AppFont, app/accessibility.css,
|
||||||
|
// lib/mail-reading.ts appFontFamilyName): Atkinson Hyperlegible Next, Lexend
|
||||||
|
// and OpenDyslexic. As in the webmail the font is the app's text font, and a
|
||||||
|
// plain-text mail body (which the webmail draws in the app document) uses it
|
||||||
|
// too; HTML mail keeps the sender's fonts.
|
||||||
|
//
|
||||||
|
// The app's text: Android finds the fonts in android/app/src/main/assets/
|
||||||
|
// fonts by family name (scripts/build-reading-fonts.py), and the Text patch in
|
||||||
|
// ./reading-font-text applies the family. iOS needs them registered in the
|
||||||
|
// app bundle (expo-font's config plugin), which the app doesn't have yet, so
|
||||||
|
// there the setting only reaches mail bodies.
|
||||||
|
//
|
||||||
|
// A mail body renders in a sandboxed WebView, which gets the woff2 files as
|
||||||
|
// data: URIs (./reading-fonts-data: a few hundred KB of base64 string
|
||||||
|
// constants, which Hermes keeps in the bytecode's string table rather than
|
||||||
|
// parsing them at startup).
|
||||||
|
|
||||||
|
import type { AppFont } from '../stores/settings-store';
|
||||||
|
import { ATKINSON, LEXEND, OPENDYSLEXIC, type ReadingFontFace } from './reading-fonts-data';
|
||||||
|
|
||||||
|
export type { AppFont };
|
||||||
|
|
||||||
|
/** The CSS family name a font is bundled under (webmail appFontFamilyName), or null for the default. */
|
||||||
|
export function appFontFamilyName(font: AppFont | null | undefined): string | null {
|
||||||
|
switch (font) {
|
||||||
|
case 'atkinson': return 'Atkinson Hyperlegible Next';
|
||||||
|
case 'lexend': return 'Lexend';
|
||||||
|
case 'opendyslexic': return 'OpenDyslexic';
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Android asset family (assets/fonts/<name>.ttf and <name>_bold.ttf). */
|
||||||
|
export function appFontAssetFamily(font: AppFont | null | undefined): string | null {
|
||||||
|
switch (font) {
|
||||||
|
case 'atkinson': return 'AtkinsonHyperlegibleNext';
|
||||||
|
case 'lexend': return 'Lexend';
|
||||||
|
case 'opendyslexic': return 'OpenDyslexic';
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the platform has the fonts for the app's own text. */
|
||||||
|
export function uiFontsBundled(platform: string): boolean {
|
||||||
|
return platform === 'android';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fontFamily the app's text is drawn in: null for the theme font, and
|
||||||
|
* where the fonts aren't bundled (an unknown family is an error on iOS).
|
||||||
|
*/
|
||||||
|
export function uiFontFamily(font: AppFont | null | undefined, platform: string): string | null {
|
||||||
|
return uiFontsBundled(platform) ? appFontAssetFamily(font) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The mail body's own font stack (lib/email-html). */
|
||||||
|
export const MAIL_SYSTEM_FONT_STACK = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* font-family for a mail body: the reading font first (it covers Latin
|
||||||
|
* script; other scripts fall through to the system stack, as in the webmail).
|
||||||
|
*/
|
||||||
|
export function mailFontStack(font: AppFont | null | undefined): string {
|
||||||
|
const family = appFontFamilyName(font);
|
||||||
|
return family ? `"${family}", ${MAIL_SYSTEM_FONT_STACK}` : MAIL_SYSTEM_FONT_STACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
function faceFor(font: AppFont): ReadingFontFace | null {
|
||||||
|
switch (font) {
|
||||||
|
case 'atkinson': return ATKINSON;
|
||||||
|
case 'lexend': return LEXEND;
|
||||||
|
case 'opendyslexic': return OPENDYSLEXIC;
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The @font-face rules a mail document needs for the font, with the fonts as
|
||||||
|
* data: URIs (the page's CSP allows font-src data: in every mode). Empty for
|
||||||
|
* the default font.
|
||||||
|
*/
|
||||||
|
export function readingFontFaceCss(font: AppFont | null | undefined): string {
|
||||||
|
if (!font || font === 'default') return '';
|
||||||
|
const face = faceFor(font);
|
||||||
|
if (!face) return '';
|
||||||
|
return face.subsets.map((subset) => [
|
||||||
|
'@font-face {',
|
||||||
|
`font-family: "${face.family}";`,
|
||||||
|
'font-style: normal;',
|
||||||
|
'font-display: swap;',
|
||||||
|
`font-weight: ${face.weight};`,
|
||||||
|
`src: url("data:font/woff2;base64,${subset.woff2}") format("woff2");`,
|
||||||
|
subset.unicodeRange ? `unicode-range: ${subset.unicodeRange};` : '',
|
||||||
|
'}',
|
||||||
|
].filter(Boolean).join(' ')).join('\n');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user