chahinebrini b8e4b02b88 perf(images): migrate react-native Image → expo-image (memory+disk cache)
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).
2026-05-20 04:49:11 +02:00

154 lines
5.7 KiB
TypeScript
Raw 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 { 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>
);
}