Avatare (Dicebear-URLs), Chat-Attachments und Feed-Bilder wurden bei jedem App-Reload neu vom Netzwerk geladen — RN Image hat nur flüchtigen Memory-Cache. expo-image (~3.0.11) bringt persistenten Disk-Cache (cachePolicy 'memory-disk' default). 14 Files migriert: UserAvatar, ChatBubble, RoomCard, ChatInput, PostCard, ComposeCard, NotificationsDropdown, AppHeader, ProfileHeader, AddDomainSheet, DomainGrid, room, profile/edit, signup. API-Mapping: resizeMode→contentFit. PostCard onLoad las e.nativeEvent. source — expo-image liefert e.source direkt (sonst wäre der Post-Bild- Aspect-Ratio-Fix still gebrochen). PostCard: nur Image-Zeilen angefasst, Like/Count/Memo-Logik unberührt (memory/feedback_minimal_post_changes.md). Kommt mit v0.3.3 (expo-image ist Native-Modul, braucht neuen Build).
154 lines
5.7 KiB
TypeScript
154 lines
5.7 KiB
TypeScript
import { useState } from 'react';
|
||
import { View, Text, TouchableOpacity } from 'react-native';
|
||
import { Image } from 'expo-image';
|
||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||
import { Ionicons } from '@expo/vector-icons';
|
||
import { useRouter, type RelativePathString } from 'expo-router';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { useAuthStore } from '../stores/auth';
|
||
import { useNotificationStore } from '../stores/notifications';
|
||
import { resolveAvatar } from '../lib/resolveAvatar';
|
||
import { useColors } from '../lib/theme';
|
||
import { useMe } from '../hooks/useMe';
|
||
import { NotificationsDropdown } from './NotificationsDropdown';
|
||
import { HeaderDropdownMenu } from './header/HeaderDropdownMenu';
|
||
|
||
type Props = {
|
||
notifCount?: number;
|
||
showBack?: boolean;
|
||
title?: string;
|
||
};
|
||
|
||
export function AppHeader({ notifCount, showBack, title }: Props = {}) {
|
||
const insets = useSafeAreaInsets();
|
||
const router = useRouter();
|
||
const { t } = useTranslation();
|
||
const { user } = useAuthStore();
|
||
const colors = useColors();
|
||
const { me } = useMe();
|
||
const storeUnread = useNotificationStore((s) => s.unread);
|
||
const badge = notifCount ?? storeUnread;
|
||
const [notifOpen, setNotifOpen] = useState(false);
|
||
const [menuOpen, setMenuOpen] = useState(false);
|
||
|
||
const firstName = (user?.user_metadata?.first_name as string | undefined) ?? '';
|
||
const lastName = (user?.user_metadata?.last_name as string | undefined) ?? '';
|
||
const email = user?.email ?? '';
|
||
const initials = (() => {
|
||
if (me?.nickname) return me.nickname.slice(0, 2).toUpperCase();
|
||
return ((firstName.charAt(0) + (lastName.charAt(0) || email.charAt(0))).toUpperCase() || '?');
|
||
})();
|
||
|
||
// Avatar: aus DB (`/api/auth/me` → profiles.avatar). Kann Hero-Avatar-ID
|
||
// ("spider"/"hulk"/...) ODER Custom-Photo-URL (https://... von Foto-Upload)
|
||
// sein. resolveAvatar handlet beide Fälle.
|
||
const avatarUrl = me ? resolveAvatar(me.avatar, me.nickname ?? '') : '';
|
||
const [avatarLoadFailed, setAvatarLoadFailed] = useState(false);
|
||
const showAvatarImage = !!avatarUrl && !avatarLoadFailed && !!me?.avatar;
|
||
|
||
const headerHeight = insets.top + 56;
|
||
|
||
return (
|
||
<View
|
||
style={{
|
||
paddingTop: insets.top,
|
||
backgroundColor: colors.surface,
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: colors.border,
|
||
}}
|
||
>
|
||
<View className="h-14 flex-row items-center justify-between px-5">
|
||
<View className="flex-row items-center" style={{ gap: 8 }}>
|
||
{showBack ? (
|
||
<TouchableOpacity
|
||
onPress={() => router.back()}
|
||
hitSlop={10}
|
||
activeOpacity={0.6}
|
||
style={{
|
||
marginLeft: -8,
|
||
width: 36,
|
||
height: 36,
|
||
borderRadius: 18,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
accessibilityLabel="Zurück"
|
||
>
|
||
<Ionicons name="chevron-back" size={22} color={colors.text} />
|
||
</TouchableOpacity>
|
||
) : null}
|
||
<Text style={{ fontFamily: 'Nunito_700Bold', fontSize: 18, color: colors.text, letterSpacing: -0.3 }}>
|
||
{title ?? t('appHeader.appName')}
|
||
</Text>
|
||
</View>
|
||
|
||
<View className="flex-row items-center gap-1">
|
||
<TouchableOpacity
|
||
onPress={() => setNotifOpen(true)}
|
||
hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }}
|
||
activeOpacity={0.7}
|
||
style={{
|
||
width: 36,
|
||
height: 36,
|
||
borderRadius: 18,
|
||
backgroundColor: colors.surface,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
<Ionicons name="notifications-outline" size={22} color={colors.textMuted} />
|
||
{badge > 0 && (
|
||
<View className="absolute top-0 right-0 w-4 h-4 rounded-full bg-rebreak-500 items-center justify-center">
|
||
<Text className="text-white text-[9px]" style={{ fontFamily: 'Nunito_700Bold' }}>
|
||
{badge > 9 ? '9+' : String(badge)}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
</TouchableOpacity>
|
||
|
||
{/* Avatar = Trigger für Dropdown-Menu (kein separates 3-Punkte-Icon).
|
||
TouchableOpacity statt Pressable-mit-style-fn — Pressable's style-fn
|
||
schluckt auf Android manchmal width/height → 0×0 + overflow:hidden
|
||
→ Avatar unsichtbar (vgl. Mac-CTA-Fix 7d04e42). */}
|
||
<TouchableOpacity
|
||
onPress={() => setMenuOpen(true)}
|
||
hitSlop={{ top: 4, bottom: 4, left: 4, right: 4 }}
|
||
activeOpacity={0.7}
|
||
style={{
|
||
width: 36,
|
||
height: 36,
|
||
borderRadius: 18,
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
overflow: 'hidden',
|
||
backgroundColor: showAvatarImage ? colors.surfaceElevated : colors.brandOrange,
|
||
}}
|
||
>
|
||
{showAvatarImage ? (
|
||
<Image
|
||
source={{ uri: avatarUrl }}
|
||
onError={() => setAvatarLoadFailed(true)}
|
||
style={{ width: 36, height: 36, borderRadius: 18 }}
|
||
/>
|
||
) : (
|
||
<Text className="text-white text-xs" style={{ fontFamily: 'Nunito_700Bold' }}>{initials}</Text>
|
||
)}
|
||
</TouchableOpacity>
|
||
|
||
<HeaderDropdownMenu
|
||
visible={menuOpen}
|
||
onClose={() => setMenuOpen(false)}
|
||
topOffset={headerHeight + 6}
|
||
/>
|
||
</View>
|
||
</View>
|
||
|
||
<NotificationsDropdown
|
||
visible={notifOpen}
|
||
onClose={() => setNotifOpen(false)}
|
||
topOffset={headerHeight}
|
||
/>
|
||
</View>
|
||
);
|
||
}
|