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

456 lines
14 KiB
TypeScript

import { useEffect, useState } from 'react';
import {
ActivityIndicator,
ScrollView,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import {
isValidDomain,
normalizeDomain,
type Tier,
} from '../../hooks/useCustomDomains';
import { useColors, type ColorScheme } from '../../lib/theme';
import { FormSheet } from '../FormSheet';
type Props = {
visible: boolean;
tier: Tier;
onClose: () => void;
onAdd: (pattern: string, kind?: 'web' | 'mail') => Promise<{ ok: boolean; error?: string; alreadyGlobal?: boolean }>;
};
function detectKind(input: string): 'web' | 'mail' | null {
const raw = input.trim();
if (!raw) return null;
if (raw.includes('@')) return 'mail';
if (raw.includes('.')) return 'web';
return null;
}
function mailDomain(input: string): string {
const raw = input.trim();
const atIdx = raw.lastIndexOf('@');
if (atIdx === -1) return raw.toLowerCase();
return raw.slice(atIdx + 1).trim().toLowerCase();
}
export function AddDomainSheet({ visible, tier, onClose, onAdd }: Props) {
const { t } = useTranslation();
const colors = useColors();
const [input, setInput] = useState('');
const [confirmPermanent, setConfirmPermanent] = useState(false);
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null);
// User-Override über den Auto-Detect. null = follow auto-detect, sonst forced.
const [kindOverride, setKindOverride] = useState<'web' | 'mail' | null>(null);
const detected = detectKind(input);
const kind: 'web' | 'mail' | null = kindOverride ?? detected;
const normalizedWeb = kind === 'web' ? normalizeDomain(input) : '';
const normalizedMail = kind === 'mail' ? mailDomain(input) : '';
// Reset override sobald User komplett neuen Input tippt
useEffect(() => {
if (!input) setKindOverride(null);
}, [input]);
function close() {
setInput('');
setKindOverride(null);
setConfirmPermanent(false);
setError(null);
onClose();
}
function isInputValid(): boolean {
if (kind === 'web') return isValidDomain(input);
if (kind === 'mail') return normalizedMail.length > 0;
return false;
}
async function handleAdd() {
if (!isInputValid() || !confirmPermanent || adding) return;
setAdding(true);
setError(null);
const pattern = kind === 'web' ? normalizeDomain(input) : normalizedMail;
// Pass kind explicitly — we've already stripped the local-part for mail,
// so the backend's auto-detect (which keys on the "@" character) can no
// longer infer the type from the pattern alone. Without this hint a
// "info@only4-subscribers.com" entry would land as type=web because
// the @ disappeared during the strip.
const result = await onAdd(pattern, kind === 'mail' ? 'mail' : 'web');
setAdding(false);
if (result.ok) {
close();
return;
}
if (result.alreadyGlobal) {
setError(t('blocker.add_sheet_already_global', { domain: pattern }));
} else {
const raw = (result.error ?? '').toLowerCase();
if (raw.includes('web_limit_reached')) {
setError(t('blocker.error_web_limit_reached'));
} else if (raw.includes('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')) {
setError(t('blocker.error_invalid_mail'));
} else if (raw.includes('invalid_domain') || raw.includes('invalid_pattern')) {
setError(t('blocker.error_invalid_input'));
} else if (raw.includes('eintrag bereits vorhanden') || raw.includes('duplicate')) {
setError(t('blocker.error_duplicate'));
} else {
// Letzter Fallback: niemals raw JSON anzeigen. Wenn message-Feld da war, kommt's
// sauber als String an — sonst generic Fehler.
setError(t('blocker.add_sheet_add_failed'));
}
}
}
const warningText =
tier.plan === 'free'
? t('blocker.add_sheet_warning_free')
: t('blocker.add_sheet_warning_pro');
const canSubmit = isInputValid() && confirmPermanent && !adding;
return (
<FormSheet
visible={visible}
onClose={close}
title={t('blocker.add_sheet_title')}
initialHeightPct={0.78}
growWithKeyboard
>
<ScrollView
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
contentContainerStyle={{ padding: 16, gap: 12 }}
>
{/* Input field */}
<View style={{ gap: 6 }}>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.text }}>
{t('blocker.add_sheet_label')}
</Text>
<TextInput
value={input}
onChangeText={(v) => { setInput(v); setError(null); }}
placeholder={t('blocker.add_sheet_placeholder')}
placeholderTextColor={colors.textMuted}
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 10,
padding: 12,
fontSize: 14,
fontFamily: 'Nunito_400Regular',
color: colors.text,
borderWidth: 1,
borderColor: error ? '#dc2626' : colors.border,
}}
/>
{error && (
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#dc2626' }}>
{error}
</Text>
)}
</View>
{/* Help text */}
<View
style={{
flexDirection: 'row',
gap: 8,
padding: 12,
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
}}
>
<Ionicons
name="information-circle-outline"
size={16}
color={colors.textMuted}
style={{ marginTop: 1 }}
/>
<Text
style={{
flex: 1,
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
lineHeight: 17,
}}
>
{t('blocker.add_sheet_help')}
</Text>
</View>
{/* Preview card */}
<PreviewCard
kind={kind}
normalizedWeb={normalizedWeb}
normalizedMail={normalizedMail}
placeholder={t('blocker.add_sheet_placeholder')}
colors={colors}
t={t}
/>
{/* Override toggle — User kann Auto-Detect korrigieren falls falsch erkannt */}
{detected !== null && (
<TouchableOpacity
onPress={() => setKindOverride(kind === 'mail' ? 'web' : 'mail')}
activeOpacity={0.7}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
}}
>
<Ionicons
name={kind === 'mail' ? 'checkbox' : 'square-outline'}
size={20}
color={kind === 'mail' ? colors.brandOrange : colors.textMuted}
/>
<Text
style={{
flex: 1,
fontSize: 13,
fontFamily: 'Nunito_600SemiBold',
color: colors.text,
}}
>
{t('blocker.kind_override_label')}
</Text>
</TouchableOpacity>
)}
{/* Warning card */}
<View
style={{
flexDirection: 'row',
gap: 10,
padding: 12,
backgroundColor: '#fef3c7',
borderRadius: 12,
borderWidth: 1,
borderColor: '#fcd34d',
}}
>
<Ionicons name="lock-closed" size={18} color="#92400e" style={{ marginTop: 1 }} />
<Text
style={{
flex: 1,
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#92400e',
lineHeight: 17,
}}
>
{warningText}
</Text>
</View>
{/* Confirm checkbox */}
<TouchableOpacity
onPress={() => setConfirmPermanent((v) => !v)}
activeOpacity={0.7}
style={{
flexDirection: 'row',
alignItems: 'flex-start',
gap: 10,
paddingVertical: 4,
}}
>
<View
style={{
width: 22,
height: 22,
borderRadius: 6,
borderWidth: 1.5,
borderColor: confirmPermanent ? colors.success : colors.border,
backgroundColor: confirmPermanent ? colors.success : colors.bg,
alignItems: 'center',
justifyContent: 'center',
marginTop: 1,
}}
>
{confirmPermanent && <Ionicons name="checkmark" size={16} color="#fff" />}
</View>
<Text
style={{
flex: 1,
fontSize: 13,
fontFamily: 'Nunito_400Regular',
color: colors.text,
lineHeight: 18,
}}
>
{t('blocker.add_sheet_confirm_permanent')}
</Text>
</TouchableOpacity>
{/* Buttons */}
<View style={{ flexDirection: 'row', gap: 10, marginTop: 4 }}>
<TouchableOpacity onPress={close} activeOpacity={0.8} style={{ flex: 1 }}>
<View
style={{
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
backgroundColor: colors.surfaceElevated,
borderWidth: 1,
borderColor: colors.border,
}}
>
<Text style={{ fontSize: 15, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
{t('common.cancel')}
</Text>
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={handleAdd}
disabled={!canSubmit}
activeOpacity={0.85}
style={{ flex: 2 }}
>
<View
style={{
backgroundColor: canSubmit ? '#dc2626' : '#d4d4d4',
borderRadius: 14,
paddingVertical: 14,
alignItems: 'center',
}}
>
{adding ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{t('blocker.add_sheet_cta')}
</Text>
)}
</View>
</TouchableOpacity>
</View>
</ScrollView>
</FormSheet>
);
}
// ─── PreviewCard ──────────────────────────────────────────────────────────────
function PreviewCard({
kind,
normalizedWeb,
normalizedMail,
placeholder,
colors,
t,
}: {
kind: 'web' | 'mail' | null;
normalizedWeb: string;
normalizedMail: string;
placeholder: string;
colors: ColorScheme;
t: (key: string, opts?: Record<string, unknown>) => string;
}) {
if (kind === 'web') {
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
padding: 12,
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
}}
>
<Image
source={{ uri: `https://www.google.com/s2/favicons?domain=${normalizedWeb || 'example.com'}&sz=64` }}
style={{ width: 24, height: 24, borderRadius: 4 }}
/>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('blocker.preview_web', { value: normalizedWeb || '…' })}
</Text>
</View>
<Ionicons name="globe-outline" size={16} color={colors.textMuted} />
</View>
);
}
if (kind === 'mail') {
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
padding: 12,
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
}}
>
<View
style={{
width: 24,
height: 24,
borderRadius: 4,
backgroundColor: '#dbeafe',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="mail-outline" size={14} color="#2563eb" />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
{t('blocker.preview_mail', { value: normalizedMail || '…' })}
</Text>
</View>
<Ionicons name="mail-outline" size={16} color={colors.textMuted} />
</View>
);
}
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
padding: 12,
backgroundColor: colors.surfaceElevated,
borderRadius: 12,
}}
>
<Ionicons name="warning-outline" size={20} color="#dc2626" />
<Text style={{ flex: 1, fontSize: 11, fontFamily: 'Nunito_400Regular', color: '#dc2626' }}>
{t('blocker.preview_invalid')}
</Text>
</View>
);
}