Files
Sauci/apps/mobile/src/features/chat/ChatScreen.tsx
T
jjbruntonandClaude Fable 5 6bc4e31719 chore(mobile): upgrade to Expo SDK 56
Bump expo, react, react-native, and all expo-* / react-native-* peers to
the SDK 56 line via expo install --fix, adding expo-splash-screen and
expo-system-ui for config plugins the SDK split out of core. Bump
@bacons/apple-targets, react-native-view-shot, and other native modules
to versions compatible with React Native 0.85 and the new architecture.

Remove @react-navigation/* direct dependencies and route their hooks
through expo-router, which is no longer compatible with react-navigation
as of SDK 56. Fix RN 0.85's removed StyleSheet.absoluteFillObject (use
absoluteFill) and react-native-view-shot 5.x's ViewShotRef type across
call sites. Downgrade eslint-plugin-react-hooks v7's new React Compiler
era rules to warnings pending a dedicated refactor, and repair Jest
fallout from the jest-expo/reanimated/worklets/expo-media-library bumps.

Regenerate the committed ios/ and android/ native projects with
`expo prebuild --clean` (required for @bacons/apple-targets 5.x) against
the production release channel, and document that requirement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 11:11:37 +01:00

443 lines
16 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from "react";
import { View, StyleSheet, KeyboardAvoidingView, Platform, Alert } from "react-native";
import { useLocalSearchParams, useRouter, useFocusEffect, useIsFocused } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { LinearGradient } from "expo-linear-gradient";
import Animated, { FadeInDown } from "react-native-reanimated";
import { InputBar, ChatHeader, ChatMessages, ReportMessageSheet, ChatSettingsSheet, FullScreenImageModal, FullScreenVideoModal } from "./components";
import { useMediaUpload } from "./hooks/useMediaUpload";
import { useMessageActions } from "./hooks/useMessageActions";
import type { ReportReason, Match } from "./types";
import { appDataApi } from "../../lib/appDataApi";
import { chatApi } from "../../lib/chatApi";
import { ApiError } from "../../lib/apiClient";
import { useAuthStore, useMessageStore, useMatchStore } from "../../store";
import { useAmbientOrbAnimation, useMediaPicker, useTypingIndicator, useMessageSubscription, useMediaSaver } from "../../hooks";
import { Database } from "../../types/supabase";
import { GradientBackground } from "../../components/ui";
import { colors, gradients, radius, spacing, typography } from "../../theme";
import { Events } from "../../lib/analytics";
type Message = Database["public"]["Tables"]["messages"]["Row"];
// Premium color palette for Chat
const ROSE_RGBA = 'rgba(232, 164, 174, ';
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
export const ChatScreen: React.FC = () => {
const { id } = useLocalSearchParams();
const matchId = id as string;
const router = useRouter();
const insets = useSafeAreaInsets();
const { user, partner } = useAuthStore();
const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore();
const { archiveMatch, unarchiveMatch, isMatchArchived } = useMatchStore();
const [match, setMatch] = useState<Match | null>(null);
const [inputText, setInputText] = useState("");
const [showSettingsSheet, setShowSettingsSheet] = useState(false);
const { uploading, uploadStatus, uploadMedia } = useMediaUpload(matchId, user?.id);
const { saveMedia, saving: mediaSaving } = useMediaSaver();
const [fullScreenImage, setFullScreenImage] = useState<string | null>(null);
const [fullScreenImageLoading, setFullScreenImageLoading] = useState(true);
const [fullScreenVideo, setFullScreenVideo] = useState<string | null>(null);
const [reportingMessage, setReportingMessage] = useState<Message | null>(null);
// Ambient orb breathing animations
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation({
opacityRange1: [0.2, 0.4],
opacityRange2: [0.15, 0.35],
});
// Media picker hook
const { pickMedia, takePhoto, recordVideo } = useMediaPicker({
imageQuality: 0.7,
libraryVideoMaxDuration: 300, // 5 min max
cameraVideoMaxDuration: 60, // 1 min for camera recording
});
const isFocused = useIsFocused();
// Each hook needs a value the other produces: the message poll reports typing
// state, and an arriving message clears the indicator. The ref breaks the cycle.
const clearTypingRef = useRef<() => void>(() => undefined);
const handleNewMessage = useCallback(() => clearTypingRef.current(), []);
// Message subscription hook
const { messages, setMessages, partnerTyping: syncedTyping } = useMessageSubscription({
matchId,
userId: user?.id,
onNewMessage: handleNewMessage,
isFocused,
});
// Typing indicator hook, reading the state the message poll already fetched
const { partnerTyping, sendTypingEvent, clearTypingIndicator } = useTypingIndicator({
channelName: `chat:${matchId}`,
matchId,
userId: user?.id,
isFocused,
externalTyping: syncedTyping,
});
useEffect(() => {
clearTypingRef.current = clearTypingIndicator;
}, [clearTypingIndicator]);
// Message actions hook for deletion and reporting
const { showDeleteOptions, deleteForSelf } = useMessageActions({
userId: user?.id,
onReport: (message) => setReportingMessage(message),
});
// Memoized navigation callbacks
const handleBack = useCallback(() => {
router.push('/(app)/matches');
}, [router]);
const handleOpenSettings = useCallback(() => {
setShowSettingsSheet(true);
}, []);
const handleCloseSettings = useCallback(() => {
setShowSettingsSheet(false);
}, []);
// Memoized modal callbacks
const handleCloseImage = useCallback(() => {
setFullScreenImage(null);
}, []);
const handleSaveImage = useCallback(() => {
if (fullScreenImage) {
saveMedia(fullScreenImage, 'image');
}
}, [fullScreenImage, saveMedia]);
const handleImageLoadStart = useCallback(() => {
setFullScreenImageLoading(true);
}, []);
const handleImageLoadEnd = useCallback(() => {
setFullScreenImageLoading(false);
}, []);
const handleCloseVideo = useCallback(() => {
setFullScreenVideo(null);
}, []);
const handleSaveVideo = useCallback(() => {
if (fullScreenVideo) {
saveMedia(fullScreenVideo, 'video');
}
}, [fullScreenVideo, saveMedia]);
const handleCloseReport = useCallback(() => {
setReportingMessage(null);
}, []);
// Memoized archive/unarchive callbacks
const handleArchive = useCallback(async () => {
await archiveMatch(matchId);
router.push('/(app)/matches');
}, [archiveMatch, matchId, router]);
const handleUnarchive = useCallback(async () => {
await unarchiveMatch(matchId);
}, [unarchiveMatch, matchId]);
// Handle long press on message to show options
const handleMessageLongPress = useCallback((message: Message, isMe: boolean) => {
// Don't show options for already deleted messages
if (message.deleted_at) return;
showDeleteOptions(message, isMe);
}, [showDeleteOptions]);
// Handle report submission
const handleSubmitReport = useCallback(async (reason: ReportReason) => {
if (!reportingMessage || !user?.id) return;
try {
await chatApi.report(reportingMessage.id, reason);
// Also delete the message for the reporter (so they don't see it anymore)
await deleteForSelf(reportingMessage.id);
// Remove from local state
setMessages(prev => prev.filter(m => m.id !== reportingMessage.id));
Alert.alert('Report Submitted', 'Thank you for your report. We will review it shortly.');
setReportingMessage(null);
} catch (err) {
console.error('Report error:', err);
if (err instanceof ApiError && err.status === 409) {
Alert.alert('Already Reported', 'You have already reported this message.');
} else {
Alert.alert('Error', 'Failed to submit report');
}
setReportingMessage(null);
}
}, [reportingMessage, user?.id, deleteForSelf, setMessages]);
// Track active chat to prevent notifications for current chat
useFocusEffect(
useCallback(() => {
if (matchId) {
setActiveMatchId(matchId);
markMatchMessagesAsRead(matchId);
Events.matchViewed();
}
return () => setActiveMatchId(null);
}, [matchId, setActiveMatchId, markMatchMessagesAsRead])
);
useEffect(() => {
if (!matchId) return;
const fetchMatch = async () => {
setMatch(null); // Clear previous match data immediately
try {
const { match } = await appDataApi.matchContext(matchId);
setMatch(match);
} catch (error) {
console.error("Error fetching match context:", error);
}
};
fetchMatch();
}, [matchId]);
const handleTyping = useCallback((text: string) => {
setInputText(text);
if (text.length > 0) {
sendTypingEvent();
}
}, [sendTypingEvent]);
const handleSend = useCallback(async () => {
if (!inputText.trim()) return;
if (!user) return;
const content = inputText.trim();
setInputText("");
try {
const { message } = await chatApi.sendText(matchId, content);
setMessages(previous => previous.some(item => item.id === message.id) ? previous : [message, ...previous]);
Events.messageSent();
} catch (err) {
console.error("Error sending message:", err);
Alert.alert("Delivery Failed", "Couldn't send message. Please check your connection.");
setInputText(content);
}
}, [inputText, user, matchId, setMessages]);
const handlePickMedia = useCallback(async () => {
const result = await pickMedia();
if (result) {
uploadMedia(result.uri, result.mediaType);
}
}, [pickMedia, uploadMedia]);
const handleTakePhoto = useCallback(async () => {
const result = await takePhoto();
if (result) {
uploadMedia(result.uri, 'image');
}
}, [takePhoto, uploadMedia]);
const handleRecordVideo = useCallback(async () => {
const result = await recordVideo();
if (result) {
uploadMedia(result.uri, 'video');
}
}, [recordVideo, uploadMedia]);
const revealMessage = useCallback(async (messageId: string) => {
const message = messages.find(m => m.id === messageId);
const now = new Date();
// Set expiration 30 days from now for videos only
const expiresAt = message?.media_type === 'video'
? new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString()
: null;
setMessages(prev => prev.map(m =>
m.id === messageId ? {
...m,
media_viewed_at: now.toISOString(),
media_expires_at: expiresAt
} : m
));
try {
const viewed = await appDataApi.markMediaViewed(messageId, expiresAt);
setMessages(prev => prev.map(m => m.id === messageId ? {
...m,
media_viewed_at: viewed.media_viewed_at,
media_expires_at: viewed.media_expires_at,
} : m));
} catch (error) {
console.error("Error marking media viewed:", error);
}
}, [messages, setMessages]);
return (
<GradientBackground>
{/* Ambient Orbs - Commented out for flat look
<Animated.View style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]} pointerEvents="none">
<LinearGradient
colors={[colors.premium.goldGlow, 'transparent']}
style={styles.orbGradient}
start={{ x: 0.5, y: 0.5 }}
end={{ x: 1, y: 1 }}
/>
</Animated.View>
<Animated.View style={[styles.ambientOrb, styles.orbBottomLeft, orbStyle2]} pointerEvents="none">
<LinearGradient
colors={[`${ROSE_RGBA}0.2)`, 'transparent']}
style={styles.orbGradient}
start={{ x: 0.5, y: 0.5 }}
end={{ x: 0, y: 0 }}
/>
</Animated.View>
*/}
<KeyboardAvoidingView
style={styles.keyboardAvoiding}
behavior={Platform.OS === "ios" ? "padding" : undefined}
keyboardVerticalOffset={0}
>
<ChatHeader
partner={partner}
insets={insets}
onBack={handleBack}
onSettingsPress={handleOpenSettings}
/>
<ChatMessages
messages={messages}
userId={user?.id}
userName={user?.name ?? undefined}
partnerName={partner?.name ?? undefined}
match={match}
uploadStatus={uploadStatus}
partnerTyping={partnerTyping}
onImagePress={setFullScreenImage}
onVideoFullScreen={setFullScreenVideo}
revealMessage={revealMessage}
onMessageLongPress={handleMessageLongPress}
/>
{/* Premium Input Bar */}
<Animated.View
entering={FadeInDown.duration(300)}
style={[styles.inputWrapper, { paddingBottom: insets.bottom || spacing.sm }]}
>
{/* Flat background */}
<View style={[StyleSheet.absoluteFill, { backgroundColor: colors.backgroundLight, borderTopWidth: 1, borderTopColor: colors.border }]} />
{/* Gradient background - removed
<LinearGradient
colors={['rgba(22, 33, 62, 0.9)', 'rgba(13, 13, 26, 0.95)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
*/}
{/* Top border glow - removed
<View style={styles.inputWrapperBorder} />
*/}
<InputBar
inputText={inputText}
uploading={uploading}
onChangeText={handleTyping}
onSend={handleSend}
onPickMedia={handlePickMedia}
onTakePhoto={handleTakePhoto}
onRecordVideo={handleRecordVideo}
/>
</Animated.View>
</KeyboardAvoidingView>
{/* Full Screen Image Modal */}
<FullScreenImageModal
uri={fullScreenImage}
visible={!!fullScreenImage}
loading={fullScreenImageLoading}
saving={mediaSaving}
onClose={handleCloseImage}
onSave={handleSaveImage}
onLoadStart={handleImageLoadStart}
onLoadEnd={handleImageLoadEnd}
/>
{/* Full Screen Video Modal */}
<FullScreenVideoModal
uri={fullScreenVideo}
visible={!!fullScreenVideo}
saving={mediaSaving}
onClose={handleCloseVideo}
onSave={handleSaveVideo}
/>
{/* Report Message Sheet */}
<ReportMessageSheet
visible={reportingMessage !== null}
onClose={handleCloseReport}
onSubmit={handleSubmitReport}
/>
{/* Chat Settings Sheet */}
<ChatSettingsSheet
visible={showSettingsSheet}
onClose={handleCloseSettings}
isArchived={isMatchArchived(matchId)}
onArchive={handleArchive}
onUnarchive={handleUnarchive}
/>
</GradientBackground>
);
};
const styles = StyleSheet.create({
keyboardAvoiding: {
flex: 1,
},
// Ambient orbs
ambientOrb: {
position: 'absolute',
width: 280,
height: 280,
borderRadius: 140,
},
orbTopRight: {
top: 80,
right: -60,
},
orbBottomLeft: {
bottom: 200,
left: -60,
},
orbGradient: {
width: '100%',
height: '100%',
borderRadius: 140,
},
inputWrapper: {
overflow: 'hidden',
},
inputWrapperBorder: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 1,
backgroundColor: `${ACCENT_RGBA}0.15)`,
},
});