Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions examples/demo/src/ChatScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,7 @@ export default function ChatScreen({ currentUser }: Props) {
<ChatProvider currentUser={currentUser}>
<LibChatScreen
conversationId={activeConversation}
currentUserId={currentUser.id}
memberIds={[currentUser.id, recipientId.trim()]}
partners={[{ id: recipientId.trim(), name: recipientId.trim() }]}
/>
</ChatProvider>
</div>
Expand Down
1 change: 1 addition & 0 deletions postcss.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
module.exports = {
plugins: [
require('postcss-import'),
require('tailwindcss'),
require('autoprefixer'),
],
Expand Down
9 changes: 4 additions & 5 deletions src/components/ChatList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,8 @@ export const ChatList: React.FC<ChatListProps> = ({
{conversations.map((c) => (
<button
key={c.id}
className={`conversation-item ${
selectedConversationId === c.id ? "active" : ""
}`}
className={`conversation-item ${selectedConversationId === c.id ? "active" : ""
}`}
onClick={() => {
handleSelectConversation(c);
}}
Expand All @@ -60,8 +59,8 @@ export const ChatList: React.FC<ChatListProps> = ({
{c?.latestMessage?.text || ""}
</div>
</div>
{(c.unRead || 0) > 0 && (
<span className="unread-badge">{c.unRead || 0}</span>
{(c.unRead?.[currentUser?.id] || 0) > 0 && (
<span className="unread-badge">{c.unRead?.[currentUser?.id] || 0}</span>
)}
</button>
))}
Expand Down
22 changes: 16 additions & 6 deletions src/components/ChatScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({

// Keep a ref so the subscription callback always reads the latest key
const derivedKeyRef = useRef(derivedKey);
// Track whether we've already auto-selected the first conversation
const hasAutoSelectedRef = useRef(!!conversationId);
useEffect(() => {
derivedKeyRef.current = derivedKey;
}, [derivedKey]);
Expand Down Expand Up @@ -133,22 +135,30 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
})
);
setConversations(decrypted);
// If nothing selected, pick first
if (!selectedConversationId && decrypted.length > 0) {
setSelectedConversationId(decrypted[0].id);
setSelectedName(decrypted[0].name || "");
// Auto-select first conversation only once
if (!hasAutoSelectedRef.current && decrypted.length > 0) {
hasAutoSelectedRef.current = true;
const first = decrypted[0];
setSelectedConversationId(first.id);
setSelectedName(first.name || "");
setSelectedPartners(
(first.members ?? [])
.filter((m: string) => m !== `${currentUser?.id}`)
.map((m: string) => ({ id: m }))
);
}
}
);
return () => unsubscribe?.();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentUser?.id]);

// Reset unread count when a conversation is selected
useEffect(() => {
if (selectedConversationId) {
markAsRead();
}
}, [selectedConversationId, markAsRead]);
}, [selectedConversationId]); // eslint-disable-line react-hooks/exhaustive-deps

const handleSendMessage = useCallback(
async (text: string) => {
Expand Down Expand Up @@ -190,7 +200,7 @@ export const ChatScreen: React.FC<ChatScreenProps> = ({
console.error("Failed to start chat", e);
}
},
[currentUser?.id]
[currentUser?.id, currentUser?.name]
);

const handleFileUpload = useCallback(
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export const useChat = ({ user, conversationId, memberIds, name }: UseChatProps)
setError(err instanceof Error ? err.message : 'Failed to mark as read');
throw err;
}
}, [conversationId, user.id]);
}, [conversationId, user.id, chatService]);

