Two bugs reported on the new mail-pattern flow: 1. The sheet sent the full local@domain.tld pattern to the backend so a user blocking communications@only4-subscribers.com would only catch that exact local-part — newsletter@, info@, promo@ from the same sender would slip through. Casino affiliates rotate the local-part on every blast while keeping the domain stable, so we now strip the local-part on submit. The preview-card under the input shows what actually gets stored (only4-subscribers.com), so the user sees the pattern that will hit. Bare tokens without "@" stay as-is and reach the backend as display-name candidates. 2. FormSheet's backdrop was a <Pressable> — straight violation of the "TouchableOpacity, never Pressable" rule. Swapped for <TouchableOpacity activeOpacity={1}> so the tap-to-dismiss still works with no visible feedback on the dim layer.
248 lines
9.2 KiB
TypeScript
248 lines
9.2 KiB
TypeScript
import { ReactNode, useEffect, useRef, useState } from 'react';
|
|
import {
|
|
Animated,
|
|
Keyboard,
|
|
Modal,
|
|
PanResponder,
|
|
Platform,
|
|
Text,
|
|
TouchableOpacity,
|
|
View,
|
|
useWindowDimensions,
|
|
} from 'react-native';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { useColors } from '../lib/theme';
|
|
|
|
/**
|
|
* App-weites Bottom-Sheet — DAS eine Pattern für alle Custom-Modals.
|
|
*
|
|
* Verallgemeinert das verifizierte `PostCommentsSheet`-Pattern:
|
|
* - `<Modal transparent>` mit hellem (oder ganz ohne) Backdrop — verdunkelt den
|
|
* Main-Screen nie stark.
|
|
* - **Standard-Header**: Grabber-Bar mittig + Titel **links**. KEINE
|
|
* „Fertig"/„Abbrechen"/„Zurück"-Buttons — Schließen = runterswipen / Backdrop-Tap.
|
|
* - **Resizable**: Drag am Handle/Header zieht das Sheet größer/kleiner;
|
|
* Drag nach unten unter `minHeightPct` (oder schneller Flick) → dismiss.
|
|
* - **Höhe ≤ 75 % Screen**, IMMER (Drag + Keyboard-Expand sind hart gedeckelt).
|
|
* - **Keyboard-aware**: Tastatur auf → Sheet wächst um Tastatur-Höhe (gedeckelt),
|
|
* `paddingBottom: keyboardHeight` (iOS) schiebt den Inhalt exakt über die
|
|
* Tastatur. Android: `windowSoftInputMode=adjustResize` im Manifest macht das.
|
|
*
|
|
* Driver-Trennung (sonst „Style property 'height' is not supported by native
|
|
* animated module"-Crash): äußere View animiert `height` im JS-Driver, innere
|
|
* View animiert `transform: translateY` (Slide/Dismiss) im Native-Driver.
|
|
*
|
|
* Der Inhalt (`children`) wird in einem `flex:1`-Wrapper unter dem Header
|
|
* gerendert — der Caller layoutet selbst (z.B. `flex:1`-ScrollView + Bottom-Bar
|
|
* für eine Input-Zeile, die dann automatisch über der Tastatur sitzt).
|
|
*
|
|
* Für progressive Mehr-Feld-Formulare (Mail-Account, Domain hinzufügen) kommt
|
|
* `<SheetFieldStack>` als Inhalt rein (Phase 2).
|
|
*/
|
|
|
|
const MAX_HEIGHT_PCT = 0.75; // harter Cap — nie höher
|
|
const DRAG_FLICK_VELOCITY = 1.5;
|
|
|
|
export interface FormSheetProps {
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
/** Titel links im Header. */
|
|
title: string;
|
|
children: ReactNode;
|
|
/** Start-Höhe als Anteil der Screen-Höhe (0..0.75). Default 0.5. */
|
|
initialHeightPct?: number;
|
|
/** Drag-down unter diesen Anteil (oder Flick) → dismiss. Default 0.3. */
|
|
minHeightPct?: number;
|
|
/** Backdrop-Deckkraft (0 = kein Dim). Default 0.12 — Main-Screen bleibt sichtbar. */
|
|
backdropOpacity?: number;
|
|
/** Default true — Tap auf Backdrop schließt das Sheet. */
|
|
dismissOnBackdrop?: boolean;
|
|
/** Default true — fügt unten einen Safe-Area-Spacer ein wenn die Tastatur zu ist. */
|
|
safeAreaBottom?: boolean;
|
|
/** Default true — Sheet wächst/expandiert wenn die Tastatur aufgeht. Für
|
|
* Sheets ohne Input egal; auf false setzen wenn man's bewusst nicht will. */
|
|
growWithKeyboard?: boolean;
|
|
/** Border-Radius oben. Default 24. */
|
|
topRadius?: number;
|
|
}
|
|
|
|
export function FormSheet({
|
|
visible,
|
|
onClose,
|
|
title,
|
|
children,
|
|
initialHeightPct = 0.5,
|
|
minHeightPct = 0.3,
|
|
backdropOpacity = 0.12,
|
|
dismissOnBackdrop = true,
|
|
safeAreaBottom = true,
|
|
growWithKeyboard = true,
|
|
topRadius = 24,
|
|
}: FormSheetProps) {
|
|
const colors = useColors();
|
|
const insets = useSafeAreaInsets();
|
|
// useWindowDimensions: live — auf Android schrumpft height bei offener Tastatur
|
|
// (adjustResize), daher dynamisch statt Dimensions.get (statisch beim Modul-Load).
|
|
const { height: SCREEN_H } = useWindowDimensions();
|
|
|
|
const maxHeight = SCREEN_H * MAX_HEIGHT_PCT;
|
|
const initialHeight = Math.min(SCREEN_H * initialHeightPct, maxHeight);
|
|
const dismissHeight = SCREEN_H * minHeightPct;
|
|
|
|
const sheetHeight = useRef(new Animated.Value(initialHeight)).current; // JS driver
|
|
const dismissY = useRef(new Animated.Value(0)).current; // native driver
|
|
const currentHeight = useRef(initialHeight); // letzte „Ruhe"-Höhe (Drag oder initial)
|
|
const keyboardHeightRef = useRef(0);
|
|
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
|
|
|
// Reset bei (Wieder-)Öffnen
|
|
useEffect(() => {
|
|
if (visible) {
|
|
sheetHeight.setValue(initialHeight);
|
|
dismissY.setValue(0);
|
|
currentHeight.current = initialHeight;
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [visible]);
|
|
|
|
const handleClose = () => {
|
|
Keyboard.dismiss();
|
|
sheetHeight.setValue(initialHeight);
|
|
dismissY.setValue(0);
|
|
currentHeight.current = initialHeight;
|
|
onClose();
|
|
};
|
|
|
|
// Keyboard: Sheet wächst (gedeckelt) + paddingBottom schiebt Inhalt über die Tastatur
|
|
useEffect(() => {
|
|
if (!growWithKeyboard) return;
|
|
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
|
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
|
const showSub = Keyboard.addListener(showEvent, (e) => {
|
|
const h = e.endCoordinates.height;
|
|
keyboardHeightRef.current = h;
|
|
setKeyboardHeight(h);
|
|
Animated.timing(sheetHeight, {
|
|
toValue: Math.min(currentHeight.current + h, maxHeight),
|
|
duration: Platform.OS === 'ios' ? e.duration ?? 250 : 200,
|
|
useNativeDriver: false,
|
|
}).start();
|
|
});
|
|
const hideSub = Keyboard.addListener(hideEvent, (e) => {
|
|
keyboardHeightRef.current = 0;
|
|
setKeyboardHeight(0);
|
|
Animated.timing(sheetHeight, {
|
|
toValue: Math.min(currentHeight.current, maxHeight),
|
|
duration: Platform.OS === 'ios' ? e?.duration ?? 250 : 200,
|
|
useNativeDriver: false,
|
|
}).start();
|
|
});
|
|
return () => {
|
|
showSub.remove();
|
|
hideSub.remove();
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [growWithKeyboard, maxHeight]);
|
|
|
|
const panResponder = useRef(
|
|
PanResponder.create({
|
|
onStartShouldSetPanResponder: () => true,
|
|
onMoveShouldSetPanResponder: () => true,
|
|
onPanResponderTerminationRequest: () => false,
|
|
onPanResponderMove: (_, g) => {
|
|
// Drag rauf (dy<0) → höher. Mit offener Tastatur rechnen wir vom
|
|
// gewachsenen Stand aus.
|
|
const base = currentHeight.current + keyboardHeightRef.current;
|
|
const next = base - g.dy;
|
|
sheetHeight.setValue(Math.max(dismissHeight - 60, Math.min(maxHeight + 16, next)));
|
|
},
|
|
onPanResponderRelease: (_, g) => {
|
|
const base = currentHeight.current + keyboardHeightRef.current;
|
|
const finalH = base - g.dy;
|
|
const v = g.vy;
|
|
if (finalH < dismissHeight || v > DRAG_FLICK_VELOCITY) {
|
|
Animated.timing(dismissY, {
|
|
toValue: SCREEN_H,
|
|
duration: 200,
|
|
useNativeDriver: true,
|
|
}).start(() => handleClose());
|
|
return;
|
|
}
|
|
let target = finalH;
|
|
if (v < -DRAG_FLICK_VELOCITY) target = maxHeight;
|
|
const clamped = Math.max(SCREEN_H * minHeightPct, Math.min(maxHeight, target));
|
|
Animated.spring(sheetHeight, {
|
|
toValue: clamped,
|
|
useNativeDriver: false,
|
|
friction: 9,
|
|
tension: 70,
|
|
}).start();
|
|
// „Ruhe"-Höhe = ohne Tastatur-Anteil merken
|
|
currentHeight.current = Math.max(0, clamped - keyboardHeightRef.current);
|
|
},
|
|
}),
|
|
).current;
|
|
|
|
const dragHandlers = panResponder.panHandlers;
|
|
|
|
return (
|
|
<Modal visible={visible} transparent animationType="slide" onRequestClose={handleClose} statusBarTranslucent>
|
|
{/* Backdrop */}
|
|
<TouchableOpacity
|
|
activeOpacity={1}
|
|
onPress={dismissOnBackdrop ? handleClose : undefined}
|
|
style={{ flex: 1, backgroundColor: `rgba(0,0,0,${backdropOpacity})` }}
|
|
/>
|
|
|
|
{/* Outer: animated height (JS driver) */}
|
|
<Animated.View
|
|
style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: sheetHeight }}
|
|
>
|
|
{/* Inner: animated transform (native driver) — getrennt, kein Driver-Mix */}
|
|
<Animated.View
|
|
style={{
|
|
flex: 1,
|
|
backgroundColor: colors.bg,
|
|
borderTopLeftRadius: topRadius,
|
|
borderTopRightRadius: topRadius,
|
|
overflow: 'hidden',
|
|
paddingBottom: Platform.OS === 'ios' ? keyboardHeight : 0,
|
|
transform: [{ translateY: dismissY }],
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: -2 },
|
|
shadowOpacity: 0.08,
|
|
shadowRadius: 8,
|
|
}}
|
|
>
|
|
{/* Grabber-Bar (mittig, drag-area) */}
|
|
<View {...dragHandlers} style={{ alignItems: 'center', paddingTop: 8, paddingBottom: 6 }}>
|
|
<View style={{ width: 36, height: 5, borderRadius: 3, backgroundColor: colors.border }} />
|
|
</View>
|
|
|
|
{/* Header: Titel links — keine Buttons. Auch drag-area. */}
|
|
<View
|
|
{...dragHandlers}
|
|
style={{
|
|
paddingHorizontal: 20,
|
|
paddingTop: 4,
|
|
paddingBottom: 12,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: colors.border,
|
|
}}
|
|
>
|
|
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
|
{title}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* Inhalt */}
|
|
<View style={{ flex: 1 }}>{children}</View>
|
|
|
|
{/* Safe-Area-Spacer (nur wenn Tastatur zu) */}
|
|
{safeAreaBottom && <View style={{ height: keyboardHeight > 0 ? 0 : insets.bottom }} />}
|
|
</Animated.View>
|
|
</Animated.View>
|
|
</Modal>
|
|
);
|
|
}
|