chahinebrini 5c539f8937 feat(presence,sheets,chat): tester-build polish bundle
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>
2026-05-18 08:06:47 +02:00

302 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}