feat(native/blocker): type picker + mail patterns in AddDomainSheet

AddDomainSheet now opens with a Seite / E-Mail segmented control.
Web keeps the existing flow (label, placeholder, favicon preview,
domain normalization). Mail switches to a free-form pattern input
(address / domain / display-name — user types what they see in
their inbox) with a mail-icon preview after the field is filled.
addDomain(pattern, kind) now sends { pattern, kind: 'web' | 'mail' }
and the server decides the concrete type. Type field flows through
the CustomDomain type so DomainGrid tiles render the mail-outline
icon for mail entries instead of the favicon fallback.

i18n: blocker.type_web / type_mail / add_web_* / add_mail_* across
de/en/fr with %{var} placeholders per repo convention.
This commit is contained in:
chahinebrini 2026-05-16 01:54:32 +02:00
parent 7dbcac6700
commit 4eab5df7e2
7 changed files with 274 additions and 41 deletions

View File

@ -387,8 +387,8 @@ export default function BlockerScreen() {
setAddSheetOpen(false);
refreshDomains();
}}
onAdd={async (d) => {
const result = await addDomain(d);
onAdd={async (pattern, kind) => {
const result = await addDomain(pattern, kind);
if (result.ok) {
// Neue Custom-Domain → Filter muss aktualisierten Hash-Set kriegen
const sync = await syncBlocklist();

View File

@ -13,27 +13,30 @@ import {
normalizeDomain,
type Tier,
} from '../../hooks/useCustomDomains';
import { useColors } from '../../lib/theme';
import { useColors, type ColorScheme } from '../../lib/theme';
import { FormSheet } from '../FormSheet';
import { SheetFieldStack } from '../SheetFieldStack';
type InputKind = 'web' | 'mail';
type Props = {
visible: boolean;
tier: Tier;
onClose: () => void;
onAdd: (domain: string) => Promise<{ ok: boolean; error?: string; alreadyGlobal?: boolean }>;
onAdd: (pattern: string, kind: InputKind) => Promise<{ ok: boolean; error?: string; alreadyGlobal?: boolean }>;
};
export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
const { t } = useTranslation();
const colors = useColors();
const [kind, setKind] = useState<InputKind>('web');
const [input, setInput] = useState('');
const [confirmPermanent, setConfirmPermanent] = useState(false);
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fieldsDone, setFieldsDone] = useState(false);
const normalized = normalizeDomain(input);
const normalizedWeb = kind === 'web' ? normalizeDomain(input) : '';
function close() {
setInput('');
@ -43,18 +46,32 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
onClose();
}
function handleKindChange(next: InputKind) {
if (next === kind) return;
setKind(next);
setInput('');
setError(null);
setFieldsDone(false);
}
function isInputValid(): boolean {
if (kind === 'web') return isValidDomain(input);
return input.trim().length > 0;
}
async function handleAdd() {
if (!isValidDomain(input) || !confirmPermanent || adding) return;
if (!isInputValid() || !confirmPermanent || adding) return;
setAdding(true);
setError(null);
const result = await onAdd(input);
const pattern = kind === 'web' ? input : input.trim();
const result = await onAdd(pattern, kind);
setAdding(false);
if (result.ok) {
close();
return;
}
if (result.alreadyGlobal) {
setError(t('blocker.add_sheet_already_global', { domain: normalized }));
setError(t('blocker.add_sheet_already_global', { domain: normalizedWeb || input.trim() }));
} else {
setError(result.error ?? t('blocker.add_sheet_add_failed'));
}
@ -65,6 +82,22 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
? t('blocker.add_sheet_warning_free')
: t('blocker.add_sheet_warning_pro');
const inputLabel = kind === 'web'
? t('blocker.add_web_label')
: t('blocker.add_mail_label');
const inputPlaceholder = kind === 'web'
? t('blocker.add_web_placeholder')
: t('blocker.add_mail_placeholder');
const helpText = kind === 'web'
? t('blocker.add_web_help')
: t('blocker.add_mail_help');
const validateField = kind === 'web'
? (v: string) => isValidDomain(v) ? undefined : t('blocker.add_sheet_invalid')
: (v: string) => v.trim().length > 0 ? undefined : t('blocker.add_mail_invalid');
return (
<FormSheet
visible={visible}
@ -74,24 +107,57 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
growWithKeyboard
>
<SheetFieldStack
intro={
<TypePicker kind={kind} onChange={handleKindChange} />
}
fields={[
{
key: 'domain',
label: t('blocker.add_sheet_label'),
placeholder: t('blocker.add_sheet_placeholder'),
key: 'pattern',
label: inputLabel,
placeholder: inputPlaceholder,
value: input,
onChangeText: (v) => { setInput(v); setError(null); },
normalize: normalizeDomain,
keyboardType: 'url',
normalize: kind === 'web' ? normalizeDomain : undefined,
keyboardType: kind === 'web' ? 'url' : 'default',
autoCapitalize: 'none',
autoCorrect: false,
validate: (v) =>
isValidDomain(v) ? undefined : t('blocker.add_sheet_invalid'),
validate: validateField,
},
]}
onComplete={() => setFieldsDone(true)}
>
{/* Favicon-Preview */}
{/* Help-Text */}
<View
style={{
flexDirection: 'row',
gap: 8,
padding: 12,
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
marginBottom: 8,
}}
>
<Ionicons
name={kind === 'web' ? 'globe-outline' : 'mail-outline'}
size={16}
color={colors.textMuted}
style={{ marginTop: 1 }}
/>
<Text
style={{
flex: 1,
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
lineHeight: 17,
}}
>
{helpText}
</Text>
</View>
{/* Favicon-Preview (nur Web) */}
{kind === 'web' && (
<View
style={{
flexDirection: 'row',
@ -105,7 +171,7 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
>
<Image
source={{
uri: `https://www.google.com/s2/favicons?domain=${normalized}&sz=64`,
uri: `https://www.google.com/s2/favicons?domain=${normalizedWeb}&sz=64`,
}}
style={{ width: 24, height: 24, borderRadius: 4 }}
/>
@ -118,9 +184,49 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
}}
numberOfLines={1}
>
{normalized}
{normalizedWeb}
</Text>
</View>
)}
{/* Mail-Typ Icon-Preview */}
{kind === 'mail' && (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
padding: 12,
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
marginBottom: 12,
}}
>
<View
style={{
width: 24,
height: 24,
borderRadius: 4,
backgroundColor: '#dbeafe',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="mail-outline" size={14} color="#2563eb" />
</View>
<Text
style={{
flex: 1,
fontSize: 14,
fontFamily: 'Nunito_600SemiBold',
color: colors.text,
}}
numberOfLines={1}
>
{input.trim() || inputPlaceholder}
</Text>
</View>
)}
{/* Warnung */}
<View
@ -230,3 +336,88 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
</FormSheet>
);
}
// ─── TypePicker ──────────────────────────────────────────────────────────────
function TypePicker({ kind, onChange }: { kind: InputKind; onChange: (k: InputKind) => void }) {
const { t } = useTranslation();
const colors = useColors();
return (
<View
style={{
flexDirection: 'row',
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
padding: 3,
gap: 3,
}}
>
<TypePill
icon="globe-outline"
label={t('blocker.type_web')}
active={kind === 'web'}
onPress={() => onChange('web')}
colors={colors}
/>
<TypePill
icon="mail-outline"
label={t('blocker.type_mail')}
active={kind === 'mail'}
onPress={() => onChange('mail')}
colors={colors}
/>
</View>
);
}
function TypePill({
icon,
label,
active,
onPress,
colors,
}: {
icon: 'globe-outline' | 'mail-outline';
label: string;
active: boolean;
onPress: () => void;
colors: ColorScheme;
}) {
return (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.8}
style={{
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
paddingVertical: 9,
borderRadius: 10,
backgroundColor: active ? colors.bg : 'transparent',
shadowColor: active ? '#000' : 'transparent',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: active ? 0.08 : 0,
shadowRadius: 2,
elevation: active ? 1 : 0,
}}
>
<Ionicons
name={icon}
size={15}
color={active ? colors.text : colors.textMuted}
/>
<Text
style={{
fontSize: 13,
fontFamily: active ? 'Nunito_700Bold' : 'Nunito_400Regular',
color: active ? colors.text : colors.textMuted,
}}
>
{label}
</Text>
</TouchableOpacity>
);
}

View File

@ -391,9 +391,22 @@ function DomainTile({
</View>
</View>
{/* Mitte: Favicon + Domain-Name (zentriert, flex-1) */}
{/* Mitte: Icon + Domain-Name (zentriert, flex-1) */}
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', gap: 4, paddingVertical: 8 }}>
{!imgError ? (
{domain.type === 'mail_domain' || domain.type === 'mail_display_name' ? (
<View
style={{
width: 26,
height: 26,
borderRadius: 5,
backgroundColor: '#dbeafe',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="mail-outline" size={14} color="#2563eb" />
</View>
) : !imgError ? (
<Image
source={{ uri: `https://www.google.com/s2/favicons?domain=${stripped}&sz=128` }}
style={{ width: 26, height: 26, borderRadius: 5 }}

View File

@ -3,9 +3,12 @@ import { apiFetch } from '../lib/api';
export type DomainStatus = 'active' | 'submitted' | 'approved' | 'rejected';
export type EntryKind = 'web' | 'mail_domain' | 'mail_display_name';
export type CustomDomain = {
id: string;
domain: string;
type?: EntryKind;
status: DomainStatus;
addedAt?: string;
postId?: string | null;
@ -45,7 +48,7 @@ export type UseCustomDomainsReturn = {
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
addDomain: (domain: string) => Promise<{ ok: boolean; error?: string; alreadyGlobal?: boolean }>;
addDomain: (domain: string, kind?: 'web' | 'mail') => Promise<{ ok: boolean; error?: string; alreadyGlobal?: boolean }>;
submitDomain: (id: string) => Promise<{ ok: boolean; error?: string }>;
removeDomain: (id: string) => Promise<{ ok: boolean; error?: string }>;
/** Live-Validate (regex) ob string gültiger Domain-Name ist. */
@ -109,17 +112,16 @@ export function useCustomDomains(plan: Plan): UseCustomDomainsReturn {
}, [fetchDomains]);
const addDomain = useCallback(
async (input: string) => {
if (!isValidDomain(input)) return { ok: false, error: 'invalid_domain' };
async (input: string, kind: 'web' | 'mail' = 'web') => {
if (kind === 'web' && !isValidDomain(input)) return { ok: false, error: 'invalid_domain' };
if (kind === 'mail' && !input.trim()) return { ok: false, error: 'invalid_pattern' };
const tier = deriveTier(plan, domains);
if (tier.atLimit) return { ok: false, error: 'limit_reached' };
const normalized = normalizeDomain(input);
const pattern = kind === 'web' ? normalizeDomain(input) : input.trim();
try {
// Backend könnte einen `alreadyGlobal`-Flag setzen wenn die Domain
// bereits in der globalen Blocklist ist (Slot wird nicht verbraucht).
const res = await apiFetch<any>('/api/custom-domains', {
method: 'POST',
body: { domain: normalized },
body: { pattern, kind },
});
if (res?.alreadyGlobal) {
return { ok: false, alreadyGlobal: true };

View File

@ -316,7 +316,16 @@
"cooldown_elapsed_message": "Der Cooldown ist abgelaufen — der Schutz wurde deaktiviert. Du kannst den ReBreak-Bedienungshilfe-Dienst jetzt in den Einstellungen ausschalten.",
"cooldown_elapsed_open_settings": "Einstellungen öffnen",
"app_lock_coming_soon_badge": "Bald",
"app_lock_coming_soon_desc": "App-Sperre wird bald verfügbar — Schutz ist bereits aktiv."
"app_lock_coming_soon_desc": "App-Sperre wird bald verfügbar — Schutz ist bereits aktiv.",
"type_web": "Seite",
"type_mail": "E-Mail",
"add_web_label": "Domain",
"add_web_placeholder": "z.B. casino.com",
"add_web_help": "Diese Webseite wird auf allen geschützten Geräten blockiert.",
"add_mail_label": "E-Mail-Absender oder Display-Name",
"add_mail_placeholder": "z.B. only4-subscribers.com oder EXTRASPIN",
"add_mail_help": "Mail-Adresse, Domain oder Display-Name. Wir blockieren alle Mails die diesem Muster entsprechen.",
"add_mail_invalid": "Bitte ein Muster eingeben."
},
"mail": {
"title": "Mail-Schutz",

View File

@ -316,7 +316,16 @@
"cooldown_elapsed_message": "The cooldown has elapsed — protection was disabled. You can now turn off the ReBreak accessibility service in Settings.",
"cooldown_elapsed_open_settings": "Open Settings",
"app_lock_coming_soon_badge": "Soon",
"app_lock_coming_soon_desc": "App lock coming soon — filter protection is already active."
"app_lock_coming_soon_desc": "App lock coming soon — filter protection is already active.",
"type_web": "Website",
"type_mail": "Email",
"add_web_label": "Domain",
"add_web_placeholder": "e.g. casino.com",
"add_web_help": "This website will be blocked on all your protected devices.",
"add_mail_label": "Email sender or display name",
"add_mail_placeholder": "e.g. only4-subscribers.com or EXTRASPIN",
"add_mail_help": "Email address, domain or display name. We block all emails matching this pattern.",
"add_mail_invalid": "Please enter a pattern."
},
"mail": {
"title": "Mail Shield",

View File

@ -316,7 +316,16 @@
"cooldown_elapsed_message": "La pause de sécurité est terminée — la protection a été désactivée. Vous pouvez maintenant désactiver le service d'accessibilité ReBreak dans les Réglages.",
"cooldown_elapsed_open_settings": "Ouvrir les Réglages",
"app_lock_coming_soon_badge": "Bientôt",
"app_lock_coming_soon_desc": "Verrouillage de l'app bientôt disponible — la protection par filtre est déjà active."
"app_lock_coming_soon_desc": "Verrouillage de l'app bientôt disponible — la protection par filtre est déjà active.",
"type_web": "Site web",
"type_mail": "E-mail",
"add_web_label": "Domaine",
"add_web_placeholder": "ex. casino.com",
"add_web_help": "Ce site sera bloqué sur tous vos appareils protégés.",
"add_mail_label": "Expéditeur ou nom affiché",
"add_mail_placeholder": "ex. only4-subscribers.com ou EXTRASPIN",
"add_mail_help": "Adresse e-mail, domaine ou nom affiché. Nous bloquons tous les mails correspondant à ce modèle.",
"add_mail_invalid": "Veuillez saisir un modèle."
},
"mail": {
"title": "Protection Mail",