mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
Update
This commit is contained in:
1 parent
6ff5bc62f2
commit
adab288a3a
204 files changed
+11334
-14295
No files matched your search
+449
-131
@@ -1,18 +1,19 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { View, Text, StyleSheet, FlatList, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform, Image, ActivityIndicator, Alert } from "react-native";
|
||||
import { useLocalSearchParams, useRouter, Stack } from "expo-router";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { View, Text, StyleSheet, FlatList, TextInput, TouchableOpacity, KeyboardAvoidingView, Platform, Image, ActivityIndicator, Alert, Modal, Dimensions } from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
|
||||
import { supabase } from "../../../src/lib/supabase";
|
||||
import { useAuthStore } from "../../../src/store";
|
||||
import { useAuthStore, useMessageStore } from "../../../src/store";
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { decode } from 'base64-arraybuffer';
|
||||
import { Database } from "../../../src/types/supabase";
|
||||
import { GradientBackground, GlassCard } from "../../../src/components/ui";
|
||||
import { colors, gradients, spacing, radius, typography, blur, shadows } from "../../../src/theme";
|
||||
import { GradientBackground } from "../../../src/components/ui";
|
||||
import { colors, gradients, spacing, radius, typography } from "../../../src/theme";
|
||||
|
||||
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
||||
|
||||
@@ -20,14 +21,34 @@ export default function ChatScreen() {
|
||||
const { id } = useLocalSearchParams();
|
||||
const matchId = id as string;
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { user } = useAuthStore();
|
||||
const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [match, setMatch] = useState<any>(null);
|
||||
const [inputText, setInputText] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [partnerTyping, setPartnerTyping] = useState(false);
|
||||
const [fullScreenImage, setFullScreenImage] = useState<string | null>(null);
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isFocusedRef = useRef(false);
|
||||
|
||||
// Track active chat to prevent notifications for current chat
|
||||
// Use useFocusEffect because screens don't unmount on navigation
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
isFocusedRef.current = true;
|
||||
if (matchId) {
|
||||
setActiveMatchId(matchId);
|
||||
markMatchMessagesAsRead(matchId);
|
||||
}
|
||||
return () => {
|
||||
isFocusedRef.current = false;
|
||||
setActiveMatchId(null);
|
||||
};
|
||||
}, [matchId, setActiveMatchId, markMatchMessagesAsRead])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!matchId) return;
|
||||
@@ -61,18 +82,32 @@ export default function ChatScreen() {
|
||||
if (data) {
|
||||
setMessages(data);
|
||||
|
||||
const unreadIds = data
|
||||
.filter(m => m.user_id !== user?.id && !m.read_at)
|
||||
.map(m => m.id);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Get partner messages that need to be marked as delivered or read
|
||||
const partnerMessages = data.filter(m => m.user_id !== user?.id);
|
||||
const undeliveredIds = partnerMessages.filter(m => !m.delivered_at).map(m => m.id);
|
||||
const unreadIds = partnerMessages.filter(m => !m.read_at).map(m => m.id);
|
||||
|
||||
// Mark as both delivered and read (since user is viewing the chat)
|
||||
if (unreadIds.length > 0) {
|
||||
await supabase
|
||||
.from("messages")
|
||||
.update({ read_at: new Date().toISOString() })
|
||||
.update({ delivered_at: now, read_at: now })
|
||||
.in("id", unreadIds);
|
||||
|
||||
setMessages(prev => prev.map(m =>
|
||||
unreadIds.includes(m.id) ? { ...m, read_at: new Date().toISOString() } : m
|
||||
unreadIds.includes(m.id) ? { ...m, delivered_at: now, read_at: now } : m
|
||||
));
|
||||
} else if (undeliveredIds.length > 0) {
|
||||
// Only mark as delivered if already read but not delivered (edge case)
|
||||
await supabase
|
||||
.from("messages")
|
||||
.update({ delivered_at: now })
|
||||
.in("id", undeliveredIds);
|
||||
|
||||
setMessages(prev => prev.map(m =>
|
||||
undeliveredIds.includes(m.id) ? { ...m, delivered_at: now } : m
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -92,13 +127,16 @@ export default function ChatScreen() {
|
||||
},
|
||||
async (payload) => {
|
||||
const newMessage = payload.new as Message;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (newMessage.user_id !== user?.id) {
|
||||
if (newMessage.user_id !== user?.id && isFocusedRef.current) {
|
||||
// Mark as both delivered and read (only if screen is focused)
|
||||
await supabase
|
||||
.from("messages")
|
||||
.update({ read_at: new Date().toISOString() })
|
||||
.update({ delivered_at: now, read_at: now })
|
||||
.eq("id", newMessage.id);
|
||||
newMessage.read_at = new Date().toISOString();
|
||||
newMessage.delivered_at = now;
|
||||
newMessage.read_at = now;
|
||||
}
|
||||
|
||||
setMessages((prev) => [newMessage, ...prev]);
|
||||
@@ -220,14 +258,11 @@ export default function ChatScreen() {
|
||||
|
||||
if (uploadError) throw uploadError;
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from("chat-media")
|
||||
.getPublicUrl(fileName);
|
||||
|
||||
// Store just the path, not the full URL (bucket is private)
|
||||
await supabase.from("messages").insert({
|
||||
match_id: matchId,
|
||||
user_id: user.id,
|
||||
media_path: publicUrl,
|
||||
media_path: fileName,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
@@ -263,60 +298,88 @@ export default function ChatScreen() {
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<MessageContent item={item} isMe={isMe} revealMessage={revealMessage} />
|
||||
<MessageContent item={item} isMe={isMe} revealMessage={revealMessage} onImagePress={setFullScreenImage} />
|
||||
</LinearGradient>
|
||||
) : (
|
||||
<View style={[styles.bubble, styles.theirBubble]}>
|
||||
<MessageContent item={item} isMe={isMe} revealMessage={revealMessage} />
|
||||
<MessageContent item={item} isMe={isMe} revealMessage={revealMessage} onImagePress={setFullScreenImage} />
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const isYesYes = match?.match_type === "yes_yes";
|
||||
|
||||
return (
|
||||
<GradientBackground>
|
||||
<Stack.Screen options={{
|
||||
title: "Chat",
|
||||
headerStyle: { backgroundColor: colors.backgroundLight },
|
||||
headerTintColor: colors.text,
|
||||
headerShadowVisible: false,
|
||||
}} />
|
||||
|
||||
<KeyboardAvoidingView
|
||||
style={styles.keyboardAvoiding}
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
keyboardVerticalOffset={Platform.OS === "ios" ? 90 : 0}
|
||||
keyboardVerticalOffset={0}
|
||||
>
|
||||
{/* Sticky top bar with question and answers */}
|
||||
{/* Custom Header */}
|
||||
<View style={[styles.header, { paddingTop: insets.top }]}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.back()}
|
||||
style={styles.backButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>Chat</Text>
|
||||
<View style={styles.headerSpacer} />
|
||||
</View>
|
||||
|
||||
{/* Question Header */}
|
||||
{match?.question && (
|
||||
<GlassCard style={styles.stickyTopBar} noPadding>
|
||||
<View style={styles.stickyContent}>
|
||||
<Text style={styles.stickyQuestionText} numberOfLines={2}>
|
||||
<View style={styles.questionHeader}>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.questionIconGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons
|
||||
name={isYesYes ? "heart" : "heart-half"}
|
||||
size={16}
|
||||
color={colors.text}
|
||||
/>
|
||||
</LinearGradient>
|
||||
<View style={styles.questionContent}>
|
||||
<Text style={styles.questionText} numberOfLines={2}>
|
||||
{match.question.text}
|
||||
{match.question.partner_text ? ` & ${match.question.partner_text}` : ""}
|
||||
{match.question.partner_text ? ` / ${match.question.partner_text}` : ""}
|
||||
</Text>
|
||||
{match.responses && match.responses.length > 0 && (
|
||||
<View style={styles.stickyAnswersContainer}>
|
||||
<View style={styles.answersRow}>
|
||||
{match.responses.map((response: any) => (
|
||||
<View key={response.user_id} style={styles.answerBadge}>
|
||||
<Text style={styles.stickyAnswerText}>
|
||||
{response.user_id === user?.id ? 'You' : (response.profiles?.name || 'Partner')}: {response.answer}
|
||||
<View key={response.user_id} style={styles.answerChip}>
|
||||
<Text style={styles.answerName}>
|
||||
{response.user_id === user?.id ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')}
|
||||
</Text>
|
||||
<View style={[
|
||||
styles.answerDot,
|
||||
response.answer === 'yes' && styles.answerDotYes,
|
||||
response.answer === 'maybe' && styles.answerDotMaybe
|
||||
]} />
|
||||
<Text style={styles.answerText}>{response.answer}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</GlassCard>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Messages List */}
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={messages}
|
||||
renderItem={renderMessage}
|
||||
keyExtractor={(item) => item.id}
|
||||
inverted
|
||||
style={styles.messageList}
|
||||
contentContainerStyle={styles.listContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
@@ -326,57 +389,150 @@ export default function ChatScreen() {
|
||||
entering={FadeIn.duration(200)}
|
||||
style={styles.typingContainer}
|
||||
>
|
||||
<Text style={styles.typingText}>Partner is typing...</Text>
|
||||
<View style={styles.typingBubble}>
|
||||
<View style={styles.typingDots}>
|
||||
<View style={[styles.typingDot, styles.typingDot1]} />
|
||||
<View style={[styles.typingDot, styles.typingDot2]} />
|
||||
<View style={[styles.typingDot, styles.typingDot3]} />
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
) : null
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<View style={styles.emptyChat}>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={styles.emptyChatIcon}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons name="chatbubbles-outline" size={24} color={colors.text} />
|
||||
</LinearGradient>
|
||||
<Text style={styles.emptyChatText}>Start the conversation!</Text>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Input bar with glass effect */}
|
||||
{Platform.OS === 'ios' ? (
|
||||
<BlurView intensity={blur.medium} tint="dark" style={styles.inputWrapper}>
|
||||
<InputBar
|
||||
inputText={inputText}
|
||||
uploading={uploading}
|
||||
onChangeText={handleTyping}
|
||||
onSend={handleSend}
|
||||
onPickImage={handlePickImage}
|
||||
/>
|
||||
</BlurView>
|
||||
) : (
|
||||
<View style={[styles.inputWrapper, styles.inputWrapperAndroid]}>
|
||||
<InputBar
|
||||
inputText={inputText}
|
||||
uploading={uploading}
|
||||
onChangeText={handleTyping}
|
||||
onSend={handleSend}
|
||||
onPickImage={handlePickImage}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{/* Input Bar */}
|
||||
<View style={[styles.inputWrapper, { paddingBottom: insets.bottom || spacing.sm }]}>
|
||||
<InputBar
|
||||
inputText={inputText}
|
||||
uploading={uploading}
|
||||
onChangeText={handleTyping}
|
||||
onSend={handleSend}
|
||||
onPickImage={handlePickImage}
|
||||
/>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* Full Screen Image Modal */}
|
||||
<Modal
|
||||
visible={!!fullScreenImage}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={() => setFullScreenImage(null)}
|
||||
>
|
||||
<View style={styles.fullScreenOverlay}>
|
||||
<TouchableOpacity
|
||||
style={styles.fullScreenCloseButton}
|
||||
onPress={() => setFullScreenImage(null)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons name="close" size={28} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
{fullScreenImage && (
|
||||
<Image
|
||||
source={{ uri: fullScreenImage }}
|
||||
style={styles.fullScreenImage}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</Modal>
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
|
||||
// Extract storage path from media_path (handles both old full URLs and new path-only format)
|
||||
function getStoragePath(mediaPath: string): string {
|
||||
if (mediaPath.startsWith('http')) {
|
||||
// Old format: extract path from full URL
|
||||
const match = mediaPath.match(/\/chat-media\/(.+)$/);
|
||||
return match ? match[1] : mediaPath;
|
||||
}
|
||||
return mediaPath;
|
||||
}
|
||||
|
||||
function MessageContent({
|
||||
item,
|
||||
isMe,
|
||||
revealMessage,
|
||||
onImagePress,
|
||||
}: {
|
||||
item: Message;
|
||||
isMe: boolean;
|
||||
revealMessage: (id: string) => void;
|
||||
onImagePress: (uri: string) => void;
|
||||
}) {
|
||||
const [signedUrl, setSignedUrl] = useState<string | null>(null);
|
||||
const [urlError, setUrlError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!item.media_path) return;
|
||||
|
||||
const fetchSignedUrl = async () => {
|
||||
const storagePath = getStoragePath(item.media_path!);
|
||||
const { data, error } = await supabase.storage
|
||||
.from('chat-media')
|
||||
.createSignedUrl(storagePath, 3600); // 1 hour expiry
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to get signed URL:', error);
|
||||
setUrlError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data?.signedUrl) {
|
||||
setSignedUrl(data.signedUrl);
|
||||
} else {
|
||||
setUrlError(true);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSignedUrl();
|
||||
}, [item.media_path]);
|
||||
|
||||
if (item.media_path) {
|
||||
const isViewed = !!item.media_viewed_at;
|
||||
const canOpenFullScreen = isMe || isViewed;
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Image
|
||||
source={{ uri: item.media_path }}
|
||||
style={styles.messageImage}
|
||||
blurRadius={(!isMe && !item.media_viewed_at) ? 20 : 0}
|
||||
/>
|
||||
{(!isMe && !item.media_viewed_at) && (
|
||||
<TouchableOpacity
|
||||
activeOpacity={canOpenFullScreen ? 0.8 : 1}
|
||||
onPress={() => canOpenFullScreen && signedUrl && onImagePress(signedUrl)}
|
||||
disabled={!canOpenFullScreen || !signedUrl}
|
||||
>
|
||||
{signedUrl ? (
|
||||
<Image
|
||||
source={{ uri: signedUrl }}
|
||||
style={styles.messageImage}
|
||||
blurRadius={(!isMe && !isViewed) ? 20 : 0}
|
||||
/>
|
||||
) : urlError ? (
|
||||
<View style={[styles.messageImage, { justifyContent: 'center', alignItems: 'center', backgroundColor: colors.surface }]}>
|
||||
<Ionicons name="image-outline" size={32} color={colors.textSecondary} />
|
||||
<Text style={{ color: colors.textSecondary, fontSize: 12, marginTop: 4 }}>Image unavailable</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[styles.messageImage, { justifyContent: 'center', alignItems: 'center' }]}>
|
||||
<ActivityIndicator color={colors.primary} />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{/* Reveal overlay for recipient */}
|
||||
{(!isMe && !isViewed) && (
|
||||
<TouchableOpacity
|
||||
style={styles.revealOverlay}
|
||||
activeOpacity={0.8}
|
||||
@@ -388,6 +544,13 @@ function MessageContent({
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{/* Viewed indicator for sender */}
|
||||
{isMe && isViewed && (
|
||||
<View style={styles.viewedBadge}>
|
||||
<Ionicons name="eye" size={12} color={colors.success} />
|
||||
<Text style={styles.viewedText}>Viewed</Text>
|
||||
</View>
|
||||
)}
|
||||
<MessageMeta item={item} isMe={isMe} />
|
||||
</View>
|
||||
);
|
||||
@@ -402,18 +565,35 @@ function MessageContent({
|
||||
}
|
||||
|
||||
function MessageMeta({ item, isMe }: { item: Message; isMe: boolean }) {
|
||||
// 3-state read receipts:
|
||||
// - Single tick (grey) = sent to server
|
||||
// - Double tick (grey) = delivered to partner's device
|
||||
// - Double tick (blue) = read by partner
|
||||
const isDelivered = !!item.delivered_at;
|
||||
const isRead = !!item.read_at;
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (isRead) {
|
||||
// Double tick blue - read
|
||||
return <Ionicons name="checkmark-done" size={16} color="#34B7F1" />;
|
||||
} else if (isDelivered) {
|
||||
// Double tick grey - delivered but not read
|
||||
return <Ionicons name="checkmark-done" size={16} color="rgba(255,255,255,0.5)" />;
|
||||
} else {
|
||||
// Single tick grey - sent but not delivered
|
||||
return <Ionicons name="checkmark" size={16} color="rgba(255,255,255,0.5)" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.metaContainer}>
|
||||
<Text style={styles.timestamp}>
|
||||
{new Date(item.created_at!).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</Text>
|
||||
{isMe && (
|
||||
<Ionicons
|
||||
name={item.read_at ? "checkmark-done" : "checkmark"}
|
||||
size={14}
|
||||
color={item.read_at ? colors.success : "rgba(255,255,255,0.6)"}
|
||||
style={styles.readIcon}
|
||||
/>
|
||||
<View style={styles.readStatus}>
|
||||
{getStatusIcon()}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
@@ -438,7 +618,7 @@ function InputBar({
|
||||
{uploading ? (
|
||||
<ActivityIndicator color={colors.primary} size="small" />
|
||||
) : (
|
||||
<Ionicons name="image-outline" size={24} color={colors.primary} />
|
||||
<Ionicons name="image-outline" size={22} color={colors.textSecondary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
@@ -456,13 +636,20 @@ function InputBar({
|
||||
<TouchableOpacity
|
||||
onPress={onSend}
|
||||
disabled={!inputText.trim()}
|
||||
style={[styles.sendButton, inputText.trim() && styles.sendButtonActive]}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Ionicons
|
||||
name="send"
|
||||
size={20}
|
||||
color={inputText.trim() ? colors.text : colors.textTertiary}
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={inputText.trim() ? gradients.primary as [string, string] : [colors.glass.background, colors.glass.background]}
|
||||
style={styles.sendButton}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons
|
||||
name="send"
|
||||
size={18}
|
||||
color={inputText.trim() ? colors.text : colors.textTertiary}
|
||||
/>
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
@@ -472,9 +659,94 @@ const styles = StyleSheet.create({
|
||||
keyboardAvoiding: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingBottom: spacing.sm,
|
||||
backgroundColor: colors.backgroundLight,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.glass.border,
|
||||
},
|
||||
backButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerTitle: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
},
|
||||
headerSpacer: {
|
||||
width: 40,
|
||||
},
|
||||
questionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.glass.backgroundLight,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.glass.border,
|
||||
},
|
||||
messageList: {
|
||||
flex: 1,
|
||||
},
|
||||
questionIconGradient: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
questionContent: {
|
||||
flex: 1,
|
||||
},
|
||||
questionText: {
|
||||
...typography.subhead,
|
||||
color: colors.text,
|
||||
fontWeight: '500',
|
||||
lineHeight: 20,
|
||||
},
|
||||
answersRow: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
answerChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
answerName: {
|
||||
...typography.caption2,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
answerDot: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
backgroundColor: colors.textTertiary,
|
||||
},
|
||||
answerDotYes: {
|
||||
backgroundColor: colors.success,
|
||||
},
|
||||
answerDotMaybe: {
|
||||
backgroundColor: colors.warning,
|
||||
},
|
||||
answerText: {
|
||||
...typography.caption2,
|
||||
color: colors.textSecondary,
|
||||
textTransform: 'capitalize',
|
||||
},
|
||||
listContent: {
|
||||
flexGrow: 1,
|
||||
padding: spacing.md,
|
||||
paddingBottom: spacing.lg,
|
||||
},
|
||||
messageRow: {
|
||||
marginBottom: spacing.sm,
|
||||
@@ -489,8 +761,7 @@ const styles = StyleSheet.create({
|
||||
bubble: {
|
||||
maxWidth: "78%",
|
||||
padding: spacing.md,
|
||||
borderRadius: radius.xl,
|
||||
...shadows.sm,
|
||||
borderRadius: radius.lg,
|
||||
},
|
||||
myBubble: {
|
||||
borderBottomRightRadius: radius.xs,
|
||||
@@ -504,6 +775,7 @@ const styles = StyleSheet.create({
|
||||
messageText: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
lineHeight: 22,
|
||||
},
|
||||
messageImage: {
|
||||
width: 200,
|
||||
@@ -519,35 +791,70 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
timestamp: {
|
||||
...typography.caption2,
|
||||
color: "rgba(255, 255, 255, 0.6)",
|
||||
color: "rgba(255, 255, 255, 0.5)",
|
||||
},
|
||||
readIcon: {
|
||||
readStatus: {
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
viewedBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
position: 'absolute',
|
||||
top: spacing.sm,
|
||||
left: spacing.sm,
|
||||
backgroundColor: 'rgba(0,0,0,0.6)',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 3,
|
||||
borderRadius: radius.sm,
|
||||
},
|
||||
viewedText: {
|
||||
...typography.caption2,
|
||||
color: colors.success,
|
||||
fontWeight: '600',
|
||||
},
|
||||
emptyChat: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.xxl,
|
||||
},
|
||||
emptyChatIcon: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
emptyChatText: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
inputWrapper: {
|
||||
backgroundColor: colors.backgroundLight,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.glass.border,
|
||||
},
|
||||
inputWrapperAndroid: {
|
||||
backgroundColor: colors.glass.backgroundLight,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-end",
|
||||
padding: spacing.sm,
|
||||
paddingBottom: Platform.OS === "ios" ? spacing.xl : spacing.sm,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
attachButton: {
|
||||
padding: spacing.sm,
|
||||
marginBottom: spacing.xs,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: colors.glass.background,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
inputFieldWrapper: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.glass.background,
|
||||
borderRadius: radius.xl,
|
||||
borderRadius: radius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
marginHorizontal: spacing.sm,
|
||||
},
|
||||
input: {
|
||||
paddingHorizontal: spacing.md,
|
||||
@@ -560,28 +867,47 @@ const styles = StyleSheet.create({
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: colors.glass.background,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
sendButtonActive: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
typingContainer: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
typingBubble: {
|
||||
backgroundColor: colors.glass.backgroundLight,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
borderRadius: radius.lg,
|
||||
borderBottomLeftRadius: radius.xs,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
typingText: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
fontStyle: "italic",
|
||||
typingDots: {
|
||||
flexDirection: 'row',
|
||||
gap: 4,
|
||||
},
|
||||
typingDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.textTertiary,
|
||||
},
|
||||
typingDot1: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
typingDot2: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
typingDot3: {
|
||||
opacity: 0.8,
|
||||
},
|
||||
revealOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "rgba(0,0,0,0.4)",
|
||||
backgroundColor: "rgba(0,0,0,0.5)",
|
||||
borderRadius: radius.md,
|
||||
},
|
||||
revealContent: {
|
||||
@@ -596,34 +922,26 @@ const styles = StyleSheet.create({
|
||||
fontWeight: "600",
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
stickyTopBar: {
|
||||
marginHorizontal: spacing.md,
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
stickyContent: {
|
||||
padding: spacing.md,
|
||||
},
|
||||
stickyQuestionText: {
|
||||
...typography.subhead,
|
||||
color: colors.text,
|
||||
fontWeight: "600",
|
||||
textAlign: "center",
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
stickyAnswersContainer: {
|
||||
flexDirection: "row",
|
||||
fullScreenOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.95)",
|
||||
justifyContent: "center",
|
||||
gap: spacing.sm,
|
||||
alignItems: "center",
|
||||
},
|
||||
answerBadge: {
|
||||
backgroundColor: colors.primaryLight,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.sm,
|
||||
fullScreenCloseButton: {
|
||||
position: "absolute",
|
||||
top: 50,
|
||||
right: 20,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.15)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
zIndex: 10,
|
||||
},
|
||||
stickyAnswerText: {
|
||||
...typography.caption1,
|
||||
color: colors.text,
|
||||
fullScreenImage: {
|
||||
width: Dimensions.get("window").width,
|
||||
height: Dimensions.get("window").height * 0.8,
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user