Tweak email domain mode switching logic (#1449)

This commit is contained in:
Leendert de Borst
2026-01-26 23:52:00 +01:00
parent 098fb30759
commit 314fc0da56
8 changed files with 118 additions and 114 deletions
@@ -73,6 +73,12 @@ const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
const [hiddenPrivateEmailDomains, setHiddenPrivateEmailDomains] = useState<string[]>([]);
const popupRef = useRef<HTMLDivElement>(null);
/**
* Tracks whether the user explicitly toggled mode via buttons.
* While true, the value useEffect skips auto-detection of isCustomDomain.
*/
const modeToggledByUser = useRef(false);
// Get email domains from vault metadata
useEffect(() => {
/**
@@ -115,21 +121,19 @@ const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
setSelectedDomain(domain);
/*
* Auto-detect mode based on domain recognition.
* In controlled mode, notify parent via onEmailModeChange.
* In uncontrolled mode, update internal state directly.
* Auto-detect mode based on domain recognition, but only if the user
* hasn't explicitly toggled mode via the Email/Alias buttons.
*/
// Check if it's a known domain (public, private, or hidden private)
const isKnownDomain = publicEmailDomains.includes(domain) ||
privateEmailDomains.includes(domain) ||
hiddenPrivateEmailDomains.includes(domain);
if (!modeToggledByUser.current) {
const isKnownDomain = publicEmailDomains.includes(domain) ||
privateEmailDomains.includes(domain) ||
hiddenPrivateEmailDomains.includes(domain);
if (isControlled && onEmailModeChange) {
// Controlled mode: notify parent that mode should be alias (domain chooser) if domain is known
onEmailModeChange(!isKnownDomain);
} else if (!isControlled) {
// Uncontrolled mode: update internal state directly
setIsCustomDomain(!isKnownDomain);
if (isControlled && onEmailModeChange) {
onEmailModeChange(!isKnownDomain);
} else if (!isControlled) {
setIsCustomDomain(!isKnownDomain);
}
}
} else {
setLocalPart(value);
@@ -149,9 +153,13 @@ const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
/*
* Re-check domain mode when domains finish loading.
* This handles the case where value was set before domains were loaded.
* Works in both controlled and uncontrolled modes.
* Skip if the user has explicitly toggled mode via buttons.
*/
useEffect(() => {
if (modeToggledByUser.current) {
return;
}
if (!value || !value.includes('@')) {
return;
}
@@ -161,19 +169,13 @@ const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
return;
}
// Check if the domain is now recognized after domains loaded
const isKnownDomain = publicEmailDomains.includes(domain) ||
privateEmailDomains.includes(domain) ||
hiddenPrivateEmailDomains.includes(domain);
if (isControlled && onEmailModeChange) {
// Controlled mode: notify parent that mode should be alias (domain chooser) if domain is known
onEmailModeChange(!isKnownDomain);
} else if (!isControlled) {
/*
* Uncontrolled mode: update internal state directly.
* If domain is recognized and we're in custom mode, switch to domain chooser.
*/
if (isKnownDomain && isCustomDomain) {
setIsCustomDomain(false);
}
@@ -219,6 +221,7 @@ const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
// Handle local part changes
const handleLocalPartChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newLocalPart = e.target.value;
modeToggledByUser.current = false;
// If in custom domain mode, always pass through the full value
if (isCustomDomain) {
@@ -277,6 +280,7 @@ const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
// Toggle between custom domain and domain chooser
const toggleCustomDomain = useCallback(() => {
modeToggledByUser.current = true;
const newIsCustom = !isCustomDomain;
setIsCustomDomain(newIsCustom);
@@ -288,31 +292,15 @@ const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
onChange('');
setLocalPart('');
} else {
// Switching to domain chooser mode
// Switching to domain chooser mode - clear the old email-mode value.
const defaultDomain = showPrivateDomains && privateEmailDomains[0]
? privateEmailDomains[0]
: publicEmailDomains[0];
setSelectedDomain(defaultDomain);
/*
* Use the same simple pattern as mobile app:
* 1. Check localPart first (most reliable, kept in sync by useEffect)
* 2. Fallback to value if it doesn't have @ (value is just a prefix)
*
* Note: If value has @, the useEffect will have already extracted and set localPart,
* so checking localPart first is the right approach.
*/
if (localPart && localPart.trim()) {
// localPart is available - use it directly
onChange(`${localPart}@${defaultDomain}`);
} else if (value && !value.includes('@')) {
// Fallback: value is just a prefix without @
onChange(`${value}@${defaultDomain}`);
// Also update localPart to keep in sync
setLocalPart(value);
}
setLocalPart('');
onChange('');
}
}, [isCustomDomain, value, localPart, showPrivateDomains, publicEmailDomains, privateEmailDomains, onChange, setIsCustomDomain]);
}, [isCustomDomain, showPrivateDomains, publicEmailDomains, privateEmailDomains, onChange, setIsCustomDomain]);
// Handle clicks outside the popup
useEffect(() => {
@@ -339,34 +327,25 @@ const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
* Otherwise, just switches to domain chooser mode and preserves the current local part.
*/
const handleGenerateAliasClick = useCallback(() => {
// Always switch to domain chooser mode
modeToggledByUser.current = true;
setIsCustomDomain(false);
if (onGenerateAlias) {
// Delegate to the parent callback which sets the full email value (prefix@domain)
onGenerateAlias();
return;
}
/*
* No generate callback - just switching modes.
* Ensure the value includes the domain when switching from email to alias mode.
*/
// Reset to the default domain so stale domains from email mode are cleared.
const defaultDomain = showPrivateDomains && privateEmailDomains[0]
? privateEmailDomains[0]
: publicEmailDomains[0];
if (defaultDomain) {
setSelectedDomain(defaultDomain);
if (localPart && localPart.trim()) {
onChange(`${localPart}@${defaultDomain}`);
} else if (value && !value.includes('@')) {
onChange(`${value}@${defaultDomain}`);
setLocalPart(value);
}
}
}, [onGenerateAlias, setIsCustomDomain, showPrivateDomains, privateEmailDomains, publicEmailDomains, value, localPart, onChange]);
// Clear the old email-mode value so it doesn't interfere with alias mode.
setLocalPart('');
onChange('');
if (onGenerateAlias) {
onGenerateAlias();
}
}, [onGenerateAlias, setIsCustomDomain, showPrivateDomains, privateEmailDomains, publicEmailDomains, onChange]);
return (
<div className="space-y-2">
@@ -518,18 +518,25 @@ const ItemAddEdit: React.FC = () => {
const firstName = (fieldValues['alias.first_name'] as string) || '';
const lastName = (fieldValues['alias.last_name'] as string) || '';
const gender = (fieldValues['alias.gender'] as string) || Gender.Other;
const birthdate = (fieldValues['alias.birthdate'] as string) || '';
const generator = new UsernameEmailGenerator();
const prefix = generator.generateEmailPrefix({
firstName,
lastName,
gender: gender as Gender,
birthDate: birthdate ? new Date(birthdate) : new Date(),
emailPrefix: '',
nickName: ''
});
let prefix: string;
if (!firstName.trim() && !lastName.trim()) {
// No alias identity fields filled in, fall back to random prefix.
prefix = generateRandomEmailPrefix();
} else {
const gender = (fieldValues['alias.gender'] as string) || Gender.Other;
const birthdate = (fieldValues['alias.birthdate'] as string) || '';
const generator = new UsernameEmailGenerator();
prefix = generator.generateEmailPrefix({
firstName,
lastName,
gender: gender as Gender,
birthDate: birthdate ? new Date(birthdate) : new Date(),
emailPrefix: '',
nickName: ''
});
}
const defaultEmailDomain = dbContext.sqliteClient.settings.getDefaultEmailDomain();
const email = defaultEmailDomain ? `${prefix}@${defaultEmailDomain}` : prefix;
@@ -538,7 +545,7 @@ const ItemAddEdit: React.FC = () => {
...prev,
'login.email': email
}));
}, [dbContext?.sqliteClient, fieldValues]);
}, [dbContext?.sqliteClient, fieldValues, generateRandomEmailPrefix]);
/**
* Generate a random-string email alias (for Login type email field).
+18 -10
View File
@@ -395,18 +395,26 @@ export default function AddEditItemScreen(): React.ReactNode {
const handleGenerateAliasEmail = useCallback(async () => {
const firstName = (fieldValues['alias.first_name'] as string) || '';
const lastName = (fieldValues['alias.last_name'] as string) || '';
const gender = (fieldValues['alias.gender'] as string) || Gender.Other;
const birthdate = (fieldValues['alias.birthdate'] as string) || '';
const generator = new UsernameEmailGenerator();
const prefix = generator.generateEmailPrefix({
firstName,
lastName,
gender: gender as Gender,
birthDate: birthdate ? new Date(birthdate) : new Date(),
emailPrefix: '',
nickName: ''
});
let prefix: string;
if (!firstName.trim() && !lastName.trim()) {
// No alias identity fields filled in, fall back to random prefix.
prefix = generator.generateRandomEmailPrefix();
} else {
const gender = (fieldValues['alias.gender'] as string) || Gender.Other;
const birthdate = (fieldValues['alias.birthdate'] as string) || '';
prefix = generator.generateEmailPrefix({
firstName,
lastName,
gender: gender as Gender,
birthDate: birthdate ? new Date(birthdate) : new Date(),
emailPrefix: '',
nickName: ''
});
}
const defaultEmailDomain = await dbContext.sqliteClient!.getDefaultEmailDomain();
const email = defaultEmailDomain ? `${prefix}@${defaultEmailDomain}` : prefix;
@@ -272,29 +272,20 @@ export const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
onChange('');
setLocalPart('');
} else {
// Switching to domain chooser mode
setIsCustomDomain(false);
if (onGenerateAlias) {
// Delegate to the parent callback which sets the full email value (prefix@domain)
onGenerateAlias();
return;
}
// No generate callback - just switch modes and preserve current local part
// Switching to domain chooser mode - clear old email-mode value.
const defaultDomain = showPrivateDomains && privateEmailDomains[0]
? privateEmailDomains[0]
: PUBLIC_EMAIL_DOMAINS[0];
setSelectedDomain(defaultDomain);
setLocalPart('');
onChange('');
if (localPart && localPart.trim()) {
onChange(`${localPart}@${defaultDomain}`);
} else if (value && !value.includes('@')) {
onChange(`${value}@${defaultDomain}`);
setLocalPart(value);
if (onGenerateAlias) {
// Delegate to the parent callback which sets the full email value (prefix@domain)
onGenerateAlias();
}
}
}, [isCustomDomain, value, localPart, showPrivateDomains, privateEmailDomains, onChange, onGenerateAlias]);
}, [isCustomDomain, showPrivateDomains, privateEmailDomains, onChange, onGenerateAlias]);
const styles = StyleSheet.create({
domainAt: {
@@ -524,8 +515,6 @@ export const EmailDomainField: React.FC<EmailDomainFieldProps> = ({
style={styles.textInput}
value={isCustomDomain ? value : localPart}
onChangeText={handleLocalPartChange}
placeholder={isCustomDomain ? t('items.enterFullEmail') : t('items.enterEmailPrefix')}
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
@@ -177,8 +177,6 @@ export const ItemNameField = forwardRef<ItemNameFieldRef, IItemNameFieldProps>((
style={styles.input}
value={value}
onChangeText={onChangeText}
placeholder={t('items.itemName')}
placeholderTextColor={colors.textMuted}
testID="item-name-input"
/>
{hasFolders && (
-1
View File
@@ -405,7 +405,6 @@
"manual": "Manual",
"generateRandomAlias": "Generate Random Alias",
"clearAliasFields": "Clear Alias Fields",
"enterFullEmail": "Enter full email address",
"enterEmailPrefix": "Enter email prefix",
"useDomainChooser": "Use domain chooser",
"enterCustomDomain": "Enter custom domain",
@@ -304,6 +304,15 @@
return;
}
ResetToDefaultDomain();
}
/// <summary>
/// Forces SelectedDomain to the first available domain.
/// Used when switching to alias mode to clear any stale domain from email mode.
/// </summary>
private void ResetToDefaultDomain()
{
if (ShowPrivateDomains && PrivateDomains.Count > 0)
{
SelectedDomain = PrivateDomains[0];
@@ -430,7 +439,7 @@
ModeToggledByUser = true;
IsCustomDomain = false;
EnsureDefaultDomain();
ResetToDefaultDomain();
// Clear old email-mode value so it doesn't interfere with alias mode.
LocalPart = string.Empty;
@@ -445,10 +454,12 @@
/// <summary>
/// Handles the regenerate button click.
/// Delegates to the parent callback to generate a new email value.
/// Locks alias mode so auto-detection doesn't override it, then delegates
/// to the parent callback to generate a new email value.
/// </summary>
private async Task HandleRegenerate()
{
ModeToggledByUser = true;
if (OnGenerateAlias.HasDelegate)
{
await OnGenerateAlias.InvokeAsync();
@@ -1108,16 +1108,29 @@ else
/// </summary>
private async Task HandleGenerateAliasEmail()
{
var identity = new AliasVaultIdentity
{
FirstName = Obj.GetFieldValue(FieldKey.AliasFirstName),
LastName = Obj.GetFieldValue(FieldKey.AliasLastName),
BirthDate = Obj.GetFieldValue(FieldKey.AliasBirthdate),
Gender = Obj.GetFieldValue(FieldKey.AliasGender),
NickName = Obj.GetFieldValue(FieldKey.LoginUsername),
};
var firstName = Obj.GetFieldValue(FieldKey.AliasFirstName);
var lastName = Obj.GetFieldValue(FieldKey.AliasLastName);
string prefix;
if (string.IsNullOrWhiteSpace(firstName) && string.IsNullOrWhiteSpace(lastName))
{
// No alias identity fields filled in, fall back to random prefix.
prefix = await JsInteropService.GenerateRandomStringEmailPrefixAsync();
}
else
{
var identity = new AliasVaultIdentity
{
FirstName = firstName,
LastName = lastName,
BirthDate = Obj.GetFieldValue(FieldKey.AliasBirthdate),
Gender = Obj.GetFieldValue(FieldKey.AliasGender),
NickName = Obj.GetFieldValue(FieldKey.LoginUsername),
};
prefix = await JsInteropService.GenerateRandomEmailPrefixAsync(identity);
}
var prefix = await JsInteropService.GenerateRandomEmailPrefixAsync(identity);
var defaultEmailDomain = DbService.Settings.DefaultEmailDomain;
var email = !string.IsNullOrEmpty(defaultEmailDomain) ? $"{prefix}@{defaultEmailDomain}" : prefix;