return {
messages,
Expand Down
114 changes: 58 additions & 56 deletions src/services/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
deleteDoc,
setDoc,
query,
where,
orderBy,
limit,
startAfter,
Expand All @@ -21,6 +22,7 @@ import { ref, uploadBytes, getDownloadURL } from 'firebase/storage';
import { getFirebaseFirestore, getFirebaseStorage } from './firebase';
import { UserService } from './user';
import {
ConversationProps,
FireStoreCollection,
IMessage,
MediaType,
Expand Down Expand Up @@ -67,62 +69,50 @@ export class ChatService {
conversationId?: string,
): Promise<string> {
try {
// Per-user names: initiator sees otherName, others see initiator's name
const names: Record<string, string> = {};
const unRead: Record<string, number> = {};
memberIds.forEach((id) => {
names[id] = id === initiatorId ? (otherName || '') : (name || '');
unRead[id] = 0;
});

const conversationData = {
members: memberIds,
type,
name: otherName || '',
names,
unRead,
createdAt: Date.now(),
updatedAt: Date.now(),
latestMessage: null,
latestMessageTime: null,
createdBy: initiatorId
createdBy: initiatorId,
};

let docRefId: string;

if (conversationId) {
// Create the document with the specified conversationId
await updateDoc(
doc(this.db, COLLECTIONS.CONVERSATIONS, conversationId),
conversationData
).catch(async (_err) => {
// If doc does not exist, set it
).catch(async () => {
await setDoc(
doc(this.db, COLLECTIONS.CONVERSATIONS, conversationId),
conversationData
);
});
docRefId = conversationId;
} else {
// Add a new document with auto-generated ID
const docRef = await addDoc(
collection(this.db, COLLECTIONS.CONVERSATIONS),
conversationData
);
docRefId = docRef.id;
}

// Create user conversation references for each member
const promises = memberIds.map(async (memberId) => {
// Ensure user document exists
await this.userService.createUserIfNotExists(memberId);

// Get the chat name based on the memberId and initiatorId
const chatName = memberId === initiatorId ? otherName : name;
// Add conversation reference to user's conversations subcollection
await setDoc(
doc(this.db, COLLECTIONS.USERS, memberId, COLLECTIONS.CONVERSATIONS, docRefId),
{
joinedAt: Date.now(),
unRead: 0,
updatedAt: Date.now(),
members: memberIds,
name: chatName || '',
}
);
});
// Ensure user documents exist for all members
await Promise.all(memberIds.map((id) => this.userService.createUserIfNotExists(id)));

await Promise.all(promises);
return docRefId;
} catch (error) {
console.error('Error creating conversation:', error);
Expand Down Expand Up @@ -167,7 +157,9 @@ export class ChatService {
try {
// Check if conversation exists, create if it doesn't
const conversationExists = await this.conversationExists(conversationId);
const memberIds = conversationOptions?.memberIds || [String(message.senderId)];
const memberIds = conversationOptions?.memberIds && conversationOptions.memberIds.length > 1
? conversationOptions.memberIds
: [String(message.senderId)];

if (!conversationExists) {
// Use provided options or defaults
Expand Down Expand Up @@ -206,30 +198,27 @@ export class ChatService {
}
);

// Update unread counts for other members
// Update unread counts for other members on the conversation document
const conversationDoc = await getDoc(
doc(this.db, COLLECTIONS.CONVERSATIONS, conversationId)
);

if (conversationDoc.exists()) {
// Use members from the conversation document so all participants are updated,
// regardless of what memberIds was passed by the caller
const allMembers: string[] = conversationDoc.data()?.members || memberIds;
const updatePromises = allMembers.map(async (memberId: string) => {
const isSender = memberId === message.senderId;
// Update unread count in user's conversations subcollection
await setDoc(
doc(this.db, COLLECTIONS.USERS, memberId, COLLECTIONS.CONVERSATIONS, conversationId),
{
unRead: increment(isSender ? 0 : 1),
updatedAt: Date.now(),
latestMessage: convertToLatestMessage(`${message.senderId}`, conversationOptions?.name || '', message.text || ''),
},
{ merge: true } // Merge with existing data if document exists
);
const unreadUpdates: Record<string, unknown> = {};
allMembers.forEach((memberId: string) => {
if (memberId !== message.senderId) {
unreadUpdates[`unRead.${memberId}`] = increment(1);
}
});

await Promise.all(updatePromises);
await updateDoc(
doc(this.db, COLLECTIONS.CONVERSATIONS, conversationId),
{
...unreadUpdates,
latestMessage: convertToLatestMessage(`${message.senderId}`, conversationOptions?.name || '', message.text || ''),
}
);
}
} catch (error) {
console.error('Error sending message:', error);
Expand Down Expand Up @@ -279,32 +268,39 @@ export class ChatService {
}

/**
* Get user's conversations from the user's conversations subcollection
* Get user's conversations from the top-level /conversations collection
* filtered by membership.
* @param userId - The ID of the user
* @param callback - Callback function to receive conversations
* @returns Unsubscribe function
*/
subscribeToUserConversations(
userId: string,
callback: (userConversations: any[]) => void
callback: (userConversations: ConversationProps[]) => void
): () => void {
// First ensure user document exists
this.userService.createUserIfNotExists(userId).catch(console.error);

const userConversationsQuery = query(
collection(this.db, COLLECTIONS.USERS, userId, 'conversations'),
collection(this.db, COLLECTIONS.CONVERSATIONS),
where('members', 'array-contains', userId),
orderBy('updatedAt', 'desc')
);

return onSnapshot(userConversationsQuery, (snapshot) => {
const userConversations: any[] = [];
snapshot.forEach((doc) => {
const data = doc.data();
const userConversations: ConversationProps[] = [];
snapshot.forEach((docSnap) => {
const data = docSnap.data();
const names: Record<string, string> = data.names || {};
const unreadCounts: Record<string, number> = data.unRead || {};
userConversations.push({
id: doc.id,
...data,
id: docSnap.id,
name: names[userId] || '',
members: data.members || [],
unRead: unreadCounts,
updatedAt: data.updatedAt ? new Date(data.updatedAt).valueOf() : Date.now(),
joinedAt: data.joinedAt ? new Date(data.joinedAt).valueOf() : Date.now(),
joinedAt: data.createdAt ? new Date(data.createdAt).valueOf() : Date.now(),
latestMessage: data.latestMessage || undefined,
});
});
callback(userConversations);
Expand Down Expand Up @@ -359,12 +355,18 @@ export class ChatService {
});
}

// Update unread count
// Update unread count on the main conversation document
async updateUnread(conversationId: string, userId: string): Promise<void> {
try {
const conversationRef = doc(this.db, FireStoreCollection.users, userId, FireStoreCollection.conversations, conversationId);
await setDoc(conversationRef, { unRead: 0 }, { merge: true });
} catch (error) {
await updateDoc(
doc(this.db, COLLECTIONS.CONVERSATIONS, conversationId),
{ [`unRead.${userId}`]: 0 }
);
} catch (error: any) {
if (error?.code === 'not-found') {
// Conversation document doesn't exist yet, we can safely ignore
return;
}
console.error('Error updating unread count:', error);
throw new Error('Failed to update unread count');
}
Expand Down
5 changes: 3 additions & 2 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export interface IConversation {
members: string[];
latestMessage?: IMessage;
latestMessageTime?: number;
unRead?: number;
unRead?: Record<string, number>;
title?: string;
type: 'private' | 'group';
createdAt: number;
Expand Down Expand Up @@ -112,8 +112,9 @@ export interface ConversationProps {
name?: string;
image?: string;
members: string[];
unRead?: number;
unRead?: Record<string, number>;
updatedAt: number;
joinedAt?: number;
latestMessage?: LatestMessageProps;
}

Expand Down