diff --git a/schema.ts b/schema.ts index a32413f..80b231e 100644 --- a/schema.ts +++ b/schema.ts @@ -1,41 +1,57 @@ // Database schema +/** + * Notes: + * - hierarchy consists of many collections, but shallow depth in each collection. This works nicely with subscribing + * listeners in useEffect. Also if collections are combined, single document updates may become too frequent. + */ + // Firestore const collection = { - rooms: [ + chats: [ { roomId: { - createdAt: 'timestamp', - ownerId: 'userId', messages: [ { - messageId: { - createdAt: 'timestamp', - content: 'Message content', - senderId: 'userId', - type: 'type', - }, - }, - ], - playlist: [ - { - videoId: { - createdAt: 'timestamp', - url: 'https://youtube.com', - }, + content: 'Message content', + createdAt: 'timestamp', + senderId: 'userId', }, ], - states: [ + }, + }, + ], + playlists: [ + { + roomId: { + createdAt: 'timestamp', + url: 'https://youtube.com', + }, + }, + ], + rooms: [ + { + roomId: { + createdAt: 'timestamp', + ownerId: 'userId', + requests: [ { - stateId: { - time: 'timestamp', - isPlaying: true, - }, + createdAt: 'timestamp', + type: 'updateState', + senderId: 'userId', }, ], }, }, ], + states: [ + { + roomId: { + time: 'timestamp', + isPlaying: true, + }, + }, + ], users: [ { userId: { diff --git a/src/components/Messages.tsx b/src/components/Messages.tsx index c94d5b9..2027e47 100644 --- a/src/components/Messages.tsx +++ b/src/components/Messages.tsx @@ -1,6 +1,6 @@ import { IonCol, IonContent, IonGrid, IonRow } from '@ionic/react'; import React, { useEffect, useRef, useState } from 'react'; -import { currTime, db } from '../services/firebase'; +import { currTime, db, TimestampType } from '../services/firebase'; import './Messages.css'; type MessagesProps = { @@ -16,55 +16,30 @@ type Message = { content: string; }; +type RawMessage = { + content: string; + createdAt: TimestampType; + 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 [newMessages, setNewMessages] = useState([]); // Newly retrieved messages const contentRef = useRef(null); - // Only update array containing all messages ('chats') when there are new messages - useEffect(() => { - if (prevMessages !== newMessages) { - setPrevMessages(newMessages); - setChats([...chats, ...newMessages]); - } - }, [prevMessages, newMessages, chats]); - // Listen for new messages useEffect(() => { const chatUnsubscribe = db - .collection('rooms') + .collection('chats') .doc(roomId) - .collection('messages') - .orderBy('createdAt') - .where('createdAt', '>', currTime) - .onSnapshot(async (querySnapshot) => { - let newMsgs: Message[] = []; - const changes = querySnapshot.docChanges(); - for (const change of changes) { - if (change.type === 'added') { - const data = change.doc.data(); - const user = await db.collection('users').doc(data.senderId).get(); - if (data.type !== 'updateState') { - let displayName = user.data()?.name; - if (data.senderId === ownerId) { - displayName = displayName + ' 👑'; - } - - newMsgs.push({ - id: change.doc.id, - senderId: data.senderId, - sender: displayName, - content: data.content, - }); - } - } - } - - if (newMsgs.length !== 0) { - setNewMessages(newMsgs); + .onSnapshot((docSnapshot) => { + const data = docSnapshot.data(); + if (data !== undefined) { + const messages = data.messages; + setMessages(messages); } }); @@ -73,6 +48,10 @@ const Messages: React.FC = ({ ownerId, roomId, userId }) => { }; }, [roomId, ownerId]); + // Listen for list of users in the room + + useEffect(() => {}, [messages]); + // Always scroll to most recent chat message (bottom) useEffect(() => { let content = contentRef.current; diff --git a/src/components/RoomHeader.tsx b/src/components/RoomHeader.tsx index da4af52..cb3efc9 100644 --- a/src/components/RoomHeader.tsx +++ b/src/components/RoomHeader.tsx @@ -2,34 +2,31 @@ 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 } from '../services/firebase'; +import { db, timestamp, arrayUnion } from '../services/firebase'; import './RoomHeader.css'; type RoomHeaderProps = { roomId: string; userId: string; ownerId: string; - videoId: string; }; -const RoomHeader: React.FC = ({ roomId, userId, ownerId, videoId }) => { +const RoomHeader: React.FC = ({ roomId, userId, ownerId }) => { const [videoUrl, setVideoUrl] = useState(''); let history = useHistory(); const onSubmit = async () => { - if (userId === ownerId) { - if (videoUrl !== '') { - await db.collection('rooms').doc(roomId).collection('playlist').doc(videoId).update({ - url: videoUrl, - }); + if (userId === ownerId && videoUrl !== '') { + await db.collection('playlists').doc(roomId).update({ + url: videoUrl, + }); - await db.collection('rooms').doc(roomId).collection('messages').add({ - createdAt: timestamp, - senderId: userId, - content: 'changed the video', - type: 'change', + await db + .collection('rooms') + .doc(roomId) + .update({ + requests: arrayUnion({ createdAt: timestamp, senderId: userId, type: 'change' }), }); - } setVideoUrl(''); } diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx index be66e69..5325e09 100644 --- a/src/components/VideoPlayer.tsx +++ b/src/components/VideoPlayer.tsx @@ -1,16 +1,15 @@ import React, { useEffect, useRef, useState } from 'react'; import ReactPlayer from 'react-player'; -import { db, timestamp } from '../services/firebase'; -import { secondsToTimestamp, SYNC_MARGIN } from '../services/utilities'; +import { db, timestamp, arrayUnion } from '../services/firebase'; +import { SYNC_MARGIN } from '../services/utilities'; type VideoPlayerProps = { ownerId: string; userId: string; roomId: string; - stateId: string; }; -const VideoPlayer: React.FC = ({ ownerId, userId, roomId, stateId }) => { +const VideoPlayer: React.FC = ({ ownerId, userId, roomId }) => { const player = useRef(null); const [playing, setPlaying] = useState(false); const [videoUrl, setVideoUrl] = useState(''); @@ -22,18 +21,13 @@ const VideoPlayer: React.FC = ({ ownerId, userId, roomId, stat if (ownerId === userId) { const currTime = player.current?.getCurrentTime(); if (currTime !== undefined) { - const roomRef = db.collection('rooms').doc(roomId); - await roomRef.collection('messages').add({ - createdAt: timestamp, - senderId: userId, - content: 'started playing the video from ' + secondsToTimestamp(currTime), - type: 'play', - }); - - await roomRef.collection('states').doc(stateId).update({ - time: currTime, - isPlaying: true, - }); + await db + .collection('rooms') + .doc(roomId) + .update({ + requests: arrayUnion({ createdAt: timestamp, senderId: userId, type: 'play' }), + state: { isPlaying: true, time: currTime }, + }); } } }; @@ -44,18 +38,13 @@ const VideoPlayer: React.FC = ({ ownerId, userId, roomId, stat if (ownerId === userId) { const currTime = player.current?.getCurrentTime(); if (currTime !== undefined) { - const roomRef = db.collection('rooms').doc(roomId); - await roomRef.collection('messages').add({ - createdAt: timestamp, - senderId: userId, - content: 'paused the video at ' + secondsToTimestamp(currTime), - type: 'pause', - }); - - await roomRef.collection('states').doc(stateId).update({ - time: currTime, - isPlaying: false, - }); + await db + .collection('rooms') + .doc(roomId) + .update({ + requests: arrayUnion({ createdAt: timestamp, senderId: userId, type: 'pause' }), + state: { isPlaying: false, time: currTime }, + }); } } }; @@ -71,37 +60,33 @@ const VideoPlayer: React.FC = ({ ownerId, userId, roomId, stat } }; - // Listen for video state updates (member only) + // Subscribe member only listener useEffect(() => { if (ownerId !== userId) { - const stateRef = db.collection('rooms').doc(roomId).collection('states'); - const stateUnsubscribe = stateRef.onSnapshot((querySnapshot) => { - const changes = querySnapshot.docChanges(); - const change = changes[changes.length - 1]; - if (change.type === 'modified') { - const data = change.doc.data(); - const currTime = player.current?.getCurrentTime(); - if (currTime !== undefined) { - setPlaying(data.isPlaying); - if (!data.isPlaying) { - player.current?.seekTo(data.time); - } - - // Continue requesting an update on the video state, until synced - if (allowUpdate && Math.abs(currTime - data.time) > SYNC_MARGIN / 1000 && data.isPlaying) { - setAllowUpdate(false); - setTimeout(() => { - setAllowUpdate(true); - }, 3000); - - player.current?.seekTo(data.time); - console.log('diff: ' + Math.abs(currTime - data.time)); - db.collection('rooms').doc(roomId).collection('messages').add({ - createdAt: timestamp, - senderId: userId, - type: 'updateState', - }); - } + const stateRef = db.collection('states').doc(roomId); + const roomRef = db.collection('rooms').doc(roomId); + + // Add a listener to 'states' collection, listening for video state changes + const stateUnsubscribe = stateRef.onSnapshot((docSnapshot) => { + const docData = docSnapshot.data(); + + const currTime = player.current?.getCurrentTime(); + if (currTime !== undefined) { + const realPlayState: boolean = docData?.isPlaying; + const realTimeState: number = docData?.time; + setPlaying(realPlayState); + + if (allowUpdate && Math.abs(currTime - realTimeState) > SYNC_MARGIN / 1000 && realPlayState) { + setAllowUpdate(false); + setTimeout(() => { + // throttle update requests + setAllowUpdate(true); + }, 3000); + + player.current?.seekTo(realTimeState); + roomRef.update({ + requests: arrayUnion({ createdAt: timestamp, senderId: userId, type: 'updateState' }), + }); } } }); @@ -110,48 +95,48 @@ const VideoPlayer: React.FC = ({ ownerId, userId, roomId, stat stateUnsubscribe(); }; } - }, [ownerId, userId, roomId, allowUpdate]); + }, [ownerId, roomId, userId, allowUpdate]); - // Listen for video updateState requests (owner only) + // Subscribe owner only listener useEffect(() => { if (ownerId === userId) { const roomRef = db.collection('rooms').doc(roomId); - const videoUnsubscribe = roomRef - .collection('messages') - .where('type', '==', 'updateState') - .onSnapshot((querySnapshot) => { - const changes = querySnapshot.docChanges(); - const change = changes[changes.length - 1]; - if (change?.type === 'added') { - const currTime = player.current?.getCurrentTime(); - if (currTime !== undefined) { - roomRef.collection('states').doc(stateId).update({ - time: currTime, - isPlaying: true, - }); - } + const stateRef = db.collection('states').doc(roomId); + + // Add a listener to 'rooms' collection, listening for updateState requests + const roomUnsubscribe = roomRef.onSnapshot((docSnapshot) => { + const requests = docSnapshot.data()?.requests; + const req = requests[requests.length - 1]; + + if (req.type === 'updateState' && req.senderId !== userId) { + const currTime = player.current?.getCurrentTime(); + if (currTime !== undefined) { + stateRef.update({ + time: currTime, + isPlaying: true, + }); } - }); + } + }); return () => { - videoUnsubscribe(); + roomUnsubscribe(); }; } - }, [ownerId, roomId, userId, stateId]); + }, [ownerId, roomId, userId]); // Listen for video URL changes useEffect(() => { - const urlRef = db.collection('rooms').doc(roomId).collection('playlist'); - const urlUnsubscribe = urlRef.onSnapshot((querySnapshot) => { - const changes = querySnapshot.docChanges(); - for (const change of changes) { - const data = change.doc.data(); + const playlistRef = db.collection('playlist').doc(roomId); + const playlistUnsubscribe = playlistRef.onSnapshot((docSnapshot) => { + const data = docSnapshot.data(); + if (data !== undefined) { setVideoUrl(data.url); } }); return () => { - urlUnsubscribe(); + playlistUnsubscribe(); }; }, [roomId]); diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 581db45..0151061 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -13,29 +13,28 @@ const Home: React.FC = () => { // Populate both Firestore and RealTimeDB before navigating to room const createRoom = async () => { + // Firestore preparations 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 }, }); - const roomRef = db.collection('rooms').doc(roomId.id); - await roomRef.collection('playlist').add({ - createdAt: timestamp, - url: 'https://www.youtube.com/watch?v=ksHOjnopT_U', - }); - - await roomRef.collection('states').add({ - time: 0, - isPlaying: false, + await db.collection('chats').doc(roomId.id).set({ + messages: [], }); + // RealTimeDB preparations await rtdb.ref('/rooms/' + roomId.id).set({ userCount: 0 }); await rtdb.ref('/available/' + roomId.id).set({ name: 'Room Name', createdAt: new Date().toISOString() }); const path = '/room/' + roomId.id; + return history.push(path); }; - // Sign in anonymously before showing Create Room button + // Sign in anonymously before finishing loading page content useEffect(() => { const authUnsubscribe = auth.onAuthStateChanged(async (user) => { if (user) { diff --git a/src/pages/Room.tsx b/src/pages/Room.tsx index b001439..cb57ed8 100644 --- a/src/pages/Room.tsx +++ b/src/pages/Room.tsx @@ -17,34 +17,6 @@ const Room: React.FC> = ({ match }) => { const [ownerId, setOwnerId] = useState('undefined'); const [loading, setLoading] = useState(true); const [userCount, setUserCount] = useState(0); - const [videoId, setVideoId] = useState(''); - const [stateId, setStateId] = useState(''); - - // Verify that the roomId exists in db - useEffect(() => { - const fetchRoomAndVid = async () => { - const roomRef = db.collection('rooms').doc(roomId); - const room = await roomRef.get(); - if (!room.exists) { - history.push('/'); - } else { - // Set videoId for RoomHeader component - const playlistSnapshot = await roomRef.collection('playlist').get(); - const vidId = playlistSnapshot.docs[0].id; - setVideoId(vidId); - - // Set stateId for VideoPlayer component - const stateSnapshot = await roomRef.collection('states').get(); - const vidStateId = stateSnapshot.docs[0].id; - setStateId(vidStateId); - - setOwnerId(room.data()?.ownerId); - setValidRoom(true); - } - }; - - fetchRoomAndVid(); - }, [history, roomId]); // Handle logging in useEffect(() => { @@ -64,7 +36,23 @@ const Room: React.FC> = ({ match }) => { }; }, []); - // Subscribe listeners + // Verify that the roomId exists in db + useEffect(() => { + const fetchRoomAndVid = async () => { + const roomRef = db.collection('rooms').doc(roomId); + const room = await roomRef.get(); + if (!room.exists) { + history.push('/'); + } else { + setOwnerId(room.data()?.ownerId); + setValidRoom(true); + } + }; + + fetchRoomAndVid(); + }, [history, roomId]); + + // Subscribe RealTimeDB listeners useEffect(() => { if (userId !== '' && validRoom) { const populateRoom = () => { @@ -143,7 +131,7 @@ const Room: React.FC> = ({ match }) => { return ( - + {loading ? ( Loading... @@ -151,7 +139,7 @@ const Room: React.FC> = ({ match }) => { - +