mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
fix: show the server where two accounts with one address would read the same
This commit is contained in:
@@ -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 ? (
|
||||
|
||||
@@ -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)',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(', ');
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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');
|
||||
}}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user