Skip to content
This repository has been archived by the owner on Jul 2, 2024. It is now read-only.

Commit

Permalink
Merge pull request #220 from ColinLefter/real-time-channel-fix
Browse files Browse the repository at this point in the history
Real time channel fix
  • Loading branch information
TobyNguyen710 committed Apr 9, 2024
2 parents 11c7e1c + ad5a4e3 commit abec86c
Show file tree
Hide file tree
Showing 7 changed files with 143 additions and 117 deletions.
4 changes: 2 additions & 2 deletions Application/components/Messaging/MessagingInterface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useChannel } from "ably/react";
import { useChat } from "@/contexts/chatContext";
import { ChatProps, MessageProps, DisplayedMessageProps } from "@/accordTypes";
import { useUser } from '@clerk/nextjs';
import { generateHash } from "@/utility";
import { generateChannelKey } from "@/utility";

/**
* Provides a comprehensive chat interface for real-time messaging within the application.
Expand Down Expand Up @@ -63,7 +63,7 @@ export function MessagingInterface({
const memberIDs = [senderID, ...receiverIDs];
const { chatHistory, updateChatHistory } = useChat();
const messageTextIsEmpty = messageText.trim().length === 0; // messageTextIsEmpty is used to disable the send button when the textarea is empty.
const channelKey = providedChannelKey || generateHash(memberIDs);
const channelKey = providedChannelKey || generateChannelKey(null, memberIDs); // Reason for the fallback is to support direct messaging

// useChannel is a react-hook API for subscribing to messages from an Ably channel.
// You provide it with a channel name and a callback to be invoked whenever a message is received.
Expand Down
8 changes: 8 additions & 0 deletions Application/components/Messaging/NewTextChannelModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { useCache } from '@/contexts/queryCacheContext';
import { useFriendList } from '@/hooks/useFriendList';
import { notifications } from '@mantine/notifications';
import { useUser } from '@clerk/nextjs';
import { useChannel } from "ably/react";
import { getSystemsChannelID} from "@/utility";

/**
* NewChatModal facilitates the creation of new chat sessions, allowing users to select friends for group chats or direct messages (DMs).
Expand Down Expand Up @@ -54,6 +56,8 @@ export function NewTextChannelModal() {
const [senderID, setSenderID] = useState<string>('');
const friends = useFriendList({lastFetched, setLastFetched});
const [captureHistory, setcaptureHistory] = useState(true); // On by default

const { channel } = useChannel(getSystemsChannelID());

useEffect(() => {
if (user && user.username && user.id) {
Expand Down Expand Up @@ -110,6 +114,10 @@ export function NewTextChannelModal() {
});

if (response.ok) {
await channel.publish({
name: "text-channel-created",
data: { message: "A new text channel was created" } // `data` can be a string, object, or other types
});
notifications.show({
title: 'Created a new text channel!',
message: `Added ${selectedFriendUsernames.join(', ')}`,
Expand Down
81 changes: 42 additions & 39 deletions Application/components/Server/MemberList.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import React, { useEffect, useState } from 'react';
import { Avatar, Group, Text, Stack, Paper, Button, Menu, rem, TextInput, Modal } from '@mantine/core';
import { IconSettings, IconMessageCircle, IconPhoto, IconSearch, IconArrowsLeftRight, IconTrash, IconPlus, IconUserUp } from '@tabler/icons-react';
import { Group, Text, Stack, Button, Menu, rem, TextInput, Modal } from '@mantine/core';
import { IconTrash, IconPlus, IconUserUp } from '@tabler/icons-react';
import { useUser } from '@clerk/nextjs';
import { channel } from 'diagnostics_channel';
import { createHash } from 'crypto';
import { IntegerType } from 'mongodb';
import { useDisclosure } from '@mantine/hooks';
import { notifications, showNotification } from '@mantine/notifications';
import { generateHashFromString } from '@/utility';
import { showNotification } from '@mantine/notifications';
import { useChat } from '@/contexts/chatContext';
import { useChannel } from "ably/react";
import { getSystemsChannelID} from "@/utility";

export function MemberList({isAdmin, chatID, isView}: any) {
export function MemberList({ isAdmin, chatID }: any) {
// Hardcoded member list
const [membersList, setMembersList] = useState<string[]>([]);
const [membersIDList, setMembersIDList] = useState<string[]>([]);
Expand All @@ -21,49 +19,54 @@ export function MemberList({isAdmin, chatID, isView}: any) {
const [searchResult, setSearchResult] = useState<number | null>(null);
const [isAdmin1, setIsADmin] = useState(isAdmin);
const { user } = useUser();
const { activeView, setActiveView } = useChat();
const [myID, setMyID] = useState<string>('');

const { selectedChannelId, setActiveView } = useChat(); // Critical: this is how we obtain the channel key of the channel we are looking at
const { channel } = useChannel(getSystemsChannelID());

useEffect(() => {
if (user && user.id) {
// Set sender to user's username if user exists and username is not null/undefined
setMyID(user.id);
}
}, [user]); // Dependency array ensures this runs whenever `user` changes

const removeMember = async(member: String, index: any) =>{
const memberToRemove = membersIDList[index];
let membersIDListToSort = membersIDList.filter(item => item !== memberToRemove);
membersIDListToSort.sort();
const rawChannelKey = `chat:${membersIDListToSort.join(",")}`;
const newChannelKey = generateHashFromString(rawChannelKey);
try {
const response = await fetch('/api/removeMember', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
//-----------------------------------------------------------------------------------------------------------------------------------------------
body: JSON.stringify({ member: memberToRemove , channelKey: channelKey, newChannelKey: newChannelKey}), // Change this when we put in the Appshell (It is a String NOT int)
//------------------------------------------------------------------------------------------------------------------------------------------------
});

if (response.ok) {
const data = await response.json();
const stringToRemove = member;
if (myID === memberToRemove) {
setActiveView('friends'); // We are getting kicked out of the chat (this is me removing myself from a chat)
}
const removeMember = async(member: String, index: any) => {
const memberToRemove = membersIDList[index];
// No need to generate a newChannelKey here since the backend will handle this
try {
const response = await fetch('/api/removeMember', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
member: memberToRemove,
channelKey: selectedChannelId, // Use selectedChannelId from context
}),
});

const filteredMemberList = membersList.filter(item => item !== stringToRemove);
setMembersList(filteredMemberList);
} else {
console.error('Failed to fetch member list');
if (response.ok) {
const data = await response.json();
await channel.publish({
name: "removed-from-text-channel",
data: { removedMemberID: memberToRemove } // `data` can be a string, object, or other types
});
if (myID === memberToRemove) {
setActiveView('friends'); // Redirecting the user if they remove themselves
}
} catch (error) {
console.error('Error fetching member list:', error);
// Update the local members list to reflect the removal without waiting for a full refresh
const filteredMembersList = membersList.filter(item => item !== member);
const filteredMembersIDList = membersIDList.filter(id => id !== memberToRemove);
setMembersList(filteredMembersList);
setMembersIDList(filteredMembersIDList);
} else {
console.error('Failed to remove member from chat');
}
}
} catch (error) {
console.error('Error removing member from chat:', error);
}
};

const fetchUserName = async (memberIDs: String[]) => {
try {
Expand Down
59 changes: 34 additions & 25 deletions Application/components/TextChannels/TextChannels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd';
import { TextChannelItem } from "@/components/TextChannels/TextChannelItem"
import { useUser, UserProfile } from '@clerk/nextjs';
import { formatDate, truncateText } from "@/utility"
import { useState, useEffect } from 'react';
import { TextChannel } from "@/accordTypes";
import { useChannelContext } from "@/contexts/channelContext";
import { useChat } from '@/contexts/chatContext';
import { useActiveView } from '@/contexts/activeViewContext';
import { useChannel } from "ably/react";
import { getSystemsChannelID} from "@/utility";
import { useEffect, useState, useCallback } from 'react';

function reorder(list: TextChannel[], startIndex: number, endIndex: number): TextChannel[] {
const result = Array.from(list);
Expand Down Expand Up @@ -62,39 +63,47 @@ export function TextChannels() {
const [textChannels, setTextChannels] = useState<TextChannel[]>([]);
const { updateContext, setActiveView } = useChat();

useChannel(getSystemsChannelID(), (message) => {
// Listen for a specific message event to trigger the refresh
if (message.name === "text-channel-created" || message.name === "removed-from-text-channel") {
fetchUserChats(); // Call fetchUserChats to refresh the channels list
}
});

useEffect(() => {
if (user && user.id && user.username) {
setUserID(user.id);
updateSenderUsername(user.username);
}
}, [user]); // Dependency array ensures this runs whenever `user` changes

useEffect(() => {
const fetchUserChats = async () => {
if (!userID) return; // Ensure userID is available

try {
const response = await fetch('/api/get-user-text-channels', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ userID: userID })
});

if (response.ok) {
const { textChannels } = await response.json();
setTextChannels(textChannels);
} else {
console.error('Failed to fetch chat channels');
}
} catch (error) {
console.error('Error fetching chat channels:', error);
// Wrap fetchUserChats inside useCallback to avoid recreating the function on every render
const fetchUserChats = useCallback(async () => {
if (!userID) return; // Ensure userID is available

try {
const response = await fetch('/api/get-user-text-channels', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ userID: userID })
});

if (response.ok) {
const { textChannels } = await response.json();
setTextChannels(textChannels);
} else {
console.error('Failed to fetch chat channels');
}
};
} catch (error) {
console.error('Error fetching chat channels:', error);
}
}, [userID]); // Add userID to the dependency array of useCallback

useEffect(() => {
fetchUserChats();
}, [userID]); // This useEffect runs only when userID changes, which should only happen when the user logs in or their user object is fetched initially.
}, [userID, fetchUserChats]); // This useEffect depends on userID AND fetchUserChats

const onChannelClick = (channelKey: string) => {
// Find the channel details from the state
Expand Down
6 changes: 3 additions & 3 deletions Application/pages/api/new-chat.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { MongoClient } from 'mongodb';
import { getMongoDbUri } from '@/lib/dbConfig';
import { generateHash } from '@/utility';
import { generateChannelKey } from '@/utility';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'POST') {
Expand All @@ -23,7 +23,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
// Handle both text channels and DMs
let channelKey, newChat;
if (channelName && ownerID && adminIDs) { // Creating a text channel
channelKey = generateHash([channelName, memberIDs]); // memberIDs includes the sender ID
channelKey = generateChannelKey(channelName, memberIDs)

// Check if a channel with the same channelKey already exists
const existingTextChannel = await chatsCollection.findOne({ channelKey: channelKey });
Expand All @@ -44,7 +44,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} else { // Creating a DM
// DMs will be unique as we don't allow a duplicate DM channel.
// The thing that controls that is the fact that we only create a DM channel upon adding a friend.
channelKey = generateHash([memberIDs]);
channelKey = generateChannelKey(null, memberIDs);
newChat = {
channelKey: channelKey,
dateCreated: new Date(),
Expand Down
89 changes: 48 additions & 41 deletions Application/pages/api/removeMember.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { MongoClient } from 'mongodb';
import { getMongoDbUri } from '@/lib/dbConfig';

import { generateChannelKey } from '@/utility';
/**
* Handles the POST request for user registration, receiving the user's username, and find ONE entry that contains its username
* Responds with a success message and status code 200 if the credentials are valid, or an error
Expand All @@ -11,45 +11,52 @@ import { getMongoDbUri } from '@/lib/dbConfig';
* @param res - The outgoing Next.js API response object used to send back the result.
*/

