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).
This commit is contained in:
chahinebrini 2026-05-20 04:49:11 +02:00
parent a9015d1951
commit b8e4b02b88
16 changed files with 60 additions and 25 deletions

View File

@ -4,10 +4,10 @@ import {
Text, Text,
TextInput, TextInput,
TouchableOpacity, TouchableOpacity,
Image,
ActivityIndicator, ActivityIndicator,
Platform, Platform,
} from 'react-native'; } from 'react-native';
import { Image } from 'expo-image';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';

View File

@ -5,10 +5,10 @@ import {
TextInput, TextInput,
TouchableOpacity, TouchableOpacity,
ScrollView, ScrollView,
Image,
ActivityIndicator, ActivityIndicator,
Alert, Alert,
} from 'react-native'; } from 'react-native';
import { Image } from 'expo-image';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';

View File

@ -15,7 +15,7 @@ import {
Keyboard, Keyboard,
KeyboardAvoidingView, KeyboardAvoidingView,
} from 'react-native'; } from 'react-native';
import { Image } from 'react-native'; import { Image } from 'expo-image';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams } from 'expo-router'; import { useRouter, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
@ -322,7 +322,7 @@ export default function RoomScreen() {
<View style={styles.headerCenter}> <View style={styles.headerCenter}>
<View style={styles.headerAvatar}> <View style={styles.headerAvatar}>
{room?.avatarUrl ? ( {room?.avatarUrl ? (
<Image source={{ uri: room.avatarUrl }} style={styles.headerAvatarImg} resizeMode="cover" /> <Image source={{ uri: room.avatarUrl }} style={styles.headerAvatarImg} contentFit="cover" />
) : ( ) : (
<Text style={styles.headerAvatarInitials}>{initials}</Text> <Text style={styles.headerAvatarInitials}>{initials}</Text>
)} )}
@ -556,7 +556,7 @@ function RoomSettingsModal({
style={modal.avatarWrap} style={modal.avatarWrap}
> >
{room.avatarUrl ? ( {room.avatarUrl ? (
<Image source={{ uri: room.avatarUrl }} style={modal.avatar} resizeMode="cover" /> <Image source={{ uri: room.avatarUrl }} style={modal.avatar} contentFit="cover" />
) : ( ) : (
<View style={[modal.avatar, modal.avatarPlaceholder]}> <View style={[modal.avatar, modal.avatarPlaceholder]}>
<Ionicons name="people" size={32} color="#737373" /> <Ionicons name="people" size={32} color="#737373" />

View File

@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { View, Text, TouchableOpacity, Image } from 'react-native'; import { View, Text, TouchableOpacity } from 'react-native';
import { Image } from 'expo-image';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter, type RelativePathString } from 'expo-router'; import { useRouter, type RelativePathString } from 'expo-router';

View File

@ -5,10 +5,10 @@ import {
TextInput, TextInput,
Pressable, Pressable,
TouchableOpacity, TouchableOpacity,
Image,
ActivityIndicator, ActivityIndicator,
Alert, Alert,
} from 'react-native'; } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -126,7 +126,7 @@ export function ComposeCard({ onPosted }: Props) {
source={{ uri: imageUri }} source={{ uri: imageUri }}
className="w-full rounded-xl" className="w-full rounded-xl"
style={{ height: 160 }} style={{ height: 160 }}
resizeMode="cover" contentFit="cover"
/> />
<Pressable <Pressable
onPress={() => setImageUri(null)} onPress={() => setImageUri(null)}

View File

@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { View, Text, Pressable, TouchableOpacity, Modal, FlatList, Animated, Image } from 'react-native'; import { View, Text, Pressable, TouchableOpacity, Modal, FlatList, Animated } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter, type RelativePathString } from 'expo-router'; import { useRouter, type RelativePathString } from 'expo-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';

View File

@ -1,5 +1,6 @@
import { memo, useState, useCallback, useRef, useEffect } from 'react'; import { memo, useState, useCallback, useRef, useEffect } from 'react';
import { View, Text, Image, Pressable, Animated, TouchableOpacity } from 'react-native'; import { View, Text, Pressable, Animated, TouchableOpacity } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -308,7 +309,7 @@ function PostCardImpl({ post, onCommentPress }: Props) {
<Image <Image
source={{ uri: displayImage }} source={{ uri: displayImage }}
onLoad={(e) => { onLoad={(e) => {
const { width, height } = e.nativeEvent.source; const { width, height } = e.source;
if (width && height) { if (width && height) {
const ratio = width / height; const ratio = width / height;
setImageAspectRatio(Math.max(0.6, Math.min(1.78, ratio))); setImageAspectRatio(Math.max(0.6, Math.min(1.78, ratio)));
@ -316,7 +317,7 @@ function PostCardImpl({ post, onCommentPress }: Props) {
}} }}
className="w-full rounded-xl mt-3" className="w-full rounded-xl mt-3"
style={{ aspectRatio: imageAspectRatio ?? 1.78 }} style={{ aspectRatio: imageAspectRatio ?? 1.78 }}
resizeMode="cover" contentFit="cover"
/> />
)} )}
@ -439,7 +440,7 @@ function DomainFavicon({ domain, size }: DomainFaviconProps) {
<Image <Image
source={{ uri }} source={{ uri }}
style={{ width: size, height: size, borderRadius: 6 }} style={{ width: size, height: size, borderRadius: 6 }}
resizeMode="cover" contentFit="cover"
onError={() => setFailed(true)} onError={() => setFailed(true)}
/> />
); );

View File

@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { View, Text, Image } from 'react-native'; import { View, Text } from 'react-native';
import { Image } from 'expo-image';
import { useOnlineUsers } from '../hooks/useOnlineUsers'; import { useOnlineUsers } from '../hooks/useOnlineUsers';
import { resolveAvatar } from '../lib/resolveAvatar'; import { resolveAvatar } from '../lib/resolveAvatar';
import { useColors } from '../lib/theme'; import { useColors } from '../lib/theme';
@ -89,7 +90,7 @@ export function UserAvatar({
borderRadius: radius, borderRadius: radius,
backgroundColor: colors.surfaceElevated, backgroundColor: colors.surfaceElevated,
}} }}
resizeMode="cover" contentFit="cover"
/> />
) : ( ) : (
<View <View

View File

@ -1,13 +1,13 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Image,
ScrollView, ScrollView,
Text, Text,
TextInput, TextInput,
TouchableOpacity, TouchableOpacity,
View, View,
} from 'react-native'; } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@ -98,6 +98,14 @@ export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
setError(t('blocker.error_web_limit_reached')); setError(t('blocker.error_web_limit_reached'));
} else if (raw.includes('mail_limit_reached')) { } else if (raw.includes('mail_limit_reached')) {
setError(t('blocker.error_mail_limit_reached')); setError(t('blocker.error_mail_limit_reached'));
} else if (raw === 'limit_reached' || raw.includes('limit_reached')) {
// Client-side tier.atLimit Reject (combined web+mail). Bucket-spezifisch
// wäre genauer aber der Generic-Limit-Hinweis reicht für jetzt.
setError(
kind === 'mail'
? t('blocker.error_mail_limit_reached')
: t('blocker.error_web_limit_reached'),
);
} else if (raw.includes('invalid_mail_domain') || raw.includes('display_name_not_supported')) { } else if (raw.includes('invalid_mail_domain') || raw.includes('display_name_not_supported')) {
setError(t('blocker.error_invalid_mail')); setError(t('blocker.error_invalid_mail'));
} else if (raw.includes('invalid_domain') || raw.includes('invalid_pattern')) { } else if (raw.includes('invalid_domain') || raw.includes('invalid_pattern')) {

View File

@ -3,9 +3,9 @@ import {
View, View,
Text, Text,
TouchableOpacity, TouchableOpacity,
Image,
ActivityIndicator, ActivityIndicator,
} from 'react-native'; } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SuccessAlert } from '../SuccessAlert'; import { SuccessAlert } from '../SuccessAlert';

