- Android Theme parent → Theme.MaterialComponents.DayNight.NoActionBar.Bridge (fix BadgeDrawable crash in react-native-bottom-tabs after AccessibilityService toggle) - Plugin with-material-theme-android keeps theme idempotent across prebuilds - Plugin with-release-signing-android wires release signingConfig from key.properties - Splash: align native splash image with JS BrandSplash (icon.png) to eliminate double-splash flicker on app start - DM: reset partner/messages/replyTo state on userId change, disable cache for history query, switch spinner condition to isLoading||isFetching so reopens always load fresh and never show empty-state with stale partner Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
389 lines
12 KiB
TypeScript
389 lines
12 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
FlatList,
|
|
TouchableOpacity,
|
|
KeyboardAvoidingView,
|
|
Platform,
|
|
ActivityIndicator,
|
|
Image,
|
|
StyleSheet,
|
|
} from 'react-native';
|
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { apiFetch } from '../lib/api';
|
|
import { supabase } from '../lib/supabase';
|
|
import { ChatBubble, type ChatMsg } from '../components/chat/ChatBubble';
|
|
import { resolveAvatar } from '../lib/resolveAvatar';
|
|
import { ChatInput, type SendPayload } from '../components/chat/ChatInput';
|
|
import { DmChatBackground } from '../components/chat/DmChatBackground';
|
|
import { useDmRealtime } from '../hooks/useChatRealtime';
|
|
import { useColors } from '../lib/theme';
|
|
import { useThemeStore } from '../stores/theme';
|
|
|
|
type DmHistoryResponse = {
|
|
partner: {
|
|
id: string;
|
|
nickname: string;
|
|
username?: string;
|
|
avatar?: string | null;
|
|
};
|
|
messages: Array<{
|
|
id: string;
|
|
content: string;
|
|
createdAt: string;
|
|
isOwn: boolean;
|
|
readAt: string | null;
|
|
senderId?: string;
|
|
receiverId?: string;
|
|
likesCount?: number;
|
|
likedByMe?: boolean;
|
|
attachmentUrl?: string | null;
|
|
attachmentType?: string | null;
|
|
attachmentName?: string | null;
|
|
replyTo?: any;
|
|
}>;
|
|
};
|
|
|
|
const GROUP_GAP_MS = 5 * 60 * 1000;
|
|
|
|
export default function DmScreen() {
|
|
const { t } = useTranslation();
|
|
const router = useRouter();
|
|
const insets = useSafeAreaInsets();
|
|
const colors = useColors();
|
|
const styles = makeStyles(colors);
|
|
const queryClient = useQueryClient();
|
|
const flatRef = useRef<FlatList>(null);
|
|
const [myUserId, setMyUserId] = useState<string | undefined>(undefined);
|
|
|
|
const colorScheme = useThemeStore((s) => s.colorScheme);
|
|
const chatBg = colorScheme === 'dark' ? '#1a1f1e' : '#EDE8E1';
|
|
|
|
const { userId } = useLocalSearchParams<{ userId: string }>();
|
|
|
|
const [messages, setMessages] = useState<ChatMsg[]>([]);
|
|
const [partner, setPartner] = useState<DmHistoryResponse['partner'] | null>(null);
|
|
const partnerRef = useRef<DmHistoryResponse['partner'] | null>(null);
|
|
const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>(
|
|
null,
|
|
);
|
|
const [sending, setSending] = useState(false);
|
|
|
|
// Reset aller conversation-spezifischen States wenn userId wechselt (Stack-Reuse)
|
|
useEffect(() => {
|
|
setMessages([]);
|
|
setPartner(null);
|
|
partnerRef.current = null;
|
|
setReplyTo(null);
|
|
}, [userId]);
|
|
|
|
// Lade meine User-ID
|
|
useEffect(() => {
|
|
supabase.auth.getSession().then(({ data }) => {
|
|
setMyUserId(data.session?.user.id);
|
|
});
|
|
}, []);
|
|
|
|
// Lade DM-History — staleTime:0 erzwingt immer frischen Fetch (kein Cache-Hit-Bug)
|
|
const { isLoading, isFetching } = useQuery({
|
|
queryKey: ['dm-history', userId],
|
|
queryFn: async () => {
|
|
console.log('[dm] fetching history for partner', userId, 'me', myUserId);
|
|
try {
|
|
const data = await apiFetch<DmHistoryResponse>(`/api/chat/dm/${userId}`);
|
|
console.log('[dm] partner:', data.partner?.nickname, 'msgs:', data.messages?.length);
|
|
setPartner(data.partner);
|
|
partnerRef.current = data.partner;
|
|
const msgs: ChatMsg[] = data.messages.map((m: any) => ({
|
|
id: m.id,
|
|
userId: m.senderId ?? (m.isOwn ? myUserId ?? '' : userId),
|
|
nickname: m.isOwn ? 'Du' : data.partner?.nickname ?? '?',
|
|
avatar: m.isOwn ? null : data.partner?.avatar ?? null,
|
|
content: m.content,
|
|
replyTo: m.replyTo
|
|
? {
|
|
id: m.replyTo.id,
|
|
userId: m.replyTo.senderId,
|
|
nickname:
|
|
m.replyTo.senderId === myUserId ? 'Du' : data.partner?.nickname ?? '?',
|
|
content: m.replyTo.content?.slice(0, 100) ?? '',
|
|
attachmentType: m.replyTo.attachmentType ?? null,
|
|
}
|
|
: null,
|
|
attachmentUrl: m.attachmentUrl ?? null,
|
|
attachmentType: m.attachmentType ?? null,
|
|
attachmentName: m.attachmentName ?? null,
|
|
likesCount: m.likesCount ?? 0,
|
|
likedByMe: m.likedByMe ?? false,
|
|
createdAt: m.createdAt,
|
|
isOwn: m.isOwn,
|
|
readAt: m.readAt,
|
|
}));
|
|
setMessages(msgs);
|
|
return data;
|
|
} catch (err: any) {
|
|
console.error('[dm] history fetch failed:', err?.message ?? err);
|
|
throw err;
|
|
}
|
|
},
|
|
enabled: !!userId && !!myUserId,
|
|
staleTime: 0,
|
|
gcTime: 0,
|
|
});
|
|
|
|
// Realtime: neue DMs vom Partner
|
|
const onDmInsert = useCallback(
|
|
(row: any) => {
|
|
if (row.receiver_id !== myUserId) return;
|
|
setMessages((prev) => {
|
|
if (prev.some((m) => m.id === row.id)) return prev;
|
|
const p = partnerRef.current;
|
|
return [
|
|
...prev,
|
|
{
|
|
id: row.id,
|
|
userId: row.sender_id,
|
|
nickname: p?.nickname ?? '?',
|
|
avatar: p?.avatar ?? null,
|
|
content: row.content ?? '',
|
|
replyTo: null,
|
|
attachmentUrl: row.attachment_url ?? null,
|
|
attachmentType: row.attachment_type ?? null,
|
|
attachmentName: row.attachment_name ?? null,
|
|
likesCount: row.likes_count ?? 0,
|
|
likedByMe: false,
|
|
createdAt: row.created_at,
|
|
isOwn: false,
|
|
readAt: null,
|
|
},
|
|
];
|
|
});
|
|
},
|
|
[myUserId],
|
|
);
|
|
useDmRealtime(userId, onDmInsert, !!myUserId);
|
|
|
|
// Auto-Scroll bei neuen Messages
|
|
useEffect(() => {
|
|
if (messages.length > 0) {
|
|
requestAnimationFrame(() => flatRef.current?.scrollToEnd({ animated: true }));
|
|
}
|
|
}, [messages.length]);
|
|
|
|
async function handleSend(payload: SendPayload) {
|
|
if (sending) return;
|
|
setSending(true);
|
|
try {
|
|
const newMsg = await apiFetch<any>('/api/chat/dm', {
|
|
method: 'POST',
|
|
body: { receiverId: userId, ...payload },
|
|
});
|
|
setMessages((prev) => [
|
|
...prev,
|
|
{
|
|
id: newMsg.id,
|
|
userId: myUserId ?? '',
|
|
nickname: 'Du',
|
|
avatar: null,
|
|
content: newMsg.content,
|
|
replyTo: newMsg.replyTo
|
|
? {
|
|
id: newMsg.replyTo.id,
|
|
userId: newMsg.replyTo.senderId,
|
|
nickname:
|
|
newMsg.replyTo.senderId === myUserId ? 'Du' : partner?.nickname ?? '?',
|
|
content: newMsg.replyTo.content?.slice(0, 100) ?? '',
|
|
attachmentType: newMsg.replyTo.attachmentType ?? null,
|
|
}
|
|
: null,
|
|
attachmentUrl: newMsg.attachmentUrl,
|
|
attachmentType: newMsg.attachmentType,
|
|
attachmentName: newMsg.attachmentName,
|
|
likesCount: newMsg.likesCount ?? 0,
|
|
likedByMe: false,
|
|
createdAt: newMsg.createdAt,
|
|
isOwn: true,
|
|
readAt: null,
|
|
},
|
|
]);
|
|
setReplyTo(null);
|
|
queryClient.invalidateQueries({ queryKey: ['dm-conversations'] });
|
|
} catch (err) {
|
|
console.error('DM send failed:', err);
|
|
} finally {
|
|
setSending(false);
|
|
}
|
|
}
|
|
|
|
async function toggleLike(msg: ChatMsg) {
|
|
try {
|
|
const { liked } = await apiFetch<{ liked: boolean }>('/api/chat/like', {
|
|
method: 'POST',
|
|
body: { messageId: msg.id, type: 'dm' },
|
|
});
|
|
setMessages((prev) =>
|
|
prev.map((m) =>
|
|
m.id === msg.id
|
|
? { ...m, likedByMe: liked, likesCount: m.likesCount + (liked ? 1 : -1) }
|
|
: m,
|
|
),
|
|
);
|
|
} catch {}
|
|
}
|
|
|
|
function startReply(msg: ChatMsg) {
|
|
setReplyTo({
|
|
id: msg.id,
|
|
nickname: msg.nickname ?? '?',
|
|
content: msg.content?.slice(0, 100) || (msg.attachmentType === 'image' ? 'Bild' : ''),
|
|
});
|
|
}
|
|
|
|
function sameAuthor(a: ChatMsg | undefined, b: ChatMsg | undefined): boolean {
|
|
if (!a || !b) return false;
|
|
if (a.userId !== b.userId) return false;
|
|
return Math.abs(new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) <= GROUP_GAP_MS;
|
|
}
|
|
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
|
{/* Header */}
|
|
<View style={styles.header}>
|
|
<TouchableOpacity style={styles.backBtn} onPress={() => router.back()} hitSlop={8} activeOpacity={0.7}>
|
|
<Ionicons name="chevron-back" size={22} color={colors.text} />
|
|
</TouchableOpacity>
|
|
<View style={styles.headerCenter}>
|
|
<View style={styles.headerAvatar}>
|
|
{partner?.avatar ? (
|
|
<Image source={{ uri: resolveAvatar(partner.avatar, partner.nickname ?? '') }} style={styles.headerAvatarImg} />
|
|
) : (
|
|
<Text style={styles.headerAvatarInitials}>
|
|
{(partner?.nickname ?? '?').slice(0, 2).toUpperCase()}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
<Text style={styles.headerName} numberOfLines={1}>
|
|
{partner?.nickname ?? '…'}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
<KeyboardAvoidingView
|
|
style={{ flex: 1 }}
|
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
keyboardVerticalOffset={0}
|
|
>
|
|
<View style={{ flex: 1, backgroundColor: chatBg }}>
|
|
<DmChatBackground />
|
|
{(isLoading || isFetching) && messages.length === 0 ? (
|
|
<View style={styles.loadingBox}>
|
|
<ActivityIndicator color={colors.brandOrange} />
|
|
</View>
|
|
) : messages.length === 0 ? (
|
|
<View style={styles.loadingBox}>
|
|
<Ionicons name="chatbubble-outline" size={42} color="#d4d4d4" />
|
|
<Text style={styles.emptyText}>{t('chat.no_chats')}</Text>
|
|
</View>
|
|
) : (
|
|
<FlatList
|
|
ref={flatRef}
|
|
data={messages}
|
|
style={{ flex: 1 }}
|
|
renderItem={({ item, index }) => (
|
|
<ChatBubble
|
|
msg={item}
|
|
isFirstInGroup={!sameAuthor(messages[index - 1], item)}
|
|
isLastInGroup={!sameAuthor(item, messages[index + 1])}
|
|
onReply={startReply}
|
|
onLike={toggleLike}
|
|
onOpenImage={() => {}}
|
|
/>
|
|
)}
|
|
keyExtractor={(m) => m.id}
|
|
contentContainerStyle={{ paddingTop: 12, paddingBottom: 8 }}
|
|
showsVerticalScrollIndicator={false}
|
|
onContentSizeChange={() => flatRef.current?.scrollToEnd({ animated: false })}
|
|
/>
|
|
)}
|
|
</View>
|
|
|
|
<View style={{ paddingBottom: Math.max(insets.bottom - 8, 0) }}>
|
|
<ChatInput
|
|
replyTo={replyTo}
|
|
sending={sending}
|
|
onSend={handleSend}
|
|
onCancelReply={() => setReplyTo(null)}
|
|
/>
|
|
</View>
|
|
</KeyboardAvoidingView>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
function makeStyles(colors: ReturnType<typeof useColors>) {
|
|
return StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: colors.bg },
|
|
header: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 10,
|
|
backgroundColor: colors.bg,
|
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
borderBottomColor: colors.border,
|
|
},
|
|
backBtn: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 12,
|
|
backgroundColor: colors.surfaceElevated,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
headerCenter: {
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
marginLeft: 8,
|
|
},
|
|
headerAvatar: {
|
|
width: 32,
|
|
height: 32,
|
|
borderRadius: 16,
|
|
backgroundColor: colors.surfaceElevated,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
overflow: 'hidden',
|
|
marginRight: 8,
|
|
},
|
|
headerAvatarImg: { width: 32, height: 32 },
|
|
headerAvatarInitials: {
|
|
fontSize: 11,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: colors.textMuted,
|
|
},
|
|
headerName: {
|
|
fontSize: 15,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: colors.text,
|
|
flexShrink: 1,
|
|
},
|
|
loadingBox: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
emptyText: {
|
|
fontSize: 13,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
color: colors.textMuted,
|
|
marginTop: 12,
|
|
},
|
|
});
|
|
}
|