fix: show the server where two accounts with one address would read the same

This commit is contained in:
Linus Rath
2026-10-31 20:08:58 +01:00
parent bac51fbc46
commit 3b3a2fc670
7 changed files with 411 additions and 51 deletions
+7 -3
View File
@@ -9,6 +9,7 @@ import { radius, spacing, typography, type ThemePalette } from '../../theme/toke
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { useAccountStore } from '../../stores/account-store';
import { distinctAccountLabels } from '../../lib/account-labels';
import { useSearchHistoryStore } from '../../stores/search-history-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { GLOBAL_SEARCH_PROVIDERS, getSearchAccounts } from '../../api/global-search';
@@ -60,6 +61,9 @@ export function GlobalSearchSheet({ visible, initialQuery = '', onClose, navigat
const t = useLocaleStore((s) => s.t);
const locale = useLocaleStore((s) => s.locale);
const accounts = useAccountStore((s) => s.accounts);
// One address on two servers reads the same: those get the server added.
const accountLabels = React.useMemo(() => distinctAccountLabels(accounts), [accounts]);
const accountLabelOf = (localAccountId: string, fallback: string) => accountLabels.get(localAccountId) ?? fallback;
const recentSearches = useSearchHistoryStore((s) => s.recentSearches);
const addRecentSearch = useSearchHistoryStore((s) => s.addRecentSearch);
@@ -131,7 +135,7 @@ export function GlobalSearchSheet({ visible, initialQuery = '', onClose, navigat
const renderRow = (hit: GlobalSearchHit) => {
const title = hit.title || (hit.kind === 'mail' ? t('global_search.no_subject', '(no subject)') : hit.id);
const subtitle = [hit.accountLabel, hit.subtitle].filter(Boolean).join(' · ');
const subtitle = [accountLabelOf(hit.localAccountId, hit.accountLabel), hit.subtitle].filter(Boolean).join(' · ');
const snippet = hit.kind === 'mail' && hit.snippet?.preview ? hit.snippet.preview : null;
return (
<Pressable
@@ -247,7 +251,7 @@ export function GlobalSearchSheet({ visible, initialQuery = '', onClose, navigat
<>
<View style={styles.chipDivider} />
{chip('all-accounts', t('global_search.all_accounts', 'All accounts'), accountId === null, () => setAccountId(null))}
{accounts.map((a) => chip(a.id, a.displayName || a.email || a.username, accountId === a.id, () => setAccountId(a.id)))}
{accounts.map((a) => chip(a.id, accountLabelOf(a.id, a.displayName || a.email || a.username), accountId === a.id, () => setAccountId(a.id)))}
</>
) : null}
</ScrollView>
@@ -270,7 +274,7 @@ export function GlobalSearchSheet({ visible, initialQuery = '', onClose, navigat
{errors.map((error) => (
<View key={`${error.localAccountId}:${error.message}`} style={styles.errorRow}>
<AlertTriangle size={14} color={c.warning} />
<Text style={styles.rowSub}>{t('global_search.account_error', "Couldn't search {account}", { account: error.accountLabel })}</Text>
<Text style={styles.rowSub}>{t('global_search.account_error', "Couldn't search {account}", { account: accountLabelOf(error.localAccountId, error.accountLabel) })}</Text>
</View>
))}
{!trimmed && !showRecent ? (
+106
View File
@@ -0,0 +1,106 @@
// One address on two servers (usera@ on :8120 and on :8016) read the same in
// the global search chips and hit lines and on the add-account screen, and
// "Another account on …" offered only the active account's server.
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import {
accountServerLine,
ambiguousPushLabel,
distinctAccountLabels,
distinctServerUrls,
pushAccountLabel,
} from '../account-labels';
const a1 = {
id: 'usera@example.org@10.10.10.62:8120',
serverUrl: 'http://10.10.10.62:8120',
username: 'usera@example.org',
email: 'usera@example.org',
displayName: 'User A',
};
const a2 = { ...a1, id: 'usera@example.org@10.10.10.62:8016', serverUrl: 'http://10.10.10.62:8016' };
const b = {
id: 'userb@example.org@mail.example.org',
serverUrl: 'https://mail.example.org',
username: 'userb@example.org',
email: 'userb@example.org',
displayName: 'User B',
};
const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8');
describe('distinctAccountLabels', () => {
it('adds the server where two accounts would read the same', () => {
const labels = distinctAccountLabels([a1, a2, b]);
expect(labels.get(a1.id)).toBe('usera@example.org (10.10.10.62:8120)');
expect(labels.get(a2.id)).toBe('usera@example.org (10.10.10.62:8016)');
expect(labels.get(b.id)).toBe('User B');
});
it('leaves a lone account by its name', () => {
expect(distinctAccountLabels([a1, b]).get(a1.id)).toBe('User A');
});
it('falls back to the full URL when two servers only differ in scheme', () => {
const http = { ...b, id: 'x', serverUrl: 'http://mail.example.org' };
const labels = distinctAccountLabels([b, http]);
expect(labels.get(b.id)).toBe('userb@example.org (https://mail.example.org)');
expect(labels.get('x')).toBe('userb@example.org (http://mail.example.org)');
});
});
describe('accountServerLine', () => {
it('names the server when another account has the same address', () => {
expect(accountServerLine(a1, [a1, a2])).toBe('10.10.10.62:8120');
});
it('names a server on a non-default port, as the account settings do', () => {
expect(accountServerLine(a1, [a1, b])).toBe('10.10.10.62:8120');
});
it('leaves it out on a default port with nobody to confuse it with', () => {
expect(accountServerLine(b, [a1, b])).toBeNull();
});
});
describe('distinctServerUrls', () => {
it('offers every server once, the active account first', () => {
const third = { ...a1, id: 'other@10.10.10.62:8120', serverUrl: 'http://10.10.10.62:8120/' };
expect(distinctServerUrls([a1, a2, third, b], a2.id)).toEqual([a2.serverUrl, a1.serverUrl, b.serverUrl]);
});
});
describe('push notification labels', () => {
const registry = [a1, a2, b];
it('adds the server to the account a push names when another login has the same username', () => {
expect(pushAccountLabel(a2.id, 'usera@example.org', registry)).toBe('usera@example.org (10.10.10.62:8016)');
expect(pushAccountLabel(b.id, 'userb@example.org', registry)).toBe('userb@example.org');
});
it('names the servers a push may be from when it fits two logins', () => {
expect(ambiguousPushLabel([a1.id, a2.id], 'usera@example.org', registry))
.toBe('usera@example.org (10.10.10.62:8120, 10.10.10.62:8016)');
expect(ambiguousPushLabel([b.id], 'userb@example.org', registry)).toBe('userb@example.org');
});
});
describe('where the labels are used', () => {
it('global search names chips, hits and errors with them', () => {
const src = read('components/search/GlobalSearchSheet.tsx');
expect(src).toMatch(/distinctAccountLabels\(accounts\)/);
expect(src).toMatch(/accountLabelOf\(hit\.localAccountId, hit\.accountLabel\)/);
expect(src).toMatch(/chip\(a\.id, accountLabelOf\(a\.id,/);
expect(src).toMatch(/accountLabelOf\(error\.localAccountId, error\.accountLabel\)/);
});
it('the add-account screen shows the server line and every known server', () => {
const choose = read('screens/login/ChooseStep.tsx');
expect(choose).toMatch(/accountServerLine\(account, accounts\)/);
expect(choose).toMatch(/knownServerUrls\.map\(/);
const login = read('screens/LoginScreen.tsx');
expect(login).toMatch(/distinctServerUrls\(accounts, activeAccountId\)/);
expect(login).toMatch(/knownServerUrls=\{isAddMode \? addServerUrls : \[\]\}/);
});
});
@@ -0,0 +1,74 @@
// Pushes for usera@ on two servers both read "New email / usera@example.org".
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('../../api/jmap-client', () => ({
jmapClient: {
accountId: 'jmap-primary',
getStoredCredentials: vi.fn(async () => null),
setStoredCredentials: vi.fn(async () => undefined),
},
}));
vi.mock('../client-cert', () => ({ secureFetch: vi.fn(async () => ({ ok: false, status: 500 })) }));
vi.mock('../oauth', () => ({ refreshOAuthAccessToken: vi.fn(async (t: unknown) => t) }));
import AsyncStorage from '@react-native-async-storage/async-storage';
import { NativeModules } from 'react-native';
import { jmapClient } from '../../api/jmap-client';
import { pushBackgroundTask } from '../push-background-task';
const ONE = 'usera@example.org@10.10.10.62:8120';
const TWO = 'usera@example.org@10.10.10.62:8016';
describe('push notifications for one address on two servers', () => {
const showNotification = vi.fn(async () => undefined);
beforeEach(async () => {
vi.clearAllMocks();
await AsyncStorage.clear();
await AsyncStorage.setItem('account-registry', JSON.stringify({
state: {
accounts: [
{ id: ONE, username: 'usera@example.org', serverUrl: 'http://10.10.10.62:8120' },
{ id: TWO, username: 'usera@example.org', serverUrl: 'http://10.10.10.62:8016' },
],
},
}));
(NativeModules as Record<string, unknown>).BulwarkFcm = { showNotification };
(jmapClient.getStoredCredentials as ReturnType<typeof vi.fn>).mockResolvedValue(null);
});
afterEach(() => {
delete (NativeModules as Record<string, unknown>).BulwarkFcm;
});
it('names the servers on a push that fits both logins', async () => {
await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([ONE, TWO]));
await AsyncStorage.setItem('push:jmapAccountIds:v1', JSON.stringify({ [ONE]: 'b', [TWO]: 'b' }));
await pushBackgroundTask({ kind: 'jmap-email-push', accountLabel: 'usera@example.org', accountId: 'b', emailIds: '["m1"]' });
expect(showNotification).toHaveBeenCalledTimes(1);
expect((showNotification.mock.calls[0] as unknown[])[0]).toMatchObject({
title: 'New email',
body: 'usera@example.org (10.10.10.62:8120, 10.10.10.62:8016)',
});
});
it('names the server of a login whose lookup failed', async () => {
await AsyncStorage.setItem('push:accountIds:v1', JSON.stringify([ONE, TWO]));
await AsyncStorage.setItem('push:jmapAccountIds:v1', JSON.stringify({ [ONE]: 'a', [TWO]: 'b' }));
// The session can't be opened: the lookup fails.
(jmapClient.getStoredCredentials as ReturnType<typeof vi.fn>).mockResolvedValue({
serverUrl: 'http://10.10.10.62:8016',
username: 'usera@example.org',
password: 'secret',
});
await pushBackgroundTask({ kind: 'jmap-email-push', accountLabel: 'usera@example.org', accountId: 'b', emailIds: '["m1"]' });
expect(showNotification).toHaveBeenCalledTimes(1);
expect((showNotification.mock.calls[0] as unknown[])[0]).toMatchObject({
title: 'New email',
body: 'usera@example.org (10.10.10.62:8016)',
groupTitle: 'usera@example.org (10.10.10.62:8016)',
});
});
});
+139
View File
@@ -0,0 +1,139 @@
// Telling apart accounts that read the same. One address on two servers (or
// two ports of one host) is two accounts, and the places that name an
// account in one line (global search chips and hit lines, the add-account
// screen, push notifications) showed two identical "User A". The switcher
// and the account settings already show the server with its port when that
// isn't the default (account-utils serverHostLabel); these do the same, but
// only where another account would otherwise read the same.
import { accountRowText, nonDefaultPort, serverHostLabel, type NamedAccount } from './account-utils';
const key = (value: string) => value.trim().toLowerCase();
/** The server as the switcher names it; the full URL when two only differ in scheme. */
function serverName(serverUrl: string, others: readonly string[]): string {
const host = serverHostLabel(serverUrl);
const clash = others.some((u) => key(u.replace(/\/+$/, '')) !== key(serverUrl.replace(/\/+$/, ''))
&& key(serverHostLabel(u)) === key(host));
return clash ? serverUrl.replace(/\/+$/, '') : host;
}
/**
* `label` per id, with the server added in brackets when another item has
* the same label: "usera@example.org (mail.example.org:8016)".
*/
export function labelsWithServer(
items: ReadonlyArray<{ id: string; label: string; serverUrl?: string }>,
): Map<string, string> {
const out = new Map<string, string>();
for (const item of items) {
const twins = items.filter((o) => o.id !== item.id && key(o.label) === key(item.label));
if (twins.length === 0 || !item.serverUrl) {
out.set(item.id, item.label);
continue;
}
const server = serverName(item.serverUrl, twins.map((o) => o.serverUrl ?? ''));
out.set(item.id, `${item.label} (${server})`);
}
return out;
}
export interface ServerAccount extends NamedAccount {
serverUrl: string;
}
/**
* The one-line name of each account (a chip, a search hit's account): the
* name, or the address when another account has the same name
* (accountRowText), plus the server when that still reads the same.
*/
export function distinctAccountLabels(accounts: readonly ServerAccount[]): Map<string, string> {
return labelsWithServer(accounts.map((a) => ({
id: a.id,
label: accountRowText(a, accounts).title,
serverUrl: a.serverUrl,
})));
}
/**
* The server line under an account row: shown, as in the account settings,
* when the server is on a port other than the default, and also when another
* account has the same address. Null when the row doesn't need one.
*/
export function accountServerLine(account: ServerAccount, accounts: readonly ServerAccount[]): string | null {
const address = key(account.email || account.username);
const shared = accounts.some((a) => a.id !== account.id && key(a.email || a.username) === address);
if (!shared && !nonDefaultPort(account.serverUrl)) return null;
return serverName(account.serverUrl, accounts.filter((a) => a.id !== account.id).map((a) => a.serverUrl));
}
/**
* The servers the accounts are on, each once, the active account's first:
* the add-account screen offers "Another account on …" for each.
*/
export function distinctServerUrls(
accounts: ReadonlyArray<{ id: string; serverUrl: string }>,
activeAccountId: string | null,
): string[] {
const ordered = [
...accounts.filter((a) => a.id === activeAccountId),
...accounts.filter((a) => a.id !== activeAccountId),
];
const seen = new Set<string>();
const out: string[] = [];
for (const a of ordered) {
if (!a.serverUrl) continue;
const k = key(a.serverUrl.replace(/\/+$/, ''));
if (seen.has(k)) continue;
seen.add(k);
out.push(a.serverUrl);
}
return out;
}
/** What the push task reads from the stored account registry. */
export interface RegistryLabelAccount {
id: string;
username?: string;
serverUrl?: string;
}
/**
* The account a push notification names (the relay's label, the username),
* with the server when another signed-in account has the same username.
*/
export function pushAccountLabel(
accountId: string,
label: string,
registry: readonly RegistryLabelAccount[],
): string {
if (!label || !registry.some((a) => a.id === accountId)) return label;
const items = registry.map((a) => ({
id: a.id,
label: a.id === accountId ? label : a.username ?? '',
serverUrl: a.serverUrl,
}));
return labelsWithServer(items).get(accountId) ?? label;
}
/**
* The account line of a push that more than one login could own (see
* matchAccountsForPush): the label, and when those logins are on different
* servers, the servers it may have come from.
*/
export function ambiguousPushLabel(
candidateIds: readonly string[],
label: string,
registry: readonly RegistryLabelAccount[],
): string {
const urls = candidateIds
.map((id) => registry.find((a) => a.id === id)?.serverUrl)
.filter((u): u is string => !!u);
const servers: string[] = [];
for (const url of urls) {
const name = serverName(url, urls);
if (!servers.includes(name)) servers.push(name);
}
if (servers.length < 2) return label;
return label ? `${label} (${servers.join(', ')})` : servers.join(', ');
}
+22 -7
View File
@@ -6,6 +6,7 @@ import type { Email, JMAPMethodCall, JMAPSession, Mailbox } from '../api/types';
import { detectDeviceLocale, isSupportedLocale, translate, type LocaleCode } from '../i18n';
import { secureFetch } from './client-cert';
import { setKnownAccounts, type KnownAccount } from './account-utils';
import { ambiguousPushLabel, pushAccountLabel } from './account-labels';
import { freshStoredCredentials } from './stored-token-refresh';
import {
generateEmailAvatarColor,
@@ -477,7 +478,7 @@ export async function pushBackgroundTask(data: unknown): Promise<void> {
setKnownAccounts(registry.filter((a): a is KnownAccount => !!a.username && !!a.serverUrl));
const jmapAccountIds = await readPushJmapAccountIds();
const match = matchAccountsForPush(payload, accountIds, jmapAccountIds, registry);
if (match.ambiguous) await showPlainNotification(payload);
if (match.ambiguous) await showPlainNotification(payload, match.accountIds, registry);
for (const accountId of match.accountIds) {
try {
@@ -489,7 +490,7 @@ export async function pushBackgroundTask(data: unknown): Promise<void> {
err instanceof Error ? err.message : err,
);
// Something arrived; say so even though the lookup couldn't say what.
if (!match.ambiguous) await showLookupFailedNotification(accountId, payload).catch(() => undefined);
if (!match.ambiguous) await showLookupFailedNotification(accountId, payload, registry).catch(() => undefined);
}
}
} catch (error) {
@@ -590,7 +591,12 @@ async function notifyForPush(accountId: string, session: DetachedSession, payloa
if (!native?.showNotification) return;
const groupKey = `bulwark-mail:${accountId}`;
const groupTitle = payload.accountLabel ?? accountId.split('@')[0] ?? accountId;
// With the server when another login has the same username.
const groupTitle = pushAccountLabel(
accountId,
payload.accountLabel ?? accountId.split('@')[0] ?? accountId,
await readAccountRegistry(),
);
const favicons = await senderFaviconsAllowed();
const locale = await notificationLocale();
@@ -646,11 +652,16 @@ async function notifyForPush(accountId: string, session: DetachedSession, payloa
* tap opens the app as it is; empty ids, which the tap handler refuses, keep
* it from opening anything (NotificationTapStore).
*/
async function showPlainNotification(payload: RelayPushData): Promise<void> {
async function showPlainNotification(
payload: RelayPushData,
candidateIds: readonly string[],
registry: RegistryAccount[],
): Promise<void> {
const native = NativeModules.BulwarkFcm as BulwarkFcmNative | undefined;
if (!native?.showNotification) return;
const locale = await notificationLocale();
const label = payload.accountLabel ?? '';
// The same username on two servers: name the servers it may be from.
const label = ambiguousPushLabel(candidateIds, payload.accountLabel ?? '', registry);
await native.showNotification({
notificationId: `mail-plain:${payload.jmapAccountId ?? ''}:${payload.emailIds[0] ?? ''}`,
title: translate(locale, 'notifications.new_email', 'New email'),
@@ -669,11 +680,15 @@ async function showPlainNotification(payload: RelayPushData): Promise<void> {
* account's name and no message, so a tap opens the app as it is. One per
* account; the next failure replaces it rather than stacking up.
*/
async function showLookupFailedNotification(accountId: string, payload: RelayPushData): Promise<void> {
async function showLookupFailedNotification(
accountId: string,
payload: RelayPushData,
registry: RegistryAccount[],
): Promise<void> {
const native = NativeModules.BulwarkFcm as BulwarkFcmNative | undefined;
if (!native?.showNotification) return;
const locale = await notificationLocale();
const label = payload.accountLabel ?? accountId.split('@')[0] ?? accountId;
const label = pushAccountLabel(accountId, payload.accountLabel ?? accountId.split('@')[0] ?? accountId, registry);
await native.showNotification({
notificationId: `mail-unknown:${accountId}`,
title: translate(locale, 'notifications.new_email', 'New email'),
+12 -9
View File
@@ -37,6 +37,7 @@ import SigningInStep, { type SigningInPhase } from './login/SigningInStep';
import { loginBackAction } from './login/login-back';
import { reauthStart, type LoginStepName } from './login/reauth-start';
import { isSignInCancelled } from '../lib/sign-in-abort';
import { distinctServerUrls } from '../lib/account-labels';
interface LoginScreenProps {
onLogin?: () => void;
@@ -236,10 +237,13 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
setCertBusy(false);
}
}, [certBusy]);
const knownServerUrl = React.useMemo(() => {
const active = accounts.find((a) => a.id === activeAccountId);
return active?.serverUrl ?? accounts[0]?.serverUrl ?? null;
}, [accounts, activeAccountId]);
// Every server an account is on, the active one's first: two servers on
// one host (another port) are both offered.
const addServerUrls = React.useMemo(
() => distinctServerUrls(accounts, activeAccountId),
[accounts, activeAccountId],
);
const knownServerUrl = addServerUrls[0] ?? null;
const goTo = React.useCallback(
(next: StepName) => {
@@ -659,15 +663,14 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo
<ChooseStep
isAddMode={isAddMode}
accounts={accounts}
knownServerUrl={isAddMode ? knownServerUrl : null}
knownServerUrls={isAddMode ? addServerUrls : []}
notice={chooseNotice}
onScan={() => setScannerVisible(true)}
onPaste={() => setPasteVisible(true)}
onUseEmail={() => goTo('email')}
onUseKnownServer={() => {
if (!knownServerUrl) return;
setServerUrl(knownServerUrl);
setServerInput(knownServerUrl);
onUseKnownServer={(url) => {
setServerUrl(url);
setServerInput(url);
setServerDiscovered(false);
goTo('confirm');
}}
+51 -32
View File
@@ -5,6 +5,8 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
import { useColors, useResolvedTheme } from '../../theme/colors';
import type { AccountEntry } from '../../stores/account-store';
import { getAccountInitials } from '../../lib/avatar-utils';
import { accountRowText, serverHostLabel } from '../../lib/account-utils';
import { accountServerLine } from '../../lib/account-labels';
import OptionTile from './OptionTile';
import LoginNotice from './LoginNotice';
import { useLocaleStore } from '../../stores/locale-store';
@@ -17,22 +19,21 @@ const LOGO_DARK = require('../../../assets/logos/Bulwark Logo White.png');
interface ChooseStepProps {
isAddMode: boolean;
accounts: AccountEntry[];
/** Host of the signed-in account, offered as a shortcut in add mode. */
knownServerUrl: string | null;
/**
* The servers of the signed-in accounts, the active one first, each
* offered as a shortcut in add mode.
*/
knownServerUrls: string[];
onScan: () => void;
/** Paste the sign-in link instead of scanning its code. */
onPaste: () => void;
onUseEmail: () => void;
onUseKnownServer: () => void;
onUseKnownServer: (serverUrl: string) => void;
onManualSetup: () => void;
notice?: { title: string; detail?: string } | null;
disabled?: boolean;
}
function hostOf(url: string): string {
return url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').split('/')[0];
}
function initialsOf(account: AccountEntry): string {
return getAccountInitials(account.displayName ?? '', account.email || account.username);
}
@@ -44,7 +45,7 @@ function initialsOf(account: AccountEntry): string {
export default function ChooseStep({
isAddMode,
accounts,
knownServerUrl,
knownServerUrls,
onScan,
onPaste,
onUseEmail,
@@ -57,6 +58,7 @@ export default function ChooseStep({
const theme = useResolvedTheme();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const hasKnownServer = isAddMode && knownServerUrls.length > 0;
// Sits right under the scan tile, wherever that is.
const pasteTile = (
@@ -91,21 +93,35 @@ export default function ChooseStep({
{isAddMode && accounts.length > 0 ? (
<View style={styles.accountList}>
{accounts.map((account) => (
<View key={account.id} style={styles.accountRow}>
<View style={[styles.avatar, { backgroundColor: account.avatarColor }]}>
<Text style={styles.avatarText}>{initialsOf(account)}</Text>
{accounts.map((account) => {
// The address leads when another account has the same name, and
// the server shows when another has the same address or the port
// isn't the default, as in the switcher.
const rowText = accountRowText(account, accounts);
const server = accountServerLine(account, accounts);
return (
<View key={account.id} style={styles.accountRow}>
<View style={[styles.avatar, { backgroundColor: account.avatarColor }]}>
<Text style={styles.avatarText}>{initialsOf(account)}</Text>
</View>
<View style={styles.accountText}>
<Text style={styles.accountName} numberOfLines={1}>
{rowText.title}
</Text>
{rowText.subtitle ? (
<Text style={styles.accountEmail} numberOfLines={1}>
{rowText.subtitle}
</Text>
) : null}
{server ? (
<Text style={styles.accountEmail} numberOfLines={1}>
{server}
</Text>
) : null}
</View>
</View>
<View style={styles.accountText}>
<Text style={styles.accountName} numberOfLines={1}>
{account.displayName || account.username}
</Text>
<Text style={styles.accountEmail} numberOfLines={1}>
{account.email || account.username}
</Text>
</View>
</View>
))}
);
})}
</View>
) : null}
@@ -116,15 +132,18 @@ export default function ChooseStep({
) : null}
<View style={styles.options}>
{isAddMode && knownServerUrl ? (
<OptionTile
emphasis="primary"
title={t('login.mobile.known_server_title', 'Another account on {host}', { host: hostOf(knownServerUrl) })}
description={t('login.mobile.known_server_desc', "We already know this server, so there's no setup")}
renderIcon={(color, size) => <Plus size={size} color={color} />}
onPress={onUseKnownServer}
disabled={disabled}
/>
{hasKnownServer ? (
knownServerUrls.map((url, i) => (
<OptionTile
key={url}
emphasis={i === 0 ? 'primary' : undefined}
title={t('login.mobile.known_server_title', 'Another account on {host}', { host: serverHostLabel(url) })}
description={t('login.mobile.known_server_desc', "We already know this server, so there's no setup")}
renderIcon={(color, size) => <Plus size={size} color={color} />}
onPress={() => onUseKnownServer(url)}
disabled={disabled}
/>
))
) : (
<>
<OptionTile
@@ -147,7 +166,7 @@ export default function ChooseStep({
disabled={disabled}
/>
{isAddMode && knownServerUrl ? (
{hasKnownServer ? (
<>
<OptionTile
title={t('login.mobile.scan_title', 'Scan a sign-in code')}