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:
parent
7dbcac6700
commit
4eab5df7e2
@ -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();
|
||||
|
||||
@ -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,54 +107,127 @@ 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',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
gap: 8,
|
||||
padding: 12,
|
||||
backgroundColor: colors.surfaceElevated,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{
|
||||
uri: `https://www.google.com/s2/favicons?domain=${normalized}&sz=64`,
|
||||
}}
|
||||
style={{ width: 24, height: 24, borderRadius: 4 }}
|
||||
<Ionicons
|
||||
name={kind === 'web' ? 'globe-outline' : 'mail-outline'}
|
||||
size={16}
|
||||
color={colors.textMuted}
|
||||
style={{ marginTop: 1 }}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Nunito_600SemiBold',
|
||||
color: colors.text,
|
||||
fontSize: 12,
|
||||
fontFamily: 'Nunito_400Regular',
|
||||
color: colors.textMuted,
|
||||
lineHeight: 17,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{normalized}
|
||||
{helpText}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Favicon-Preview (nur Web) */}
|
||||
{kind === 'web' && (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: 12,
|
||||
backgroundColor: colors.surfaceElevated,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{
|
||||
uri: `https://www.google.com/s2/favicons?domain=${normalizedWeb}&sz=64`,
|
||||
}}
|
||||
style={{ width: 24, height: 24, borderRadius: 4 }}
|
||||
/>
|
||||
<Text
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Nunito_600SemiBold',
|
||||
color: colors.text,
|
||||
}}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{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
|
||||
style={{
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 }}
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user