diff --git a/src/components/search/GlobalSearchSheet.tsx b/src/components/search/GlobalSearchSheet.tsx index 9dee0145..b8c0849e 100644 --- a/src/components/search/GlobalSearchSheet.tsx +++ b/src/components/search/GlobalSearchSheet.tsx @@ -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 ( {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} @@ -270,7 +274,7 @@ export function GlobalSearchSheet({ visible, initialQuery = '', onClose, navigat {errors.map((error) => ( - {t('global_search.account_error', "Couldn't search {account}", { account: error.accountLabel })} + {t('global_search.account_error', "Couldn't search {account}", { account: accountLabelOf(error.localAccountId, error.accountLabel) })} ))} {!trimmed && !showRecent ? ( diff --git a/src/lib/__tests__/account-labels.test.ts b/src/lib/__tests__/account-labels.test.ts new file mode 100644 index 00000000..660dce6c --- /dev/null +++ b/src/lib/__tests__/account-labels.test.ts @@ -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 : \[\]\}/); + }); +}); diff --git a/src/lib/__tests__/push-account-label.test.ts b/src/lib/__tests__/push-account-label.test.ts new file mode 100644 index 00000000..9af32673 --- /dev/null +++ b/src/lib/__tests__/push-account-label.test.ts @@ -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).BulwarkFcm = { showNotification }; + (jmapClient.getStoredCredentials as ReturnType).mockResolvedValue(null); + }); + + afterEach(() => { + delete (NativeModules as Record).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).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)', + }); + }); +}); diff --git a/src/lib/account-labels.ts b/src/lib/account-labels.ts new file mode 100644 index 00000000..2b7cad9d --- /dev/null +++ b/src/lib/account-labels.ts @@ -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 { + const out = new Map(); + 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 { + 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(); + 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(', '); +} diff --git a/src/lib/push-background-task.ts b/src/lib/push-background-task.ts index eeb5e238..283247ae 100644 --- a/src/lib/push-background-task.ts +++ b/src/lib/push-background-task.ts @@ -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 { 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 { 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 { +async function showPlainNotification( + payload: RelayPushData, + candidateIds: readonly string[], + registry: RegistryAccount[], +): Promise { 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 { * 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 { +async function showLookupFailedNotification( + accountId: string, + payload: RelayPushData, + registry: RegistryAccount[], +): Promise { 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'), diff --git a/src/screens/LoginScreen.tsx b/src/screens/LoginScreen.tsx index 0ef171e7..c6a094c9 100644 --- a/src/screens/LoginScreen.tsx +++ b/src/screens/LoginScreen.tsx @@ -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 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'); }} diff --git a/src/screens/login/ChooseStep.tsx b/src/screens/login/ChooseStep.tsx index 46d5905d..07d352df 100644 --- a/src/screens/login/ChooseStep.tsx +++ b/src/screens/login/ChooseStep.tsx @@ -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 ? ( - {accounts.map((account) => ( - - - {initialsOf(account)} + {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 ( + + + {initialsOf(account)} + + + + {rowText.title} + + {rowText.subtitle ? ( + + {rowText.subtitle} + + ) : null} + {server ? ( + + {server} + + ) : null} + - - - {account.displayName || account.username} - - - {account.email || account.username} - - - - ))} + ); + })} ) : null} @@ -116,15 +132,18 @@ export default function ChooseStep({ ) : null} - {isAddMode && knownServerUrl ? ( - } - onPress={onUseKnownServer} - disabled={disabled} - /> + {hasKnownServer ? ( + knownServerUrls.map((url, i) => ( + } + onPress={() => onUseKnownServer(url)} + disabled={disabled} + /> + )) ) : ( <> - {isAddMode && knownServerUrl ? ( + {hasKnownServer ? ( <>