Skip to content

Commit 2044b63

Browse files
committed
added notification badge for new messages, visual changes
the flatlist of conversations now sorts by last active chat. fixed layout to respect device safe areas
1 parent fb029f2 commit 2044b63

2 files changed

Lines changed: 135 additions & 71 deletions

File tree

GamerMatchApp/app/(tabs)/chatScreen.tsx

Lines changed: 80 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { getProfiles, UserProfile, RawUserProfile, mapProfileData } from "../../
99
import conversationData from "../data/conversations.json";
1010
import firestore from "@react-native-firebase/firestore";
1111
import auth from "@react-native-firebase/auth";
12+
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
1213

1314
// interface to define chat message structure
1415
interface ChatMessage {
@@ -34,9 +35,12 @@ const imageMap: any = {
3435
export default function ChatScreen() {
3536

3637
const router = useRouter();
37-
// const profileMap = Object.fromEntries(profilesData.map(u => [u.id, u]));
3838
const [profiles, setProfiles] = useState<Record<string, UserProfile>>({});
3939
const [messageReadStatus, setMessageReadStatus] = useState(conversationData);
40+
const [hasUnread, setHasUnread] = useState<Record<string,boolean>>({}); // track which conversations have unread messages
41+
const [lastMessages, setLastMessages] = useState<Record<string,string>>({}); // track the last message for each conversation
42+
const [lastMessageAt, setLastMessageAt] = useState<Record<string, number>>({}); // track the timestamp of the last message for sorting
43+
const insets = useSafeAreaInsets();
4044

4145
// starts a conversation between the current user and the selected user or opens it if it already exists
4246
const startConversation = async (otherUser: UserProfile) => {
@@ -57,6 +61,9 @@ export default function ChatScreen() {
5761
lastMessageAt: firestore.FieldValue.serverTimestamp(),
5862
})).id;
5963
// console.log("navigating with conversationId:", conversationId); // debug to verify we have the conversation ID before navigating
64+
65+
setHasUnread(prev => ({ ...prev, [otherUser.firebaseUid]: false })); // mark as read when opening the conversation
66+
6067
router.push({
6168
pathname: "/conversationScreen",
6269
params: { id: conversationId },
@@ -81,6 +88,40 @@ export default function ChatScreen() {
8188
};
8289
loadProfiles();
8390
},[]);
91+
92+
useEffect(() => {
93+
const currentUid = auth().currentUser?.uid;
94+
if (!currentUid) return;
95+
96+
const unsubscribe = firestore()
97+
.collection("conversations")
98+
.where("participants", "array-contains", currentUid)
99+
.onSnapshot(snapshot => {
100+
const unread: Record<string, boolean> = {};
101+
const lastMsg: Record<string, string> = {};
102+
const lastMsgAt: Record<string, number> = {};
103+
104+
snapshot.docs.forEach(doc => {
105+
const data = doc.data();
106+
const participants: string[] = data.participants ?? [];
107+
const otherUid = participants.find(uid => uid !== currentUid);
108+
if (!otherUid) return;
109+
110+
// true if the current user has any unread messages in this conversation
111+
unread[otherUid] = !!(data.hasUnread?.[currentUid]) && data.lastMessageSenderId !== currentUid;
112+
const sentByMe = data.lastMessageSenderId === currentUid;
113+
lastMsg[otherUid] = data.lastMessage ? `${sentByMe ? "You: " : ""}${data.lastMessage}` : "Start a conversation";
114+
lastMsgAt[otherUid] = data.lastMessageAt?.toMillis() ?? 0; // use timestamp for sorting
115+
116+
});
117+
setHasUnread(unread);
118+
setLastMessages(lastMsg);
119+
setLastMessageAt(lastMsgAt);
120+
});
121+
122+
return () => unsubscribe();
123+
}, []);
124+
84125
// Load message read status
85126
useEffect(() => {
86127
const loadMessageReadStatus = async () => {
@@ -134,40 +175,44 @@ useEffect(() => {
134175
};
135176

136177
return (
137-
<View style={styles.container}>
138-
<FlatList<UserProfile>
139-
data={Object.values(profiles)}
140-
keyExtractor={(item) => item.id}
141-
renderItem={({ item }) => {
142-
const avatarSource = item.avatar
143-
? /^https?:\/\//i.test(item.avatar)
144-
? { uri: item.avatar }
145-
: imageMap[item.avatar] ?? require("../../assets/images/chicken.png")
146-
: require("../../assets/images/chicken.png");
147-
148-
return (
149-
<TouchableOpacity
150-
style={styles.messageCard}
151-
onPress={() => startConversation(item)}
152-
>
153-
<Image source={avatarSource} style={styles.profilePicture} />
154-
<View style={styles.messageContent}>
155-
<Text style={styles.username}>{item.name}</Text>
156-
<Text style={styles.messageText}>Start a conversation</Text>
157-
</View>
158-
</TouchableOpacity>
159-
);
160-
}}
161-
/>
162-
</View>
178+
<View style={[styles.container, { paddingTop: insets.top }]}>
179+
<FlatList<UserProfile>
180+
data={Object.values(profiles).sort((a,b) => {
181+
const aTime = lastMessageAt[a.firebaseUid] ?? 0;
182+
const bTime = lastMessageAt[b.firebaseUid] ?? 0;
183+
return bTime - aTime; // sort by most recent message
184+
})}
185+
keyExtractor={(item) => item.id}
186+
renderItem={({ item }) => {
187+
const avatarSource = item.avatar
188+
? /^https?:\/\//i.test(item.avatar)
189+
? { uri: item.avatar }
190+
: imageMap[item.avatar] ?? require("../../assets/images/chicken.png")
191+
: require("../../assets/images/chicken.png");
192+
const isUnread = item.firebaseUid ? (hasUnread[item.firebaseUid] ?? false) : false;
193+
return (
194+
<TouchableOpacity
195+
style={styles.messageCard}
196+
onPress={() => startConversation(item)}
197+
>
198+
<Image source={avatarSource} style={styles.profilePicture} />
199+
<View style={styles.messageContent}>
200+
<Text style={styles.username}>{item.name}</Text>
201+
<Text style={styles.messageText}>{lastMessages[item.firebaseUid] ?? "Start a conversation"}</Text>
202+
</View>
203+
{isUnread && <Text style={styles.notificationBadge}> </Text>}
204+
</TouchableOpacity>
205+
);
206+
}}
207+
/>
208+
</View>
163209
);
164210
}
165211

166212
const styles = StyleSheet.create({
167213
container: {
168214
flex: 1,
169215
backgroundColor: "#D9F7D7",
170-
paddingTop: 20,
171216
},
172217
messageCard: {
173218
flexDirection: 'row',
@@ -195,8 +240,8 @@ const styles = StyleSheet.create({
195240
color: '#666',
196241
},
197242
notificationBadgeFormat: {
198-
flexDirection: 'row',
199-
padding: 15,
243+
marginLeft: 'auto',
244+
justifyContent: 'center',
200245
alignItems: 'center',
201246
},
202247
notificationBadge: {
@@ -206,6 +251,10 @@ const styles = StyleSheet.create({
206251
backgroundColor: "red",
207252
borderRadius: 20,
208253
color: "white",
209-
}
254+
},
255+
safeArea: {
256+
flex: 1,
257+
backgroundColor: '#D9F7D7',
258+
},
210259
});
211260

GamerMatchApp/app/(tabs)/conversationScreen.tsx

Lines changed: 55 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// this is the conversation screen between two users
22
import { useLocalSearchParams } from "expo-router";
3-
import { useEffect, useState } from 'react';
3+
import { useEffect, useState, useRef } from 'react';
44
import { ActivityIndicator, FlatList, Image, StyleSheet, Text, TextInput, TouchableOpacity, View } from "react-native";
5+
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
56
import firestore from "@react-native-firebase/firestore";
67
import auth from "@react-native-firebase/auth";
78
import { getProfiles, UserProfile, RawUserProfile, mapProfileData } from "../../services/api";
@@ -27,6 +28,8 @@ export default function ConversationScreen() {
2728
const [otherUser, setOtherUser] = useState<UserProfile | null>(null);
2829
const [loadingUser, setLoadingUser] = useState(true);
2930
const currentUid = auth().currentUser?.uid;
31+
const flatListRef = useRef<FlatList>(null);
32+
const insets = useSafeAreaInsets();
3033

3134
// load the other user's profile for the header
3235
useEffect(() => {
@@ -44,6 +47,13 @@ export default function ConversationScreen() {
4447
const mapped = mapProfileData(rawProfiles);
4548
const found = mapped.find(p => p.firebaseUid === otherUid);
4649
if (found) setOtherUser(found);
50+
51+
await firestore()
52+
.collection("conversations")
53+
.doc(conversationId)
54+
.update({
55+
[`hasUnread.${otherUid}`]: false, // mark messages as read for this user
56+
});
4757
} catch (e) {
4858
console.error("Error loading other user:", e);
4959
} finally {
@@ -74,6 +84,7 @@ export default function ConversationScreen() {
7484
try {
7585
const text = messageText.trim();
7686
setMessageText("");
87+
console.log("sending to user: ", otherUser?.firebaseUid);
7788

7889
await firestore()
7990
.collection("conversations")
@@ -92,6 +103,8 @@ export default function ConversationScreen() {
92103
.update({
93104
lastMessage: text,
94105
lastMessageAt: firestore.FieldValue.serverTimestamp(),
106+
lastMessageSenderId: currentUid,
107+
[`hasUnread.${otherUser?.firebaseUid}`]: true, // mark as unread for the other user
95108
});
96109
} catch (e) {
97110
console.error("Error sending message:", e);
@@ -105,46 +118,49 @@ export default function ConversationScreen() {
105118
: require("../../assets/images/chicken.png");
106119

107120
return (
108-
<View style={styles.container}>
109-
<View style={styles.headerContainer}>
110-
{loadingUser ? (
111-
<ActivityIndicator size="small" color="#000" />
112-
) : (
113-
<>
114-
<Image source={avatarSource} style={styles.profilePicture} />
115-
<Text style={{ fontSize: 20, fontWeight: "bold" }}>
116-
{otherUser ? otherUser.name : "Unknown User"}
117-
</Text>
118-
</>
119-
)}
120-
</View>
121-
<FlatList
122-
data={messages}
123-
keyExtractor={(item) => item.id}
124-
renderItem={({ item }) => {
125-
const isCurrentUser = item.senderId === currentUid;
126-
return (
127-
<View style={[styles.messageRow, isCurrentUser && styles.currentUserRow]}>
128-
<View style={[styles.messageBubble, { backgroundColor: isCurrentUser ? "#A3D9A5" : "#E0E0E0" }]}>
129-
<Text style={styles.message}>{item.text}</Text>
121+
<View style={[styles.container, { paddingTop: insets.top }]}>
122+
<View style={[StyleSheet.absoluteFill, { height: insets.top, backgroundColor: '#A3D9A5' }]} />
123+
<View style={styles.headerContainer}>
124+
{loadingUser ? (
125+
<ActivityIndicator size="small" color="#000" />
126+
) : (
127+
<>
128+
<Image source={avatarSource} style={styles.profilePicture} />
129+
<Text style={{ fontSize: 20, fontWeight: "bold" }}>
130+
{otherUser ? otherUser.name : "Unknown User"}
131+
</Text>
132+
</>
133+
)}
134+
</View>
135+
<FlatList
136+
ref={flatListRef}
137+
onContentSizeChange={() => flatListRef.current?.scrollToEnd()}
138+
data={messages}
139+
keyExtractor={(item) => item.id}
140+
renderItem={({ item }) => {
141+
const isCurrentUser = item.senderId === currentUid;
142+
return (
143+
<View style={[styles.messageRow, isCurrentUser && styles.currentUserRow]}>
144+
<View style={[styles.messageBubble, { backgroundColor: isCurrentUser ? "#A3D9A5" : "#E0E0E0" }]}>
145+
<Text style={styles.message}>{item.text}</Text>
146+
</View>
130147
</View>
131-
</View>
132-
);
133-
}}
134-
/>
135-
<View style={styles.inputContainer}>
136-
<TextInput
137-
style={styles.textInput}
138-
placeholder="Type a message..."
139-
value={messageText}
140-
onChangeText={setMessageText}
141-
multiline
148+
);
149+
}}
142150
/>
143-
<TouchableOpacity style={styles.sendButton} onPress={handleSendMessage}>
144-
<Text style={styles.sendButtonText}>Send</Text>
145-
</TouchableOpacity>
151+
<View style={[styles.inputContainer, { paddingBottom: insets.bottom }]}>
152+
<TextInput
153+
style={styles.textInput}
154+
placeholder="Type a message..."
155+
value={messageText}
156+
onChangeText={setMessageText}
157+
multiline
158+
/>
159+
<TouchableOpacity style={styles.sendButton} onPress={handleSendMessage}>
160+
<Text style={styles.sendButtonText}>Send</Text>
161+
</TouchableOpacity>
162+
</View>
146163
</View>
147-
</View>
148164
);
149165
}
150166
const styles = StyleSheet.create({
@@ -153,8 +169,7 @@ const styles = StyleSheet.create({
153169
backgroundColor: "#D9F7D7",
154170
},
155171
headerContainer: {
156-
backgroundColor: "#A3D9A5",
157-
padding: 16,
172+
backgroundColor: "#A3D9A5", //A3D9A5
158173
borderBottomWidth: 1,
159174
borderBottomColor: "#e0e0e0",
160175
fontSize: 20,

0 commit comments

Comments
 (0)