View File

@ -2,12 +2,12 @@ import { useState } from 'react';
import { import {
View, View,
Text, Text,
Image,
TouchableOpacity, TouchableOpacity,
StyleSheet, StyleSheet,
Modal, Modal,
Platform, Platform,
} from 'react-native'; } from 'react-native';
import { Image } from 'expo-image';
import * as Clipboard from 'expo-clipboard'; import * as Clipboard from 'expo-clipboard';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -198,7 +198,7 @@ export function ChatBubble({
<Image <Image
source={{ uri: msg.attachmentUrl }} source={{ uri: msg.attachmentUrl }}
style={styles.image} style={styles.image}
resizeMode="cover" contentFit="cover"
/> />
{isImageOnly && ( {isImageOnly && (
<View style={styles.imageTimeOverlay}> <View style={styles.imageTimeOverlay}>

View File

@ -2,7 +2,6 @@ import { useState, useRef } from 'react';
import { import {
View, View,
Text, Text,
Image,
TextInput, TextInput,
TouchableOpacity, TouchableOpacity,
StyleSheet, StyleSheet,
@ -10,6 +9,7 @@ import {
Platform, Platform,
Alert, Alert,
} from 'react-native'; } from 'react-native';
import { Image } from 'expo-image';
import * as ImagePicker from 'expo-image-picker'; import * as ImagePicker from 'expo-image-picker';
// TODO(sdk54): migrate to new expo-file-system class-based API (File/Directory/Paths) — see Task #14 // TODO(sdk54): migrate to new expo-file-system class-based API (File/Directory/Paths) — see Task #14
import * as FileSystem from 'expo-file-system/legacy'; import * as FileSystem from 'expo-file-system/legacy';
@ -165,7 +165,7 @@ export function ChatInput({
{attachment && ( {attachment && (
<View style={styles.attachBar}> <View style={styles.attachBar}>
{attachment.isImage ? ( {attachment.isImage ? (
<Image source={{ uri: attachment.uri }} style={styles.attachImg} resizeMode="cover" /> <Image source={{ uri: attachment.uri }} style={styles.attachImg} contentFit="cover" />
) : ( ) : (
<View style={styles.attachFileIcon}> <View style={styles.attachFileIcon}>
<Ionicons name="document" size={18} color="#737373" /> <Ionicons name="document" size={18} color="#737373" />

View File

@ -1,4 +1,5 @@
import { View, Text, Image, TouchableOpacity, StyleSheet } from 'react-native'; import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme'; import { useColors } from '../../lib/theme';
@ -43,7 +44,7 @@ export function RoomCard({ room, onPress }: Props) {
<View style={styles.row}> <View style={styles.row}>
<View style={[styles.avatar, { backgroundColor: room.isPublic ? '#EFF6FF' : colors.surfaceElevated }]}> <View style={[styles.avatar, { backgroundColor: room.isPublic ? '#EFF6FF' : colors.surfaceElevated }]}>
{room.avatarUrl ? ( {room.avatarUrl ? (
<Image source={{ uri: room.avatarUrl }} style={styles.avatarImg} resizeMode="cover" /> <Image source={{ uri: room.avatarUrl }} style={styles.avatarImg} contentFit="cover" />
) : !room.isPublic ? ( ) : !room.isPublic ? (
<Text style={styles.avatarInitials}>{initials}</Text> <Text style={styles.avatarInitials}>{initials}</Text>
) : ( ) : (

View File

@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { View, Text, TouchableOpacity, Image } from 'react-native'; import { View, Text, TouchableOpacity } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import Svg, { Path } from 'react-native-svg'; import Svg, { Path } from 'react-native-svg';
import { useColors } from '../../lib/theme'; import { useColors } from '../../lib/theme';

View File

@ -36,7 +36,8 @@
"expo-file-system": "~19.0.22", "expo-file-system": "~19.0.22",
"expo-font": "~14.0.11", "expo-font": "~14.0.11",
"expo-haptics": "^15.0.8", "expo-haptics": "^15.0.8",
"expo-image-manipulator": "~14.0.7", "expo-image": "~3.0.11",
"expo-image-manipulator": "~14.0.7",
"expo-image-picker": "~17.0.11", "expo-image-picker": "~17.0.11",
"expo-linking": "~8.0.12", "expo-linking": "~8.0.12",
"expo-local-authentication": "~17.0.8", "expo-local-authentication": "~17.0.8",

20
pnpm-lock.yaml generated
View File

@ -192,6 +192,9 @@ importers:
expo-haptics: expo-haptics:
specifier: ^15.0.8 specifier: ^15.0.8
version: 15.0.8(expo@54.0.34) version: 15.0.8(expo@54.0.34)
expo-image:
specifier: ~3.0.11
version: 3.0.11(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)
expo-image-manipulator: expo-image-manipulator:
specifier: ~14.0.7 specifier: ~14.0.7
version: 14.0.8(expo@54.0.34) version: 14.0.8(expo@54.0.34)
@ -5567,6 +5570,17 @@ packages:
peerDependencies: peerDependencies:
expo: '*' expo: '*'
expo-image@3.0.11:
resolution: {integrity: sha512-4TudfUCLgYgENv+f48omnU8tjS2S0Pd9EaON5/s1ZUBRwZ7K8acEr4NfvLPSaeXvxW24iLAiyQ7sV7BXQH3RoA==}
peerDependencies:
expo: '*'
react: '*'
react-native: '*'
react-native-web: '*'
peerDependenciesMeta:
react-native-web:
optional: true
expo-json-utils@0.15.0: expo-json-utils@0.15.0:
resolution: {integrity: sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==} resolution: {integrity: sha512-duRT6oGl80IDzH2LD2yEFWNwGIC2WkozsB6HF3cDYNoNNdUvFk6uN3YiwsTsqVM/D0z6LEAQ01/SlYvN+Fw0JQ==}
@ -15566,6 +15580,12 @@ snapshots:
expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(typescript@5.8.3) expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
expo-image-loader: 6.0.0(expo@54.0.34) expo-image-loader: 6.0.0(expo@54.0.34)
expo-image@3.0.11(expo@54.0.34)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0):
dependencies:
expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
react: 19.1.0
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)
expo-json-utils@0.15.0: {} expo-json-utils@0.15.0: {}
expo-keep-awake@15.0.8(expo@54.0.34)(react@19.1.0): expo-keep-awake@15.0.8(expo@54.0.34)(react@19.1.0):