- Neuer Endpoint /api/magic/me liefert nickname/avatar/plan fuer Hub-Header. Mac-App ruft fetchMe() beim Hub-Load. - DeviceHubView Header zeigt jetzt Avatar (AsyncImage mit Fallback auf Initial-Letter), Nickname + Plan-Badge statt nur 'ReBreak Magic'. - /api/magic/devices erweitert: listet zusaetzlich UserDevice-Rows mit boundToPlan != null (das sind iPhone/iPad aus dem Native-App-Login- Flow, Legend-Device-Lock). source='locked'. - Dedupe: ProtectedDevice wird unterdrueckt wenn bereits ein UserDevice mit aehnlichem Namen + gleicher Plattform existiert (fixt doppelten MacBook im Hub). - Helper prettyPlatform() + Normalisierung (platform-key 'mac'/'ios'/ 'android'/'win') fuer robusten Vergleich.
1242 lines
44 KiB
TypeScript
1242 lines
44 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
TextInput,
|
|
FlatList,
|
|
TouchableOpacity,
|
|
Platform,
|
|
Alert,
|
|
ActivityIndicator,
|
|
StyleSheet,
|
|
Keyboard,
|
|
Modal,
|
|
ScrollView,
|
|
Dimensions,
|
|
type FlatList as FlatListType,
|
|
} from 'react-native';
|
|
import { Audio } from 'expo-av';
|
|
import { KeyboardStickyView } from 'react-native-keyboard-controller';
|
|
import { Image } from 'expo-image';
|
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { useRouter, useLocalSearchParams, useFocusEffect } from 'expo-router';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useTranslation } from 'react-i18next';
|
|
import * as ImagePicker from 'expo-image-picker';
|
|
import * as MediaLibrary from 'expo-media-library';
|
|
// 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 { apiFetch } from '../lib/api';
|
|
import { ChatBubble, type ChatMsg, type MessageReaction } from '../components/chat/ChatBubble';
|
|
import { VoiceRecordingBar, formatVoiceDuration } from '../components/chat/VoiceRecordingBar';
|
|
import { DmChatBackground } from '../components/chat/DmChatBackground';
|
|
import { FormSheet } from '../components/FormSheet';
|
|
import { useDmRealtime } from '../hooks/useChatRealtime';
|
|
import { useDmTyping } from '../hooks/useDmTyping';
|
|
import { useColors } from '../lib/theme';
|
|
import { useThemeStore } from '../stores/theme';
|
|
import { useChatBackgroundStore, type ChatBgStyle } from '../stores/chatBackground';
|
|
import { useAuthStore } from '../stores/auth';
|
|
import { supabase } from '../lib/supabase';
|
|
import { UserAvatar } from '../components/UserAvatar';
|
|
import { ChatHeaderStatus } from '../components/chat/ChatHeaderStatus';
|
|
|
|
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;
|
|
|
|
type DmData = {
|
|
partner: DmHistoryResponse['partner'];
|
|
messages: ChatMsg[];
|
|
};
|
|
|
|
// Merge bei Background-Refetch: Server-Daten sind autoritativ; lokale Extras
|
|
// (optimistische temp-* Sends + Realtime-Inserts, die der letzte Fetch noch
|
|
// nicht kannte) bleiben erhalten und werden nach createdAt einsortiert.
|
|
function mergeMessages(server: ChatMsg[], local: ChatMsg[]): ChatMsg[] {
|
|
const serverIds = new Set(server.map((m) => m.id));
|
|
const extras = local.filter((m) => !serverIds.has(m.id));
|
|
if (extras.length === 0) return server;
|
|
return [...server, ...extras].sort(
|
|
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
|
);
|
|
}
|
|
|
|
export default function DmScreen() {
|
|
const { t } = useTranslation();
|
|
const router = useRouter();
|
|
const insets = useSafeAreaInsets();
|
|
const colors = useColors();
|
|
const styles = makeStyles(colors);
|
|
const queryClient = useQueryClient();
|
|
const myUserId = useAuthStore((s) => s.user?.id);
|
|
|
|
const colorScheme = useThemeStore((s) => s.colorScheme);
|
|
|
|
const { userId } = useLocalSearchParams<{ userId: string }>();
|
|
|
|
// Pro-Chat-Hintergrund (lokal, gerätegebunden). Default = 'clean' (Insta-Style).
|
|
const chatBgStyle = useChatBackgroundStore((s) => (userId && s.backgrounds[userId]) || 'clean');
|
|
const setChatBg = useChatBackgroundStore((s) => s.setBackground);
|
|
// 'clean' → solider Theme-BG (weiß / schwarz). 'pattern' → WA-artiger Symbol-BG
|
|
// mit warmem/dunklem Tint.
|
|
const chatBg =
|
|
chatBgStyle === 'pattern'
|
|
? colorScheme === 'dark'
|
|
? '#1a1f1e'
|
|
: '#EDE8E1'
|
|
: colors.bg;
|
|
|
|
const flatListRef = useRef<FlatListType<ChatMsg>>(null);
|
|
|
|
// scrollToEnd() unterschätzt auf Android UND iOS die Content-Höhe und
|
|
// stoppt 1 Item zu früh (besonders nach Bild-Load + InputBar-Padding).
|
|
// scrollToOffset(999999) wird intern auf den echten Max-Wert geclampt.
|
|
const scrollToBottom = useCallback((animated = false) => {
|
|
flatListRef.current?.scrollToOffset({ offset: 999999, animated });
|
|
}, []);
|
|
// Seed beide aus dem React-Query-Cache → Reopen einer bereits geladenen
|
|
// Konversation ist sofort sichtbar (kein Spinner, kein Flash).
|
|
const [messages, setMessages] = useState<ChatMsg[]>(
|
|
() => queryClient.getQueryData<DmData>(['dm-history', userId])?.messages ?? [],
|
|
);
|
|
const [partner, setPartner] = useState<DmHistoryResponse['partner'] | null>(
|
|
() => queryClient.getQueryData<DmData>(['dm-history', userId])?.partner ?? null,
|
|
);
|
|
const partnerRef = useRef<DmHistoryResponse['partner'] | null>(partner);
|
|
// userId, zu dem die aktuellen `messages` gehören (Stack-Reuse-Guard).
|
|
const messagesUserId = useRef<string | undefined>(userId);
|
|
const [replyTo, setReplyTo] = useState<{ id: string; nickname: string; content: string } | null>(
|
|
null,
|
|
);
|
|
const [sending, setSending] = useState(false);
|
|
const [inputText, setInputText] = useState('');
|
|
const [attachment, setAttachment] = useState<{ uri: string; name: string } | null>(null);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [keyboardVisible, setKeyboardVisible] = useState(false);
|
|
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
|
const [inputBarHeight, setInputBarHeight] = useState(60);
|
|
const [infoSheetOpen, setInfoSheetOpen] = useState(false);
|
|
const [lightboxUri, setLightboxUri] = useState<string | null>(null);
|
|
// Echtes Seitenverhältnis des Lightbox-Bilds (via onLoad). Wird gebraucht, um
|
|
// den Container exakt auf die Bild-Maße zu sizen → borderRadius rundet dann die
|
|
// sichtbaren Foto-Ecken statt der leeren Letterbox-Ränder eines Quadrats.
|
|
const [lightboxRatio, setLightboxRatio] = useState<number | null>(null);
|
|
const [savingImage, setSavingImage] = useState(false);
|
|
|
|
const openLightbox = useCallback((uri: string) => {
|
|
setLightboxRatio(null);
|
|
setLightboxUri(uri);
|
|
}, []);
|
|
const closeLightbox = useCallback(() => {
|
|
setLightboxUri(null);
|
|
setLightboxRatio(null);
|
|
}, []);
|
|
|
|
// Voice recording
|
|
const [isVoiceRecording, setIsVoiceRecording] = useState(false);
|
|
const [voiceDuration, setVoiceDuration] = useState(0);
|
|
const [voiceLevel, setVoiceLevel] = useState(0);
|
|
const [voiceTrashFlash, setVoiceTrashFlash] = useState(false);
|
|
const voiceRecordingRef = useRef<Audio.Recording | null>(null);
|
|
const voiceTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const voiceStartTime = useRef(0);
|
|
|
|
// Konversation gewechselt (expo-router reused den DM-Screen). Reply-Draft
|
|
// wegräumen und sofort auf den Cache der neuen Konversation umschalten:
|
|
// vorhanden → instant sichtbar, sonst leeren (Spinner via isLoading).
|
|
useEffect(() => {
|
|
if (messagesUserId.current === userId) return;
|
|
setReplyTo(null);
|
|
const cached = queryClient.getQueryData<DmData>(['dm-history', userId]);
|
|
setMessages(cached?.messages ?? []);
|
|
setPartner(cached?.partner ?? null);
|
|
partnerRef.current = cached?.partner ?? null;
|
|
messagesUserId.current = userId;
|
|
}, [userId, queryClient]);
|
|
|
|
// Keyboard-Sichtbarkeit tracken + scroll to end beim Schließen
|
|
useEffect(() => {
|
|
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
|
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
|
const show = Keyboard.addListener(showEvent, (e) => {
|
|
setKeyboardHeight(e.endCoordinates.height);
|
|
setKeyboardVisible(true);
|
|
requestAnimationFrame(() => scrollToBottom(false));
|
|
});
|
|
const hide = Keyboard.addListener(hideEvent, () => {
|
|
setKeyboardHeight(0);
|
|
setKeyboardVisible(false);
|
|
setTimeout(() => scrollToBottom(false), 50);
|
|
});
|
|
return () => { show.remove(); hide.remove(); };
|
|
}, []);
|
|
|
|
// Wenn User zurücknavigiert, soll die Conversation-Liste sofort neu laden
|
|
// (unread-Badge soll verschwinden — Backend hat bereits markDmsAsRead beim GET aufgerufen)
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
return () => {
|
|
queryClient.invalidateQueries({ queryKey: ['dm-conversations'] });
|
|
};
|
|
}, [queryClient]),
|
|
);
|
|
|
|
// DM-History laden — stale-while-revalidate: gecachte Messages werden sofort
|
|
// gezeigt (useState-Seed oben + Sync-Effekt unten), im Hintergrund frisch
|
|
// gefetcht und gemerged. gcTime hält den Cache über Navigation hinweg, sodass
|
|
// ein Reopen instant ist statt jedes Mal die ganze History neu zu ziehen.
|
|
const { data: historyData, isLoading, isFetching } = useQuery<DmData>({
|
|
queryKey: ['dm-history', userId],
|
|
queryFn: async () => {
|
|
const data = await apiFetch<DmHistoryResponse>(`/api/chat/dm/${userId}`);
|
|
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,
|
|
reactions: m.reactions ?? [],
|
|
deleted: m.deleted ?? false,
|
|
}));
|
|
return { partner: data.partner, messages: msgs };
|
|
},
|
|
enabled: !!userId && !!myUserId,
|
|
staleTime: 30_000,
|
|
gcTime: 30 * 60_000,
|
|
});
|
|
|
|
// Cache → lokaler State. Lokaler State bleibt Render-Source-of-Truth, damit
|
|
// Realtime-Inserts & optimistische Sends ihn direkt mutieren können; der
|
|
// Merge bewahrt lokale Extras (temp-* / noch-nicht-gefetchte Realtime-Msgs).
|
|
useEffect(() => {
|
|
if (!historyData) return;
|
|
setPartner(historyData.partner);
|
|
partnerRef.current = historyData.partner;
|
|
setMessages((prev) => {
|
|
const base = messagesUserId.current === userId ? prev : [];
|
|
messagesUserId.current = userId;
|
|
return mergeMessages(historyData.messages, base);
|
|
});
|
|
}, [historyData, userId]);
|
|
|
|
// Neue Nachricht (incoming Realtime oder outgoing send) → immer scrollen
|
|
useEffect(() => {
|
|
if (messages.length === 0) return;
|
|
requestAnimationFrame(() => scrollToBottom(true));
|
|
}, [messages.length]);
|
|
|
|
// 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,
|
|
},
|
|
];
|
|
});
|
|
// Nachricht kam live rein WÄHREND der Chat offen ist → serverseitig als
|
|
// gelesen markieren. markDmsAsRead läuft nur im History-GET, also den
|
|
// invalidieren (refetch markiert read). Sonst bleibt der Tab-Bar-Badge
|
|
// hängen, weil dm-conversations die Live-Message als unread zählt.
|
|
queryClient.invalidateQueries({ queryKey: ['dm-history', userId] });
|
|
},
|
|
[myUserId, queryClient, userId],
|
|
);
|
|
// Realtime: Partner-Soft-Delete (Tombstone) + Reaktions-Änderungen → refetch.
|
|
const refetchHistory = useCallback(() => {
|
|
queryClient.invalidateQueries({ queryKey: ['dm-history', userId] });
|
|
}, [queryClient, userId]);
|
|
useDmRealtime(userId, onDmInsert, !!myUserId, refetchHistory, refetchHistory);
|
|
|
|
// Typing-Indicator (ephemerer Broadcast, kein DB-Write)
|
|
const { partnerTyping, sendTyping, sendStopTyping } = useDmTyping(myUserId, userId);
|
|
|
|
async function pickImage() {
|
|
const perm = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
if (!perm.granted) {
|
|
Alert.alert(t('chat.photo_access_title'), t('chat.photo_access_body'));
|
|
return;
|
|
}
|
|
const result = await ImagePicker.launchImageLibraryAsync({
|
|
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
|
quality: 0.8,
|
|
});
|
|
if (!result.canceled && result.assets[0]?.uri) {
|
|
const a = result.assets[0];
|
|
setAttachment({ uri: a.uri, name: a.fileName ?? `image-${Date.now()}.jpg` });
|
|
}
|
|
}
|
|
|
|
async function uploadAttachment(file: { uri: string; name: string }): Promise<{ url: string; type: string; name: string } | null> {
|
|
try {
|
|
setUploading(true);
|
|
const ext = file.name.split('.').pop() || 'jpg';
|
|
const path = `chat/${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
|
const base64 = await FileSystem.readAsStringAsync(file.uri, {
|
|
encoding: FileSystem.EncodingType.Base64,
|
|
});
|
|
const binary = typeof atob === 'function' ? atob(base64) : Buffer.from(base64, 'base64').toString('binary');
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
const { error } = await supabase.storage.from('chat-attachments').upload(path, bytes, {
|
|
cacheControl: '3600',
|
|
upsert: false,
|
|
contentType: 'image/jpeg',
|
|
});
|
|
if (error) throw error;
|
|
const { data } = supabase.storage.from('chat-attachments').getPublicUrl(path);
|
|
return { url: data.publicUrl, type: 'image', name: file.name };
|
|
} catch (err: any) {
|
|
Alert.alert(t('chat.upload_failed'), err?.message ?? '');
|
|
return null;
|
|
} finally {
|
|
setUploading(false);
|
|
}
|
|
}
|
|
|
|
async function handleSend() {
|
|
const content = inputText.trim();
|
|
if (!content && !attachment) return;
|
|
if (sending || uploading) return;
|
|
|
|
const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
const currentReplyTo = replyTo;
|
|
const currentAttachment = attachment;
|
|
|
|
const optimisticMsg: ChatMsg = {
|
|
id: tempId,
|
|
userId: myUserId ?? '',
|
|
nickname: 'Du',
|
|
avatar: null,
|
|
content,
|
|
replyTo: currentReplyTo
|
|
? {
|
|
id: currentReplyTo.id,
|
|
userId: myUserId ?? '',
|
|
nickname: currentReplyTo.nickname,
|
|
content: currentReplyTo.content,
|
|
attachmentType: null,
|
|
}
|
|
: null,
|
|
attachmentUrl: currentAttachment?.uri ?? null,
|
|
attachmentType: currentAttachment ? 'image' : null,
|
|
attachmentName: currentAttachment?.name ?? null,
|
|
likesCount: 0,
|
|
likedByMe: false,
|
|
createdAt: new Date().toISOString(),
|
|
isOwn: true,
|
|
readAt: null,
|
|
status: 'pending',
|
|
};
|
|
|
|
setMessages((prev) => [...prev, optimisticMsg]);
|
|
setInputText('');
|
|
setAttachment(null);
|
|
setReplyTo(null);
|
|
setSending(true);
|
|
sendStopTyping();
|
|
|
|
try {
|
|
let attachmentMeta: { url: string; type: string; name: string } | null = null;
|
|
if (currentAttachment) {
|
|
attachmentMeta = await uploadAttachment(currentAttachment);
|
|
if (!attachmentMeta) {
|
|
setMessages((prev) =>
|
|
prev.map((m) => (m.id === tempId ? { ...m, status: 'failed' as const } : m)),
|
|
);
|
|
setSending(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const newMsg = await apiFetch<any>('/api/chat/dm', {
|
|
method: 'POST',
|
|
body: {
|
|
receiverId: userId,
|
|
content,
|
|
replyToId: currentReplyTo?.id,
|
|
attachmentUrl: attachmentMeta?.url,
|
|
attachmentType: attachmentMeta?.type,
|
|
attachmentName: attachmentMeta?.name,
|
|
},
|
|
});
|
|
|
|
setMessages((prev) =>
|
|
prev.map((m) =>
|
|
m.id === tempId
|
|
? {
|
|
...m,
|
|
id: newMsg.id,
|
|
content: newMsg.content,
|
|
attachmentUrl: newMsg.attachmentUrl ?? null,
|
|
attachmentType: newMsg.attachmentType ?? null,
|
|
attachmentName: newMsg.attachmentName ?? null,
|
|
createdAt: newMsg.createdAt,
|
|
status: 'sent' as const,
|
|
}
|
|
: m,
|
|
),
|
|
);
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['dm-conversations'] });
|
|
} catch (err) {
|
|
console.error('DM send failed:', err);
|
|
setMessages((prev) =>
|
|
prev.map((m) => (m.id === tempId ? { ...m, status: 'failed' as const } : m)),
|
|
);
|
|
} 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 {}
|
|
}
|
|
|
|
// Optimistisches Anwenden einer Reaktion auf die aggregierte Pills-Liste
|
|
// (WhatsApp-Toggle: gleiches Emoji entfernt, anderes ersetzt meine Reaktion).
|
|
function applyReactionOptimistic(
|
|
reactions: MessageReaction[],
|
|
emoji: string,
|
|
): MessageReaction[] {
|
|
const list = reactions.map((r) => ({ ...r }));
|
|
const mine = list.find((r) => r.mine);
|
|
const toggledOff = mine?.emoji === emoji;
|
|
if (mine) {
|
|
mine.count -= 1;
|
|
mine.mine = false;
|
|
}
|
|
const cleaned = list.filter((r) => r.count > 0);
|
|
if (toggledOff) return cleaned;
|
|
const existing = cleaned.find((r) => r.emoji === emoji);
|
|
if (existing) {
|
|
existing.count += 1;
|
|
existing.mine = true;
|
|
} else {
|
|
cleaned.push({ emoji, count: 1, mine: true });
|
|
}
|
|
return cleaned;
|
|
}
|
|
|
|
async function toggleReaction(msg: ChatMsg, emoji: string) {
|
|
setMessages((prev) =>
|
|
prev.map((m) =>
|
|
m.id === msg.id
|
|
? { ...m, reactions: applyReactionOptimistic(m.reactions ?? [], emoji) }
|
|
: m,
|
|
),
|
|
);
|
|
try {
|
|
await apiFetch('/api/chat/reaction', {
|
|
method: 'POST',
|
|
body: { messageId: msg.id, emoji },
|
|
});
|
|
} catch {
|
|
refetchHistory(); // Rollback via Server-State
|
|
}
|
|
}
|
|
|
|
function deleteMessage(msg: ChatMsg) {
|
|
Alert.alert(
|
|
t('chat.delete_confirm_title'),
|
|
t('chat.delete_confirm_msg'),
|
|
[
|
|
{ text: t('common.cancel'), style: 'cancel' },
|
|
{
|
|
text: t('chat.delete'),
|
|
style: 'destructive',
|
|
onPress: async () => {
|
|
setMessages((prev) =>
|
|
prev.map((m) =>
|
|
m.id === msg.id
|
|
? { ...m, deleted: true, content: '', attachmentUrl: null, attachmentType: null, reactions: [] }
|
|
: m,
|
|
),
|
|
);
|
|
try {
|
|
await apiFetch('/api/chat/delete-message', {
|
|
method: 'POST',
|
|
body: { messageId: msg.id },
|
|
});
|
|
} catch {
|
|
refetchHistory();
|
|
}
|
|
},
|
|
},
|
|
],
|
|
);
|
|
}
|
|
|
|
function startReply(msg: ChatMsg) {
|
|
setReplyTo({
|
|
id: msg.id,
|
|
nickname: msg.nickname ?? '?',
|
|
content: msg.content?.slice(0, 100) ||
|
|
(msg.attachmentType === 'image' ? `📷 ${t('chat.photo')}` :
|
|
msg.attachmentType === 'audio' ? `🎤 ${t('chat.voice_message')}` : ''),
|
|
});
|
|
}
|
|
|
|
async function startVoiceRecording() {
|
|
if (isVoiceRecording || sending) return;
|
|
const { status } = await Audio.requestPermissionsAsync();
|
|
if (status !== 'granted') {
|
|
Alert.alert(t('chat.mic_access_title'), t('chat.mic_access_body'));
|
|
return;
|
|
}
|
|
try {
|
|
await Audio.setAudioModeAsync({ allowsRecordingIOS: true, playsInSilentModeIOS: true });
|
|
const rec = new Audio.Recording();
|
|
await rec.prepareToRecordAsync({ ...Audio.RecordingOptionsPresets.HIGH_QUALITY, isMeteringEnabled: true });
|
|
await rec.startAsync();
|
|
voiceRecordingRef.current = rec;
|
|
voiceStartTime.current = Date.now();
|
|
setVoiceDuration(0);
|
|
setIsVoiceRecording(true);
|
|
voiceTimerRef.current = setInterval(async () => {
|
|
setVoiceDuration(Math.floor((Date.now() - voiceStartTime.current) / 1000));
|
|
try {
|
|
const s = await voiceRecordingRef.current?.getStatusAsync();
|
|
if (s?.isRecording && s.metering !== undefined) {
|
|
setVoiceLevel(Math.max(0, Math.min(1, (s.metering + 60) / 60)));
|
|
}
|
|
} catch {}
|
|
}, 200);
|
|
} catch {
|
|
await Audio.setAudioModeAsync({ allowsRecordingIOS: false }).catch(() => {});
|
|
}
|
|
}
|
|
|
|
async function cancelVoiceRecording() {
|
|
const rec = voiceRecordingRef.current;
|
|
voiceRecordingRef.current = null;
|
|
if (voiceTimerRef.current) { clearInterval(voiceTimerRef.current); voiceTimerRef.current = null; }
|
|
setVoiceDuration(0);
|
|
setVoiceLevel(0);
|
|
setVoiceTrashFlash(true);
|
|
setTimeout(() => {
|
|
setVoiceTrashFlash(false);
|
|
setIsVoiceRecording(false);
|
|
}, 350);
|
|
try { await rec?.stopAndUnloadAsync(); } catch {}
|
|
await Audio.setAudioModeAsync({ allowsRecordingIOS: false });
|
|
}
|
|
|
|
async function stopAndSendVoice() {
|
|
if (!isVoiceRecording) return;
|
|
if (voiceTimerRef.current) { clearInterval(voiceTimerRef.current); voiceTimerRef.current = null; }
|
|
const duration = voiceDuration;
|
|
setIsVoiceRecording(false);
|
|
setVoiceDuration(0);
|
|
setVoiceLevel(0);
|
|
const rec = voiceRecordingRef.current;
|
|
voiceRecordingRef.current = null;
|
|
if (!rec) return;
|
|
try { await rec.stopAndUnloadAsync(); } catch {}
|
|
await Audio.setAudioModeAsync({ allowsRecordingIOS: false });
|
|
const uri = rec.getURI();
|
|
if (!uri) return;
|
|
await sendVoiceMessage(uri, formatVoiceDuration(duration));
|
|
}
|
|
|
|
async function sendVoiceMessage(uri: string, durationStr: string) {
|
|
if (sending) return;
|
|
const tempId = `temp-voice-${Date.now()}`;
|
|
const optimisticMsg: ChatMsg = {
|
|
id: tempId,
|
|
userId: myUserId ?? '',
|
|
nickname: 'Du',
|
|
avatar: null,
|
|
content: '',
|
|
replyTo: null,
|
|
attachmentUrl: uri,
|
|
attachmentType: 'audio',
|
|
attachmentName: durationStr,
|
|
likesCount: 0,
|
|
likedByMe: false,
|
|
createdAt: new Date().toISOString(),
|
|
isOwn: true,
|
|
readAt: null,
|
|
status: 'pending',
|
|
};
|
|
setMessages((prev) => [...prev, optimisticMsg]);
|
|
setSending(true);
|
|
try {
|
|
setUploading(true);
|
|
const ext = uri.split('.').pop() ?? 'm4a';
|
|
const path = `chat/${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
|
const base64 = await FileSystem.readAsStringAsync(uri, { encoding: FileSystem.EncodingType.Base64 });
|
|
const binary = typeof atob === 'function' ? atob(base64) : Buffer.from(base64, 'base64').toString('binary');
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
const { error } = await supabase.storage.from('chat-attachments').upload(path, bytes, {
|
|
cacheControl: '3600',
|
|
upsert: false,
|
|
contentType: 'audio/m4a',
|
|
});
|
|
setUploading(false);
|
|
if (error) throw error;
|
|
const { data } = supabase.storage.from('chat-attachments').getPublicUrl(path);
|
|
const newMsg = await apiFetch<any>('/api/chat/dm', {
|
|
method: 'POST',
|
|
body: { receiverId: userId, content: '', attachmentUrl: data.publicUrl, attachmentType: 'audio', attachmentName: durationStr },
|
|
});
|
|
setMessages((prev) =>
|
|
prev.map((m) =>
|
|
m.id === tempId
|
|
? { ...m, id: newMsg.id, attachmentUrl: newMsg.attachmentUrl, createdAt: newMsg.createdAt, status: 'sent' as const }
|
|
: m
|
|
)
|
|
);
|
|
queryClient.invalidateQueries({ queryKey: ['dm-conversations'] });
|
|
} catch (err) {
|
|
console.error('Voice send failed:', err);
|
|
setMessages((prev) => prev.map((m) => m.id === tempId ? { ...m, status: 'failed' as const } : m));
|
|
setUploading(false);
|
|
} finally {
|
|
setSending(false);
|
|
}
|
|
}
|
|
|
|
// Bild aus der Lightbox in die Fotos-App sichern. Remote-URLs müssen erst
|
|
// lokal heruntergeladen werden, da saveToLibraryAsync eine file:// URI braucht.
|
|
async function saveImage(uri: string) {
|
|
if (savingImage) return;
|
|
try {
|
|
setSavingImage(true);
|
|
const perm = await MediaLibrary.requestPermissionsAsync();
|
|
if (!perm.granted) {
|
|
Alert.alert(t('chat.photo_access_title'), t('chat.photo_access_body'));
|
|
return;
|
|
}
|
|
let localUri = uri;
|
|
if (!uri.startsWith('file://')) {
|
|
const ext = uri.split('?')[0].split('.').pop() || 'jpg';
|
|
const target = `${FileSystem.cacheDirectory}save-${Date.now()}.${ext}`;
|
|
const res = await FileSystem.downloadAsync(uri, target);
|
|
localUri = res.uri;
|
|
}
|
|
await MediaLibrary.saveToLibraryAsync(localUri);
|
|
Alert.alert(t('chat.image_saved'));
|
|
} catch (err: any) {
|
|
Alert.alert(t('chat.save_failed'), err?.message ?? '');
|
|
} finally {
|
|
setSavingImage(false);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Lightbox-Bildmaße: in die Bildschirmfläche einpassen, Seitenverhältnis wahren.
|
|
const lbWin = Dimensions.get('window');
|
|
const lbMaxW = lbWin.width - 24;
|
|
const lbMaxH = lbWin.height * 0.78;
|
|
let lbW = lbMaxW;
|
|
let lbH = lbMaxW; // Fallback (Ratio noch unbekannt): Quadrat
|
|
if (lightboxRatio) {
|
|
lbW = lbMaxW;
|
|
lbH = lbMaxW / lightboxRatio;
|
|
if (lbH > lbMaxH) {
|
|
lbH = lbMaxH;
|
|
lbW = lbMaxH * lightboxRatio;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
|
<View style={[styles.header, { backgroundColor: colors.bg }]}>
|
|
<TouchableOpacity style={styles.backBtn} onPress={() => router.back()} hitSlop={8} activeOpacity={0.7}>
|
|
<Ionicons name="chevron-back" size={22} color={colors.text} />
|
|
</TouchableOpacity>
|
|
|
|
{/* Avatar + Name — tap → Profil */}
|
|
<TouchableOpacity
|
|
style={styles.headerCenter}
|
|
activeOpacity={0.7}
|
|
onPress={() => userId && router.push(`/profile/${userId}` as any)}
|
|
>
|
|
<View style={{ marginRight: 8 }}>
|
|
<UserAvatar
|
|
userId={userId ?? null}
|
|
avatar={partner?.avatar ?? null}
|
|
nickname={partner?.nickname ?? '?'}
|
|
size="md"
|
|
/>
|
|
</View>
|
|
<View style={{ flexShrink: 1 }}>
|
|
<Text style={styles.headerName} numberOfLines={1}>
|
|
{partner?.nickname ?? '…'}
|
|
</Text>
|
|
{userId && <ChatHeaderStatus userId={userId} typing={partnerTyping} />}
|
|
</View>
|
|
</TouchableOpacity>
|
|
|
|
{/* Info-Button */}
|
|
<TouchableOpacity
|
|
style={styles.infoBtn}
|
|
hitSlop={8}
|
|
activeOpacity={0.7}
|
|
onPress={() => setInfoSheetOpen(true)}
|
|
>
|
|
<Ionicons name="information-circle-outline" size={24} color={colors.text} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
<View style={{ flex: 1, backgroundColor: chatBg }}>
|
|
{chatBgStyle === 'pattern' && <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={flatListRef}
|
|
data={messages}
|
|
style={{ flex: 1 }}
|
|
renderItem={({ item, index }) => (
|
|
<ChatBubble
|
|
msg={item}
|
|
isDM
|
|
isFirstInGroup={!sameAuthor(messages[index - 1], item)}
|
|
isLastInGroup={!sameAuthor(item, messages[index + 1])}
|
|
onReply={startReply}
|
|
onLike={toggleLike}
|
|
onReact={toggleReaction}
|
|
onDelete={deleteMessage}
|
|
onOpenImage={openLightbox}
|
|
/>
|
|
)}
|
|
keyExtractor={(m) => m.id}
|
|
contentContainerStyle={{
|
|
paddingHorizontal: 0,
|
|
paddingTop: 12,
|
|
// Tastatur offen: Input-Bar floatet (per transform) über der Tastatur,
|
|
// der Viewport schrumpft NICHT → Clearance = keyboardHeight + 4 (Gap).
|
|
// Tastatur zu: die KeyboardStickyView hat offset.closed = -insets.bottom,
|
|
// schiebt die Bar also um insets.bottom NACH OBEN über den Content →
|
|
// diese Überlappung muss als Clearance abgezogen werden, sonst wird die
|
|
// letzte Nachricht halb verdeckt. insets.bottom + 4 hält denselben
|
|
// knappen Gap wie im Keyboard-offen-State.
|
|
paddingBottom: keyboardVisible ? keyboardHeight + 4 : insets.bottom + 4,
|
|
}}
|
|
showsVerticalScrollIndicator={false}
|
|
keyboardDismissMode="interactive"
|
|
keyboardShouldPersistTaps="handled"
|
|
onContentSizeChange={() => scrollToBottom(false)}
|
|
onLayout={() => scrollToBottom(false)}
|
|
/>
|
|
)}
|
|
</View>
|
|
|
|
<KeyboardStickyView
|
|
offset={{ closed: -insets.bottom, opened: 0 }}
|
|
style={{ backgroundColor: colors.bg }}
|
|
>
|
|
<View
|
|
onLayout={(e) => {
|
|
const h = e.nativeEvent.layout.height;
|
|
if (Math.abs(h - inputBarHeight) > 1) setInputBarHeight(h);
|
|
}}
|
|
style={[
|
|
styles.inputBar,
|
|
{
|
|
paddingBottom: 8,
|
|
backgroundColor: colors.bg,
|
|
borderTopColor: colors.border,
|
|
},
|
|
]}
|
|
>
|
|
{replyTo && (
|
|
<View style={[styles.replyBar, { backgroundColor: colors.surface }]}>
|
|
<Ionicons name="arrow-undo" size={14} color="#007AFF" style={{ marginRight: 6 }} />
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={styles.replyName} numberOfLines={1}>
|
|
{t('chat.reply_to')} {replyTo.nickname}
|
|
</Text>
|
|
<Text style={[styles.replyContent, { color: colors.textMuted }]} numberOfLines={1}>
|
|
{replyTo.content || '…'}
|
|
</Text>
|
|
</View>
|
|
<TouchableOpacity hitSlop={10} onPress={() => setReplyTo(null)} activeOpacity={0.7}>
|
|
<Ionicons name="close" size={16} color={colors.textMuted} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
)}
|
|
{attachment && (
|
|
<View style={[styles.attachBar, { backgroundColor: colors.surface }]}>
|
|
<Image source={{ uri: attachment.uri }} style={styles.attachImg} contentFit="cover" />
|
|
<Text style={[styles.attachName, { color: colors.text }]} numberOfLines={1}>
|
|
{attachment.name}
|
|
</Text>
|
|
<TouchableOpacity hitSlop={10} onPress={() => setAttachment(null)} activeOpacity={0.7}>
|
|
<Ionicons name="close" size={16} color={colors.textMuted} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
)}
|
|
{isVoiceRecording ? (
|
|
<View style={styles.voiceRecBarWrap}>
|
|
<VoiceRecordingBar
|
|
duration={voiceDuration}
|
|
level={voiceLevel}
|
|
trashFlash={voiceTrashFlash}
|
|
onCancel={cancelVoiceRecording}
|
|
onSend={stopAndSendVoice}
|
|
sendIcon="arrow-up"
|
|
accentColor="#007AFF"
|
|
/>
|
|
</View>
|
|
) : (
|
|
<View style={styles.inputRow}>
|
|
<TouchableOpacity
|
|
activeOpacity={0.7}
|
|
style={[styles.addBtn, { backgroundColor: colors.surfaceElevated }]}
|
|
onPress={pickImage}
|
|
disabled={uploading || sending}
|
|
>
|
|
<Ionicons name="add" size={22} color={colors.textMuted} />
|
|
</TouchableOpacity>
|
|
<TextInput
|
|
style={[styles.textInput, { backgroundColor: colors.surfaceElevated, color: colors.text }]}
|
|
placeholder={t('chat.placeholder')}
|
|
placeholderTextColor={colors.textMuted}
|
|
value={inputText}
|
|
onChangeText={(v) => {
|
|
setInputText(v);
|
|
if (v.trim().length > 0) sendTyping();
|
|
else sendStopTyping();
|
|
}}
|
|
multiline
|
|
maxLength={2000}
|
|
returnKeyType="send"
|
|
onSubmitEditing={handleSend}
|
|
editable={!sending && !uploading}
|
|
/>
|
|
{(inputText.trim().length > 0 || attachment) ? (
|
|
<TouchableOpacity
|
|
style={[styles.sendBtn, (sending || uploading) && styles.sendBtnDisabled]}
|
|
onPress={handleSend}
|
|
disabled={sending || uploading}
|
|
activeOpacity={0.7}
|
|
>
|
|
{sending || uploading ? (
|
|
<ActivityIndicator size="small" color="#fff" />
|
|
) : (
|
|
<Ionicons name="send" size={16} color="#fff" />
|
|
)}
|
|
</TouchableOpacity>
|
|
) : (
|
|
<TouchableOpacity
|
|
style={[styles.addBtn, { backgroundColor: colors.surfaceElevated }]}
|
|
onPress={startVoiceRecording}
|
|
disabled={sending}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Ionicons name="mic" size={20} color={colors.textMuted} />
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
)}
|
|
</View>
|
|
</KeyboardStickyView>
|
|
|
|
{/* ── Info-Sheet ─────────────────────────────────────────────── */}
|
|
<DmInfoSheet
|
|
visible={infoSheetOpen}
|
|
onClose={() => setInfoSheetOpen(false)}
|
|
partner={partner}
|
|
messages={messages}
|
|
onImagePress={openLightbox}
|
|
onViewProfile={() => {
|
|
setInfoSheetOpen(false);
|
|
setTimeout(() => userId && router.push(`/profile/${userId}` as any), 250);
|
|
}}
|
|
colors={colors}
|
|
t={t}
|
|
/>
|
|
|
|
{/* ── Lightbox ───────────────────────────────────────────────── */}
|
|
<Modal visible={!!lightboxUri} transparent animationType="fade" onRequestClose={closeLightbox}>
|
|
<TouchableOpacity
|
|
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.92)', alignItems: 'center', justifyContent: 'center' }}
|
|
activeOpacity={1}
|
|
onPress={closeLightbox}
|
|
>
|
|
{lightboxUri && (
|
|
<Image
|
|
source={{ uri: lightboxUri }}
|
|
onLoad={(e) => {
|
|
const s = e.source;
|
|
if (s?.width && s?.height) setLightboxRatio(s.width / s.height);
|
|
}}
|
|
style={{ width: lbW, height: lbH, borderRadius: 16 }}
|
|
contentFit="contain"
|
|
cachePolicy="memory-disk"
|
|
/>
|
|
)}
|
|
<TouchableOpacity
|
|
style={{ position: 'absolute', top: 54, right: 20, padding: 8 }}
|
|
onPress={closeLightbox}
|
|
activeOpacity={0.7}
|
|
>
|
|
<Ionicons name="close-circle" size={32} color="#fff" />
|
|
</TouchableOpacity>
|
|
{/* Sichern */}
|
|
<TouchableOpacity
|
|
style={{
|
|
position: 'absolute',
|
|
bottom: 54,
|
|
alignSelf: 'center',
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
paddingHorizontal: 20,
|
|
paddingVertical: 12,
|
|
borderRadius: 24,
|
|
backgroundColor: 'rgba(255,255,255,0.16)',
|
|
}}
|
|
onPress={() => lightboxUri && saveImage(lightboxUri)}
|
|
disabled={savingImage}
|
|
activeOpacity={0.7}
|
|
>
|
|
{savingImage ? (
|
|
<ActivityIndicator size="small" color="#fff" />
|
|
) : (
|
|
<Ionicons name="download-outline" size={20} color="#fff" />
|
|
)}
|
|
<Text style={{ color: '#fff', fontSize: 15, fontFamily: 'Nunito_600SemiBold' }}>
|
|
{t('chat.save')}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</TouchableOpacity>
|
|
</Modal>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
// ─── DmInfoSheet ─────────────────────────────────────────────────────────────
|
|
|
|
const MEDIA_COL = 3;
|
|
const MEDIA_GAP = 2;
|
|
const MEDIA_SIZE = (Dimensions.get('window').width - MEDIA_GAP * (MEDIA_COL + 1)) / MEDIA_COL;
|
|
|
|
function DmInfoSheet({
|
|
visible,
|
|
onClose,
|
|
partner,
|
|
messages,
|
|
onImagePress,
|
|
onViewProfile,
|
|
colors,
|
|
t,
|
|
}: {
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
partner: { id: string; nickname: string; avatar?: string | null } | null;
|
|
messages: ChatMsg[];
|
|
onImagePress: (uri: string) => void;
|
|
onViewProfile: () => void;
|
|
colors: ReturnType<typeof useColors>;
|
|
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
|
|
}) {
|
|
const sharedMedia = messages.filter(
|
|
(m) => m.attachmentType === 'image' && m.attachmentUrl,
|
|
);
|
|
|
|
return (
|
|
<FormSheet
|
|
visible={visible}
|
|
onClose={onClose}
|
|
title={t('chat.info')}
|
|
initialHeightPct={0.85}
|
|
dismissOnBackdrop
|
|
>
|
|
<ScrollView contentContainerStyle={{ paddingBottom: 40 }}>
|
|
{/* Partner-Karte */}
|
|
<TouchableOpacity
|
|
activeOpacity={0.7}
|
|
onPress={onViewProfile}
|
|
style={{ flexDirection: 'row', alignItems: 'center', padding: 16, gap: 14 }}
|
|
>
|
|
{partner?.avatar ? (
|
|
<Image
|
|
source={{ uri: partner.avatar }}
|
|
style={{ width: 56, height: 56, borderRadius: 28 }}
|
|
contentFit="cover"
|
|
cachePolicy="memory-disk"
|
|
/>
|
|
) : (
|
|
<View style={{
|
|
width: 56, height: 56, borderRadius: 28,
|
|
backgroundColor: colors.brandOrange + '30',
|
|
alignItems: 'center', justifyContent: 'center',
|
|
}}>
|
|
<Text style={{ fontSize: 20, fontFamily: 'Nunito_700Bold', color: colors.brandOrange }}>
|
|
{partner?.nickname?.[0]?.toUpperCase() ?? '?'}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={{ fontSize: 17, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
|
{partner?.nickname ?? '…'}
|
|
</Text>
|
|
<Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: colors.textMuted, marginTop: 2 }}>
|
|
{t('dm.view_profile')}
|
|
</Text>
|
|
</View>
|
|
<Ionicons name="chevron-forward" size={18} color={colors.textMuted} />
|
|
</TouchableOpacity>
|
|
|
|
<View style={{ height: StyleSheet.hairlineWidth, backgroundColor: colors.border, marginHorizontal: 16 }} />
|
|
|
|
{/* Geteilte Medien */}
|
|
<View style={{ paddingHorizontal: 16, paddingTop: 20, paddingBottom: 12, flexDirection: 'row', alignItems: 'baseline', gap: 6 }}>
|
|
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
|
{t('dm.shared_media')}
|
|
</Text>
|
|
{sharedMedia.length > 0 && (
|
|
<Text style={{ fontSize: 13, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
|
|
{sharedMedia.length}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
|
|
{sharedMedia.length === 0 ? (
|
|
<View style={{ alignItems: 'center', paddingVertical: 32 }}>
|
|
<Ionicons name="images-outline" size={40} color={colors.textMuted} />
|
|
<Text style={{ marginTop: 10, fontSize: 13, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>
|
|
{t('dm.no_shared_media')}
|
|
</Text>
|
|
</View>
|
|
) : (
|
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: MEDIA_GAP, paddingHorizontal: MEDIA_GAP }}>
|
|
{[...sharedMedia].reverse().map((m) => (
|
|
<TouchableOpacity
|
|
key={m.id}
|
|
activeOpacity={0.8}
|
|
onPress={() => onImagePress(m.attachmentUrl!)}
|
|
>
|
|
<Image
|
|
source={{ uri: m.attachmentUrl! }}
|
|
style={{ width: MEDIA_SIZE, height: MEDIA_SIZE }}
|
|
contentFit="cover"
|
|
cachePolicy="memory-disk"
|
|
/>
|
|
</TouchableOpacity>
|
|
))}
|
|
</View>
|
|
)}
|
|
</ScrollView>
|
|
</FormSheet>
|
|
);
|
|
}
|
|
|
|
function makeStyles(colors: ReturnType<typeof useColors>) {
|
|
return StyleSheet.create({
|
|
container: { flex: 1, backgroundColor: colors.bg },
|
|
header: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 10,
|
|
},
|
|
backBtn: {
|
|
padding: 8,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
headerCenter: {
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
marginLeft: 8,
|
|
},
|
|
infoBtn: {
|
|
padding: 8,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
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,
|
|
},
|
|
inputBar: {
|
|
borderTopWidth: StyleSheet.hairlineWidth,
|
|
},
|
|
replyBar: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 8,
|
|
borderLeftWidth: 3,
|
|
borderLeftColor: '#007AFF',
|
|
marginHorizontal: 8,
|
|
marginTop: 6,
|
|
borderRadius: 8,
|
|
},
|
|
replyName: {
|
|
fontSize: 11,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: '#007AFF',
|
|
},
|
|
replyContent: {
|
|
fontSize: 11,
|
|
fontFamily: 'Nunito_400Regular',
|
|
marginTop: 1,
|
|
},
|
|
attachBar: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 6,
|
|
marginHorizontal: 8,
|
|
marginTop: 6,
|
|
borderRadius: 8,
|
|
},
|
|
attachImg: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 6,
|
|
marginRight: 8,
|
|
},
|
|
attachName: {
|
|
flex: 1,
|
|
fontSize: 12,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
},
|
|
inputRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'flex-end',
|
|
gap: 8,
|
|
paddingHorizontal: 12,
|
|
paddingTop: 8,
|
|
},
|
|
addBtn: {
|
|
width: 44,
|
|
height: 44,
|
|
borderRadius: 22,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
textInput: {
|
|
flex: 1,
|
|
borderRadius: 22,
|
|
paddingVertical: 9,
|
|
paddingHorizontal: 16,
|
|
fontSize: 15,
|
|
fontFamily: 'Nunito_400Regular',
|
|
maxHeight: 120,
|
|
},
|
|
sendBtn: {
|
|
width: 44,
|
|
height: 44,
|
|
borderRadius: 22,
|
|
backgroundColor: '#007AFF',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
sendBtnDisabled: {
|
|
opacity: 0.4,
|
|
},
|
|
voiceRecBarWrap: {
|
|
flexDirection: 'row',
|
|
marginHorizontal: 12,
|
|
marginTop: 8,
|
|
marginBottom: 4,
|
|
},
|
|
});
|
|
}
|