// Reader starts from here
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'POST') {
const { member, channelKey, newChannelKey } = req.body; // Intaking the data that has been sent from the client-side
let client: MongoClient | null = null; // We need to assign something to the client so TypeScript is aware that it can be null if the connection fails

try { //creating and establishing connections to the DB
client = new MongoClient(getMongoDbUri());
await client.connect();
const db = client.db('Accord');

// Reach into the db, and grab the Accounts table
const accountsCollection = db.collection("Chats");

const filter = { channelKey: channelKey };
const updateMemberList = { $pull: { memberIDs: member } };

const updateChannelKey = { $set: { channelKey: newChannelKey } };

// Querying the database by the username we received
const removedMember = await accountsCollection.updateOne( filter, updateMemberList ); // IMPORTANT: The findOne method returns a promise, so we need to await the resolution of the promise first
const result = await accountsCollection.updateOne( filter, updateChannelKey );

if (removedMember) { // Check if the user existed
return res.status(200).json({ matchedCount: removedMember.matchedCount }); // Return the array friendList of this user // Now the JSON string of above ^ will be sent back to UserSettings
} else {
return res.status(401).json({ error: 'Not fetchable' }); // Returns error if not fetchable
}
} catch (error) { // Copy paste from this point - just error catching, method detecting and closing the clients - back to UserSettings.tsx in components
console.error(error);
return res.status(500).json({ error: 'Internal server error' });
} finally {
if (client) {
await client.close();
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'POST') {
const { member, channelKey } = req.body;
let client: MongoClient | null = null;

try {
client = new MongoClient(getMongoDbUri());
await client.connect();
const db = client.db('Accord');
const chatsCollection = db.collection("Chats");

// Remove the member from the memberIDs list
await chatsCollection.updateOne(
{ channelKey: channelKey },
{ $pull: { memberIDs: member } }
);

// Fetch the updated chat to get the current list of members
const updatedChat = await chatsCollection.findOne({ channelKey: channelKey });
if (!updatedChat) {
return res.status(404).json({ error: 'Chat not found' });
}

// Regenerate the channelKey based on the updated list of memberIDs if necessary
const newMemberIDs = updatedChat.memberIDs.sort();
const newChannelKey = generateChannelKey(updatedChat.channelName, newMemberIDs);

// Update the channelKey in the database if it has changed
if (newChannelKey !== channelKey) {
await chatsCollection.updateOne(
{ channelKey: channelKey },
{ $set: { channelKey: newChannelKey } }
);
}

return res.status(200).json({ message: 'Member removed successfully', newChannelKey: newChannelKey });
} catch (error) {
console.error('Error removing member from chat:', error);
return res.status(500).json({ error: 'Internal server error' });
} finally {
if (client) {
await client.close();
}
}
} else {
res.setHeader('Allow', ['POST']);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
} else {
// Handle any requests other than POST
res.setHeader('Allow', ['POST']);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}
}
13 changes: 6 additions & 7 deletions Application/utility.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { createHash } from 'crypto';

export const generateHash = (memberIDs: string[]) => { // Member IDs must include the sender ID as well
memberIDs.sort(); // CRITICAL: Sorts in-place. We need to sort the key to counteract the swapping mechanism where sender and receiver becomes flipped.
const rawChannelKey = `chat:${memberIDs.join(",")}`;
export const generateChannelKey = (channelName: string | null, memberIDs: string[]) => {
// Ensure memberIDs are sorted to maintain consistency
memberIDs.sort();
// Create the rawChannelKey string, including the channel name if provided
const rawChannelKey = channelName ? `text-channel:${channelName}:${memberIDs.join(",")}` : `direct-message:${memberIDs.join(",")}`;
// Generate and return the SHA-256 hash of the rawChannelKey
return createHash('sha256').update(rawChannelKey).digest('hex');
};

export const generateHashFromString = (input: string) => {
return createHash('sha256').update(input).digest('hex');
};

export const formatDate = (date: Date) => {
const suffixes = ["th", "st", "nd", "rd"];
const day = date.getDate();
Expand Down

0 comments on commit abec86c

Please sign in to comment.