- 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/
197 lines
5.7 KiB
TypeScript
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,
|
|
},
|
|
});
|