chahinebrini b31066a04c feat(chat): native action sheet + Insta-style heart for DM messages
- ChatBubble: useActionSheet replaces custom Modal (native iOS popup, Android bottom sheet)
- DM mode (isDM prop): hides like-count, shows Insta-style heart badge under bubble when liked
- Group chat unchanged
- Cleanup: remove unused Modal/Platform imports, sheet styles, actionsOpen state
- deploy.sh: auto-detect ANDROID_HOME + auto-create local.properties for local Gradle
- NEXT_RELEASE.md: DM reactions release note
- Includes other staged work across binder-mac, marketing, ops/mdm, ios/
2026-05-30 09:14:32 +02:00

197 lines
5.7 KiB
TypeScript

/**
* SearchBarFloating — sticky-bottom floating search input.
*
* Inspired by iOS 17/18 sticky-bottom search in Settings.app. Apple uses
* UISearchBar with a custom bottom placement introduced progressively: in
* iOS 17 via UINavigationItem.searchController + preferredSearchBarPlacement
* (.bottomBar), in iOS 18/26 as part of the Liquid Glass system sheet.
*
* Native RN/Expo support:
* - react-native-screens / react-navigation searchBar prop → renders in
* navigation header (TOP, not bottom). No bottom placement exposed.
* - expo-router exposes no searchBar on native-stack bottom bar.
* - Conclusion: bottom-sticky native UISearchBar is NOT achievable from JS
* without a custom native module. This component is a JS approximation.
*
* Vibrancy: iOS uses expo-blur BlurView for real UIKit vibrancy. Android keeps
* the solid surface-color fallback — expo-blur's Android path (dimezisBlurView)
* war fehleranfällig/crasht, daher rendert der BlurView NUR auf iOS. Android
* bleibt damit komplett unangetastet.
*
* Use-case guidance:
* - Lyra voice input → use the existing ChatInput Mic button, not this.
* - Chat history search → native UISearchBar via navigation stack (top) is
* preferable for familiar iOS placement.
* - This component is best for: standalone screens where a persistent
* bottom-anchored search/input widget makes sense (e.g. SOS quick-access,
* domain search in Blocker page when not scrolled).
*/
import { useRef, useState } from 'react';
import {
Animated,
Platform,
StyleSheet,
TextInput,
TouchableOpacity,
useColorScheme,
View,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { BlurView } from 'expo-blur';
import { Ionicons } from '@expo/vector-icons';
import { useColors } from '../lib/theme';
const IS_IOS = Platform.OS === 'ios';
type Props = {
placeholder?: string;
/** Called when user taps the mic icon. Handle voice-input activation here. */
onPressMic?: () => void;
/** Called when user submits (keyboard "search" button or enter). */
onSubmit?: (text: string) => void;
/** Called on every keystroke. */
onChange?: (text: string) => void;
/** When true, shows ActivityIndicator-style pulse on mic icon. */
micActive?: boolean;
};
export function SearchBarFloating({
placeholder = 'Suchen…',
onPressMic,
onSubmit,
onChange,
micActive = false,
}: Props) {
const insets = useSafeAreaInsets();
const colors = useColors();
const scheme = useColorScheme();
const [text, setText] = useState('');
const micScale = useRef(new Animated.Value(1)).current;
function handleTextChange(val: string) {
setText(val);
onChange?.(val);
}
function handleSubmit() {
if (text.trim()) onSubmit?.(text.trim());
}
function handlePressMic() {
Animated.sequence([
Animated.timing(micScale, { toValue: 0.85, duration: 80, useNativeDriver: true }),
Animated.timing(micScale, { toValue: 1, duration: 120, useNativeDriver: true }),
]).start();
onPressMic?.();
}
const bottomOffset = Math.max(insets.bottom, 8);
return (
<View
pointerEvents="box-none"
style={[styles.wrapper, { bottom: bottomOffset }]}
>
<View
style={[
styles.pill,
{
// iOS: transparent → BlurView liefert die Fläche. Android: solider Fallback.
backgroundColor: IS_IOS ? 'transparent' : colors.surface,
borderColor: colors.border,
shadowColor: '#000',
},
]}
>
{IS_IOS ? (
<BlurView
intensity={60}
tint={scheme === 'dark' ? 'dark' : 'light'}
style={styles.blurFill}
/>
) : null}
<Ionicons name="search" size={18} color={colors.textMuted} style={styles.searchIcon} />
<TextInput
value={text}
onChangeText={handleTextChange}
onSubmitEditing={handleSubmit}
placeholder={placeholder}
placeholderTextColor={colors.textMuted}
returnKeyType="search"
style={[styles.input, { color: colors.text }]}
clearButtonMode="while-editing"
/>
{onPressMic ? (
<Animated.View style={{ transform: [{ scale: micScale }] }}>
<TouchableOpacity
onPress={handlePressMic}
hitSlop={8}
activeOpacity={0.7}
style={[
styles.micBtn,
{
backgroundColor: micActive ? '#007AFF' : colors.surfaceElevated,
},
]}
>
<Ionicons
name={micActive ? 'mic' : 'mic-outline'}
size={18}
color={micActive ? '#fff' : colors.textMuted}
/>
</TouchableOpacity>
</Animated.View>
) : null}
</View>
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
position: 'absolute',
left: 16,
right: 16,
zIndex: 100,
},
pill: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: 22,
borderWidth: StyleSheet.hairlineWidth,
paddingLeft: 12,
paddingRight: 8,
paddingVertical: 8,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 12,
elevation: 6,
gap: 8,
},
blurFill: {
...StyleSheet.absoluteFillObject,
borderRadius: 22,
overflow: 'hidden',
},
searchIcon: {
flexShrink: 0,
},
input: {
flex: 1,
fontSize: 16,
fontFamily: 'Nunito_400Regular',
padding: 0,
minHeight: 28,
},
micBtn: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
},
});