Online-Status (Phase 1+):
- UserAvatar mit 4 Size-Variants (sm/md/lg/xl) + integrierter Online-Dot
- OnlinePresenceProvider: Supabase-Channel + Following-Filter
- ChatHeaderStatus: "Online" neutral / "vor X min" offline
- useLastSeen + Heartbeat (60s interval + AppState-background ping)
- Privatsphäre-Toggle in profile/index
Sheets:
- FormSheet Android-keyboard-fix (Dimensions.get('screen'), kein
useWindowDimensions-Kollaps), useKeyboardHandler statt manual
Keyboard.addListener, state-reset on re-open
- PostCommentsSheet same Pattern + close-after-submit + drag bis under
app-header
- ConnectMailSheet form-view refactor: scrollable, AES-Banner als
footnote, field-order email→pw→label, fixed 0.85 über alle Steps
Chat:
- DmChatBackground iOS klecks fix (G transform statt nested Svg)
- ChatInput Lyra-1:1 (keyboardWillShow, surfaceElevated bubble,
arrow-up send, attachment links)
- dm/room/chat headers + conversation-list nutzen UserAvatar
- Foreign-Profile "Nachricht"-Button öffnet richtige DM
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
302 lines
11 KiB
TypeScript
302 lines
11 KiB
TypeScript
import { ReactNode, useEffect, useRef, useState } from 'react';
|
||
import {
|
||
Animated,
|
||
Dimensions,
|
||
Keyboard,
|
||
Modal,
|
||
PanResponder,
|
||
ScrollView,
|
||
Text,
|
||
TouchableOpacity,
|
||
View,
|
||
} from 'react-native';
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
import { useKeyboardHandler } from 'react-native-keyboard-controller';
|
||
import { runOnJS } from 'react-native-reanimated';
|
||
import { useColors } from '../lib/theme';
|
||
|
||
/**
|
||
* App-weites Bottom-Sheet — DAS eine Pattern für alle Custom-Modals.
|
||
*
|
||
* - **Default = Auto-Fit**: Sheet misst seinen Inhalt (interner ScrollView via
|
||
* `onContentSizeChange`) und wird genau so hoch wie nötig. Reicht der Cap
|
||
* nicht, scrollt der Inhalt intern.
|
||
* - **Cap**: SCREEN_H − statusBar − `navHeaderOffset` (default 56dp). So
|
||
* überschreitet das Sheet niemals den App-Nav-Header.
|
||
* - **Legacy-Mode**: Wer `initialHeightPct` setzt, bekommt das alte
|
||
* Fixed-Pct-Layout mit `<View flex:1>` children-wrapper (backwards-compat).
|
||
* - **Keyboard (iOS + Android)**: `useKeyboardHandler` aus
|
||
* `react-native-keyboard-controller` liefert den Modal-aware nativen
|
||
* Keyboard-Frame. Sheet wächst um `keyboardHeight` (gedeckelt),
|
||
* `paddingBottom: keyboardHeight` schiebt Inhalt exakt über die Tastatur —
|
||
* kein Doppel-Compensation auf Android (das war der Bug mit manuellem
|
||
* `Keyboard.addListener`-Pattern, da RN-Modal `adjustResize` ignoriert).
|
||
* - **Resize per Drag**: Grabber/Header sind drag-area → User kann größer
|
||
* ziehen (bis Max) oder zum dismissen runterswipen.
|
||
*
|
||
* 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.
|
||
*/
|
||
|
||
const DRAG_FLICK_VELOCITY = 1.5;
|
||
const DEFAULT_NAV_HEADER_OFFSET = 56;
|
||
// Grabber (8 + 5 + 6) + Header (4 + ~20 + 12 + 1) = ~56. Etwas Puffer.
|
||
const CHROME_HEIGHT = 60;
|
||
|
||
export interface FormSheetProps {
|
||
visible: boolean;
|
||
onClose: () => void;
|
||
/** Titel links im Header. */
|
||
title: string;
|
||
children: ReactNode;
|
||
/**
|
||
* Wenn gesetzt → Legacy-Fixed-Pct-Mode (alter `<View flex:1>` children-wrap).
|
||
* Ohne diesen Prop läuft Auto-Fit: Sheet wächst genau auf Content-Höhe.
|
||
*/
|
||
initialHeightPct?: number;
|
||
/** Drag-down unter diesen Anteil (oder Flick) → dismiss. Default 0.25. */
|
||
minHeightPct?: number;
|
||
/**
|
||
* Pixel-Offset unter dem Status-Bar, bei dem das Sheet aufhört zu wachsen.
|
||
* Default 56 — Standard-App-Nav-Header (Material/iOS). 0 = darf bis zur
|
||
* Status-Bar gehen.
|
||
*/
|
||
navHeaderOffset?: number;
|
||
/** Backdrop-Deckkraft (0 = kein Dim). Default 0.12. */
|
||
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 mit der Tastatur (Inputs bleiben sichtbar). */
|
||
growWithKeyboard?: boolean;
|
||
/** Border-Radius oben. Default 24. */
|
||
topRadius?: number;
|
||
}
|
||
|
||
export function FormSheet({
|
||
visible,
|
||
onClose,
|
||
title,
|
||
children,
|
||
initialHeightPct,
|
||
minHeightPct = 0.25,
|
||
navHeaderOffset = DEFAULT_NAV_HEADER_OFFSET,
|
||
backdropOpacity = 0.12,
|
||
dismissOnBackdrop = true,
|
||
safeAreaBottom = true,
|
||
growWithKeyboard = true,
|
||
topRadius = 24,
|
||
}: FormSheetProps) {
|
||
const colors = useColors();
|
||
const insets = useSafeAreaInsets();
|
||
// Dimensions.get('screen') = physische Screen-Höhe, statisch, ignoriert
|
||
// Keyboard-Resize auf Android. useWindowDimensions würde live schrumpfen
|
||
// wenn Keyboard auf und Activity adjustResize macht → maxHeight kollabiert →
|
||
// Sheet kann nicht über den Keyboard-Bereich wachsen.
|
||
const SCREEN_H = Dimensions.get('screen').height;
|
||
|
||
const autoMode = initialHeightPct === undefined;
|
||
|
||
// Cap: nicht über den App-Header. 200px Mindest-Cap als Fallback.
|
||
const maxHeight = Math.max(200, SCREEN_H - insets.top - navHeaderOffset);
|
||
|
||
// Startwert: Auto → kleiner Platzhalter bis onContentSizeChange misst.
|
||
// Legacy → der vom Caller gesetzte Pct-Wert.
|
||
const fallbackInitial = autoMode
|
||
? Math.min(SCREEN_H * 0.35, maxHeight)
|
||
: Math.min(SCREEN_H * (initialHeightPct ?? 0.5), maxHeight);
|
||
|
||
const dismissHeight = SCREEN_H * minHeightPct;
|
||
|
||
const sheetHeight = useRef(new Animated.Value(fallbackInitial)).current; // JS driver
|
||
const dismissY = useRef(new Animated.Value(0)).current; // native driver
|
||
const currentHeight = useRef(fallbackInitial); // letzte „Ruhe"-Höhe
|
||
const keyboardHeightRef = useRef(0);
|
||
const userDraggedRef = useRef(false); // sobald user manuell zieht, kein Auto-Re-Fit mehr
|
||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||
|
||
// Reset bei (Wieder-)Öffnen
|
||
useEffect(() => {
|
||
if (visible) {
|
||
// keyboardHeight reset: applyKeyboardHeight hat `if (!visible) return`,
|
||
// also kommt das `h=0`-Hide-Event beim Schließen NIE durch → ohne reset
|
||
// öffnet das Sheet beim 2. Mal mit altem paddingBottom.
|
||
setKeyboardHeight(0);
|
||
keyboardHeightRef.current = 0;
|
||
sheetHeight.setValue(fallbackInitial);
|
||
dismissY.setValue(0);
|
||
currentHeight.current = fallbackInitial;
|
||
userDraggedRef.current = false;
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [visible]);
|
||
|
||
const handleClose = () => {
|
||
Keyboard.dismiss();
|
||
sheetHeight.setValue(fallbackInitial);
|
||
dismissY.setValue(0);
|
||
currentHeight.current = fallbackInitial;
|
||
userDraggedRef.current = false;
|
||
onClose();
|
||
};
|
||
|
||
// Auto-Fit: ScrollView meldet seine natürliche Content-Höhe.
|
||
const onContentSize = (_w: number, h: number) => {
|
||
if (!autoMode || userDraggedRef.current) return;
|
||
const safeArea = safeAreaBottom ? insets.bottom : 0;
|
||
const target = Math.min(h + CHROME_HEIGHT + safeArea, maxHeight);
|
||
if (Math.abs(target - currentHeight.current) < 4) return;
|
||
currentHeight.current = target;
|
||
Animated.timing(sheetHeight, {
|
||
toValue: Math.min(target + keyboardHeightRef.current, maxHeight),
|
||
duration: 180,
|
||
useNativeDriver: false,
|
||
}).start();
|
||
};
|
||
|
||
// Keyboard: react-native-keyboard-controller liefert reliable native frame
|
||
// (Modal-aware auf Android — kein adjustResize-Doppel-Compensation-Bug).
|
||
// Wir setzen state → Animated.Value für Sheet-Höhe + paddingBottom-Anker.
|
||
const applyKeyboardHeight = (h: number) => {
|
||
// Hook feuert global — nur reagieren wenn dieses Sheet sichtbar ist,
|
||
// sonst rumpelt's mit Keyboard-Events anderer Screens.
|
||
if (!visible) return;
|
||
keyboardHeightRef.current = h;
|
||
setKeyboardHeight(h);
|
||
if (!growWithKeyboard) return;
|
||
Animated.timing(sheetHeight, {
|
||
toValue: Math.min(currentHeight.current + h, maxHeight),
|
||
duration: 220,
|
||
useNativeDriver: false,
|
||
}).start();
|
||
};
|
||
|
||
useKeyboardHandler({
|
||
onStart: (e) => {
|
||
'worklet';
|
||
runOnJS(applyKeyboardHeight)(e.height);
|
||
},
|
||
onEnd: (e) => {
|
||
'worklet';
|
||
runOnJS(applyKeyboardHeight)(e.height);
|
||
},
|
||
});
|
||
|
||
const panResponder = useRef(
|
||
PanResponder.create({
|
||
onStartShouldSetPanResponder: () => true,
|
||
onMoveShouldSetPanResponder: () => true,
|
||
onPanResponderTerminationRequest: () => false,
|
||
onPanResponderMove: (_, g) => {
|
||
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();
|
||
currentHeight.current = Math.max(0, clamped - keyboardHeightRef.current);
|
||
userDraggedRef.current = true; // ab jetzt Auto-Re-Fit ignorieren
|
||
},
|
||
}),
|
||
).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',
|
||
// iOS + Android beide: Modal-Window honoriert keyboard-resize nicht
|
||
// zuverlässig, also manuell padden damit Inputs über der Tastatur sitzen.
|
||
paddingBottom: keyboardHeight,
|
||
transform: [{ translateY: dismissY }],
|
||
shadowColor: '#000',
|
||
shadowOffset: { width: 0, height: -2 },
|
||
shadowOpacity: 0.08,
|
||
shadowRadius: 8,
|
||
}}
|
||
>
|
||
{/* Grabber-Bar (mittig, drag-area) — paddingY für 44pt-Hit-Area */}
|
||
<View
|
||
{...dragHandlers}
|
||
style={{ alignItems: 'center', paddingTop: 14, paddingBottom: 12 }}
|
||
>
|
||
<View style={{ width: 42, height: 6, 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 */}
|
||
{autoMode ? (
|
||
<ScrollView
|
||
style={{ flex: 1 }}
|
||
keyboardShouldPersistTaps="handled"
|
||
showsVerticalScrollIndicator={false}
|
||
onContentSizeChange={onContentSize}
|
||
>
|
||
{children}
|
||
</ScrollView>
|
||
) : (
|
||
<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>
|
||
);
|
||
}
|