diff --git a/schema.ts b/schema.ts index 7473112..2fb660b 100644 --- a/schema.ts +++ b/schema.ts @@ -37,6 +37,7 @@ const collection = { requests: [ { createdAt: 'timestamp', + time: '01:25:44', // Relevant for 'play', 'pause' types type: 'updateState', senderId: 'userId', }, diff --git a/src/components/Chatbox.tsx b/src/components/Chatbox.tsx index 31b80dc..a934e23 100644 --- a/src/components/Chatbox.tsx +++ b/src/components/Chatbox.tsx @@ -1,7 +1,7 @@ import { IonCard, IonFabButton, IonFooter, IonIcon, IonInput, IonToolbar } from '@ionic/react'; import { sendOutline } from 'ionicons/icons'; import React, { useState } from 'react'; -import { db, timestamp } from '../services/firebase'; +import { db, arrayUnion } from '../services/firebase'; import './Chatbox.css'; import Messages from './Messages'; import OnlineList from './OnlineList'; @@ -10,7 +10,7 @@ type ChatboxProps = { ownerId: string; roomId: string; userId: string; - userList: string[]; + userList: Map; }; const Chat: React.FC = ({ ownerId, roomId, userId, userList }) => { @@ -19,16 +19,20 @@ const Chat: React.FC = ({ ownerId, roomId, userId, userList }) => // Send message to database const sendMessage = async () => { if (message !== '') { - await db.collection('rooms').doc(roomId).collection('messages').add({ - createdAt: timestamp, - senderId: userId, - content: message, - type: 'chat', - }); - } + await db + .collection('chats') + .doc(roomId) + .update({ + messages: arrayUnion({ + createdAt: Date.now(), + senderId: userId, + content: message, + }), + }); - // Reset textarea field - setMessage(''); + // Reset textarea field + setMessage(''); + } }; const onEnter = (e: React.KeyboardEvent) => { @@ -39,7 +43,7 @@ const Chat: React.FC = ({ ownerId, roomId, userId, userList }) => return ( - + ; }; type Message = { - id: string; - senderId: string; - sender: string; content: string; -}; - -type RawMessage = { - content: string; - createdAt: TimestampType; + createdAt: number; + id: string; senderId: string; }; -const Messages: React.FC = ({ ownerId, roomId, userId }) => { - const [chats, setChats] = useState([ - { id: '', senderId: userId, sender: '', content: 'You have joined the room.' }, - ]); // All received messages - const [messages, setMessages] = useState(); - const [prevMessages, setPrevMessages] = useState([]); // Track previous messages +const Messages: React.FC = ({ ownerId, roomId, userId, userList }) => { + const [joinTime] = useState(Date.now()); // Time at mounting of the component + const [chats, setChats] = useState([]); // All processed chat messages + const [systemMessages, setSystemMessages] = useState([]); // All processed system messages + const [allMessages, setAllMessages] = useState([]); // Combined array of chat and system messages + const [userHistory] = useState>(new Map()); // All users who are/were in the room const contentRef = useRef(null); - // Listen for new messages + // Send 'joined room' message on component mount + useEffect(() => { + db.collection('rooms') + .doc(roomId) + .update({ + requests: arrayUnion({ createdAt: Date.now(), senderId: userId, time: 0, type: 'join' }), + }); + }, [roomId, userId]); + + // Listen for new chat messages useEffect(() => { const chatUnsubscribe = db .collection('chats') @@ -39,18 +44,84 @@ const Messages: React.FC = ({ ownerId, roomId, userId }) => { const data = docSnapshot.data(); if (data !== undefined) { const messages = data.messages; - setMessages(messages); + + let arr: Message[] = []; + for (const msg of messages) { + if (msg.createdAt > joinTime) { + arr.push({ + content: msg.content, + createdAt: msg.createdAt, + id: msg.senderId + msg.createdAt, + senderId: msg.senderId, + }); + } + } + setChats(arr); } }); return () => { chatUnsubscribe(); }; - }, [roomId, ownerId]); + }, [roomId, joinTime]); - // Listen for list of users in the room + // Convert request type to message content + const processType = (type: string, time: number): string => { + switch (type) { + case 'change': + return 'changed the video.'; + case 'join': + return 'joined the room.'; + case 'pause': + return 'paused the video at ' + secondsToTimestamp(time); + case 'play': + return 'played the video from ' + secondsToTimestamp(time); + default: + return ''; + } + }; - useEffect(() => {}, [messages]); + // Listen for new system messages + useEffect(() => { + const roomUnsubscribe = db + .collection('rooms') + .doc(roomId) + .onSnapshot((docSnapshot) => { + const docData = docSnapshot.data(); + if (docData !== undefined) { + const requests = docData.requests; + + let arr: Message[] = []; + for (const req of requests) { + if (req.createdAt > joinTime && req.type !== 'updateState') { + arr.push({ + content: processType(req.type, req.time), + createdAt: req.createdAt, + id: req.senderId + req.createdAt, + senderId: req.senderId, + }); + } + } + setSystemMessages(arr); + } + }); + + return () => { + roomUnsubscribe(); + }; + }, [roomId, joinTime]); + + // Maintain list of users who entered the room, in order to keep all sender names of messages in the room + useEffect(() => { + userList.forEach((name: string, id: string) => { + userHistory.set(id, name); + }); + }, [userList, userHistory]); + + // Combine messages + useEffect(() => { + setAllMessages(chats.concat(systemMessages)); + }, [chats, systemMessages]); // Always scroll to most recent chat message (bottom) useEffect(() => { @@ -60,21 +131,32 @@ const Messages: React.FC = ({ ownerId, roomId, userId }) => { setTimeout(() => { content?.scrollToBottom(200); }, 100); - }, [chats]); + }, [allMessages]); + + // Retrieve display name from userId + const getName = (id: string) => { + let name = userHistory.get(id); + if (id === ownerId) { + name += ' 👑'; + } + return name; + }; return ( - {chats.map((chat) => { - return ( - - - {chat.sender !== '' ? {chat.sender}: : <>} - {chat.content} - - - ); - })} + {allMessages + .sort((msg1, msg2) => msg1.createdAt - msg2.createdAt) + .map((msg) => { + return ( + + + {getName(msg.senderId) !== '' ? {getName(msg.senderId)}: : <>} + {msg.content} + + + ); + })} ); diff --git a/src/components/OnlineList.tsx b/src/components/OnlineList.tsx index 7659cec..8cb65e6 100644 --- a/src/components/OnlineList.tsx +++ b/src/components/OnlineList.tsx @@ -4,7 +4,7 @@ import { peopleOutline } from 'ionicons/icons'; import './OnlineList.css'; type OnlineListProps = { - userList: string[]; + userList: Map; }; const OnlineList: React.FC = ({ userList }) => { @@ -24,7 +24,7 @@ const OnlineList: React.FC = ({ userList }) => { > Online - {userList.map((user) => { + {Array.from(userList.values()).map((user) => { return ( {user} diff --git a/src/components/RoomHeader.tsx b/src/components/RoomHeader.tsx index 355a6cd..c415255 100644 --- a/src/components/RoomHeader.tsx +++ b/src/components/RoomHeader.tsx @@ -2,7 +2,7 @@ import { IonFabButton, IonIcon, IonInput, IonTitle, IonToolbar } from '@ionic/re import { add } from 'ionicons/icons'; import React, { useState } from 'react'; import { useHistory } from 'react-router'; -import { db, timestamp, arrayUnion } from '../services/firebase'; +import { db, arrayUnion } from '../services/firebase'; import './RoomHeader.css'; type RoomHeaderProps = { @@ -25,7 +25,7 @@ const RoomHeader: React.FC = ({ roomId, userId, ownerId }) => { .collection('rooms') .doc(roomId) .update({ - requests: arrayUnion({ createdAt: timestamp, senderId: userId, type: 'change' }), + requests: arrayUnion({ createdAt: Date.now(), senderId: userId, time: 0, type: 'change' }), }); setVideoUrl(''); diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx index 5325e09..b40cb8d 100644 --- a/src/components/VideoPlayer.tsx +++ b/src/components/VideoPlayer.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import ReactPlayer from 'react-player'; -import { db, timestamp, arrayUnion } from '../services/firebase'; +import { db, arrayUnion } from '../services/firebase'; import { SYNC_MARGIN } from '../services/utilities'; type VideoPlayerProps = { @@ -21,12 +21,16 @@ const VideoPlayer: React.FC = ({ ownerId, userId, roomId }) => if (ownerId === userId) { const currTime = player.current?.getCurrentTime(); if (currTime !== undefined) { + await db.collection('states').doc(roomId).update({ + isPlaying: true, + time: currTime, + }); + await db .collection('rooms') .doc(roomId) .update({ - requests: arrayUnion({ createdAt: timestamp, senderId: userId, type: 'play' }), - state: { isPlaying: true, time: currTime }, + requests: arrayUnion({ createdAt: Date.now(), senderId: userId, time: currTime, type: 'play' }), }); } } @@ -38,12 +42,16 @@ const VideoPlayer: React.FC = ({ ownerId, userId, roomId }) => if (ownerId === userId) { const currTime = player.current?.getCurrentTime(); if (currTime !== undefined) { + await db.collection('states').doc(roomId).update({ + isPlaying: false, + time: currTime, + }); + await db .collection('rooms') .doc(roomId) .update({ - requests: arrayUnion({ createdAt: timestamp, senderId: userId, type: 'pause' }), - state: { isPlaying: false, time: currTime }, + requests: arrayUnion({ createdAt: Date.now(), senderId: userId, time: currTime, type: 'pause' }), }); } } @@ -52,11 +60,11 @@ const VideoPlayer: React.FC = ({ ownerId, userId, roomId }) => // Request an update after buffering is finished (member only) const onBufferEnd = () => { if (ownerId !== userId) { - db.collection('rooms').doc(roomId).collection('messages').add({ - createdAt: timestamp, - senderId: userId, - type: 'updateState', - }); + db.collection('rooms') + .doc(roomId) + .update({ + requests: arrayUnion({ createdAt: Date.now(), senderId: userId, time: 0, type: 'updateState' }), + }); } }; @@ -85,7 +93,7 @@ const VideoPlayer: React.FC = ({ ownerId, userId, roomId }) => player.current?.seekTo(realTimeState); roomRef.update({ - requests: arrayUnion({ createdAt: timestamp, senderId: userId, type: 'updateState' }), + requests: arrayUnion({ createdAt: Date.now(), senderId: userId, time: 0, type: 'updateState' }), }); } } @@ -108,7 +116,7 @@ const VideoPlayer: React.FC = ({ ownerId, userId, roomId }) => const requests = docSnapshot.data()?.requests; const req = requests[requests.length - 1]; - if (req.type === 'updateState' && req.senderId !== userId) { + if (!!req && req.type === 'updateState' && req.senderId !== userId) { const currTime = player.current?.getCurrentTime(); if (currTime !== undefined) { stateRef.update({ @@ -127,7 +135,7 @@ const VideoPlayer: React.FC = ({ ownerId, userId, roomId }) => // Listen for video URL changes useEffect(() => { - const playlistRef = db.collection('playlist').doc(roomId); + const playlistRef = db.collection('playlists').doc(roomId); const playlistUnsubscribe = playlistRef.onSnapshot((docSnapshot) => { const data = docSnapshot.data(); if (data !== undefined) { diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 7cd8372..f5f35f0 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -17,14 +17,20 @@ const Home: React.FC = () => { const roomId = await db.collection('rooms').add({ createdAt: timestamp, ownerId: userId, - playlist: [{ createdAt: timestamp, url: 'https://www.youtube.com/watch?v=ksHOjnopT_U' }], requests: [], state: { time: 0, isPlaying: false }, }); - await db.collection('chats').doc(roomId.id).set({ messages: [], }); + await db.collection('playlists').doc(roomId.id).set({ + createdAt: timestamp, + url: 'https://www.youtube.com/watch?v=XEfDYMngJeE', + }); + await db.collection('states').doc(roomId.id).set({ + isPlaying: false, + time: 0, + }); // RealTimeDB preparations await rtdb.ref('/rooms/' + roomId.id).set({ userCount: 0 }); diff --git a/src/pages/Room.tsx b/src/pages/Room.tsx index d947d1b..82555c6 100644 --- a/src/pages/Room.tsx +++ b/src/pages/Room.tsx @@ -17,7 +17,7 @@ const Room: React.FC> = ({ match }) => { const [ownerId, setOwnerId] = useState('undefined'); const [loading, setLoading] = useState(true); const [userCount, setUserCount] = useState(0); - const [userList, setUserList] = useState(['']); + const [userList, setUserList] = useState>(new Map()); // Handle logging in useEffect(() => { @@ -63,13 +63,13 @@ const Room: React.FC> = ({ match }) => { // Keep track of online user presence in realtime database rooms roomRef.on('value', async (snapshot) => { // Populate list of users in a room - const set: string[] = []; + const map: Map = new Map(); snapshot.forEach((childSnapshot) => { - if (childSnapshot.key !== 'userCount') { - set.push(childSnapshot.child('name').val()); + if (childSnapshot.key !== null && childSnapshot.key !== 'userCount') { + map.set(childSnapshot.key, childSnapshot.child('name').val()); } }); - setUserList(set); + setUserList(map); if (!snapshot.hasChild(userId)) { // Keep userId in the room as long as a connection from the client exists