Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion frontend/src/components/common/LanguageSelect.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// files from the locales folder are listed after the built-ins; their value
// is "user:<id>".
import { onMount } from 'svelte'
import { IconCheck } from '@tabler/icons-svelte'
import { IconCheck, IconWorld } from '@tabler/icons-svelte'
import { locales, localeNames, detectOSLocale, userLocalePrefix, t } from '../../lib/i18n'
import { flagFor } from '../../lib/flags'
import { listUserLocales } from '../../lib/api'
Expand All @@ -34,6 +34,10 @@
<button type="button" class="lang-card" class:active={value === l} on:click={() => onSelect(l)} role="option" aria-selected={value === l}>
{#if flagFor(l)}
<img class="lang-flag" src={flagFor(l)} alt="" draggable="false" />
{:else}
<!-- a language no single country's flag stands for (#440). The mark
keeps the tiles aligned and reads as chosen rather than missing. -->
<IconWorld class="lang-flag lang-globe" size={16} stroke={1.8} />
{/if}
<span class="lang-name">{localeNames[l]}</span>
{#if l === recommended}
Expand Down Expand Up @@ -98,6 +102,12 @@
object-fit: cover;
}

/* the neutral mark occupies the flag's column so the names stay in line, and
sits back a shade since it is standing in for a flag rather than being one. */
:global(.lang-globe) {
color: var(--text-tertiary);
}

.lang-card:hover {
background: var(--surface-hover);
}
Expand Down
42 changes: 42 additions & 0 deletions frontend/src/lib/flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,48 @@ describe('flagFor', () => {
withSystemLanguage('zh-CN')
expect(countryOf(flagFor('zh-CN'))).toBe('cn')
})

// #440: Arabic used to borrow whatever region the reader's machine named,
// which showed Portugal's flag beside العربية on a machine set to Portuguese.
// No country's flag stands for the language, so it gets none.
it('gives arabic no flag from a region that is not its own', () => {
for (const tag of ['pt-PT', 'de-DE', 'en-US']) {
withSystemLanguage(tag)
expect(flagFor('ar'), tag).toBeUndefined()
}
})

// a reader whose own system is set to Arabic in a country is a different
// matter: that region is their own setting rather than a country picked for
// them, and it is the same rule that gives an en-US machine the US flag.
it('keeps a region the reader set themselves', () => {
withSystemLanguage('ar-EG')
expect(countryOf(flagFor('ar'))).toBe('eg')
withSystemLanguage('ar-MA')
expect(countryOf(flagFor('ar'))).toBe('ma')
})

// the same fallback would have done it to any language without an entry, so
// the rule is general rather than a special case for one language.
it('gives an unknown language no flag rather than the readers own', () => {
withSystemLanguage('pt-PT')
expect(flagFor('ja')).toBeUndefined()
expect(flagFor('user:mine')).toBeUndefined()
})

// the languages that do have a flag must keep it: this narrows what is shown,
// and it would be easy to narrow it too far.
it('still resolves every language that has one', () => {
withSystemLanguage('de-DE')
expect(countryOf(flagFor('de'))).toBe('de')
expect(countryOf(flagFor('fr'))).toBe('fr')
expect(countryOf(flagFor('it'))).toBe('it')
expect(countryOf(flagFor('pt'))).toBe('pt')
expect(countryOf(flagFor('nl'))).toBe('nl')
expect(countryOf(flagFor('es'))).toBe('es')
expect(countryOf(flagFor('pl'))).toBe('pl')
expect(countryOf(flagFor('tr'))).toBe('tr')
})
})

describe('detectOSLocale', () => {
Expand Down
44 changes: 11 additions & 33 deletions frontend/src/lib/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
// gets the US flag and an en-GB one the union flag without asking anybody or
// touching the network.
//
// A language with no entry here gets no flag, and the picker shows a neutral
// mark in its place. It used to borrow whatever region the reader's machine
// named, which put Portugal's flag next to العربية for a reader in Portugal
// (#440): a flag nobody chose, attached to a language it has nothing to do
// with. Showing none says what is true.
//
// The svgs come from flag-icons (MIT) and are bundled, never fetched.

/** Every flag in the set, keyed by lowercase ISO 3166-1 alpha-2 code. */
Expand All @@ -23,7 +29,8 @@ for (const [path, url] of Object.entries(files)) {
}

// the flag a language falls back to when the operating system names no region,
// or names one the language is not spoken in.
// or names one the language is not spoken in. A language missing from here
// shows no flag at all.
const defaultCountry: Record<string, string> = {
en: 'gb',
de: 'de',
Expand All @@ -34,8 +41,8 @@ const defaultCountry: Record<string, string> = {
pl: 'pl',
tr: 'tr',
pt: 'pt',
// ar has no entry: it is spoken across many regions, so flagFor falls back to
// the reader's own.
// ar has no entry and is given none: no single country's flag stands for the
// language, so the picker shows the neutral mark.
// keyed by the full tag: a bare "zh" would also claim zh-TW, which uses a
// different flag.
'zh-cn': 'cn',
Expand All @@ -54,39 +61,10 @@ export function flagFor(language: string): string | undefined {
const pinned = defaultCountry[tag]
return pinned ? byCountry[pinned] : undefined
}
// last resort is the reader's own region, for a language spoken across many
// of them.
const country = osRegionFor(tag) ?? defaultCountry[tag] ?? osRegion()
const country = osRegionFor(tag) ?? defaultCountry[tag]
return country ? byCountry[country] : undefined
}

/**
* The reader's own region, whatever language it belongs to.
*
* Every tag is read, not just the first. navigator.language is the interface
* language's conventional tag, and the webview rewrites one it does not
* recognize: an "en-DE" machine (English interface, German region) reports
* "en-GB" there, which is a country the reader has nothing to do with. The rest
* of the list keeps the real one, so the first tag carrying a region we have a
* flag for wins.
*
* It is best effort. A machine that names no region anywhere gets no flag,
* which is the honest answer rather than a guess.
*/
function osRegion(): string | undefined {
if (typeof navigator === 'undefined') {
return undefined
}
const tags = navigator.languages?.length ? navigator.languages : [navigator.language || '']
for (const tag of tags) {
const region = tag.toLowerCase().split('-')[1]
if (region && region.length === 2 && byCountry[region]) {
return region
}
}
return undefined
}

/**
* The operating system's region, but only when it belongs to the language being
* asked about. A de-DE install says nothing about which Spanish the user means,
Expand Down
Loading