started database changes

pull/3/head
Simon Huang 5 years ago
parent 1017155720
commit 477790d471

@ -1,41 +1,57 @@
// Database schema // 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 // Firestore
const collection = { const collection = {
rooms: [ chats: [
{ {
roomId: { roomId: {
createdAt: 'timestamp',
ownerId: 'userId',
messages: [ messages: [
{ {
messageId: { content: 'Message content',
createdAt: 'timestamp', createdAt: 'timestamp',
content: 'Message content', senderId: 'userId',
senderId: 'userId',
type: 'type',
},
},
],
playlist: [
{
videoId: {
createdAt: 'timestamp',
url: 'https://youtube.com',
},
}, },
], ],
states: [ },
},
],
playlists: [
{
roomId: {
createdAt: 'timestamp',
url: 'https://youtube.com',
},
},
],
rooms: [
{
roomId: {
createdAt: 'timestamp',
ownerId: 'userId',
requests: [
{ {
stateId: { createdAt: 'timestamp',
time: 'timestamp', type: 'updateState',
isPlaying: true, senderId: 'userId',
},
}, },
], ],
}, },
}, },
], ],
states: [
{
roomId: {
time: 'timestamp',
isPlaying: true,
},
},
],
users: [ users: [
{ {
userId: { userId: {

@ -1,6 +1,6 @@
import { IonCol, IonContent, IonGrid, IonRow } from '@ionic/react'; import { IonCol, IonContent, IonGrid, IonRow } from '@ionic/react';
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { currTime, db } from '../services/firebase'; import { currTime, db, TimestampType } from '../services/firebase';
import './Messages.css'; import './Messages.css';
type MessagesProps = { type MessagesProps = {
@ -16,55 +16,30 @@ type Message = {
content: string; content: string;
}; };
type RawMessage = {
content: string;
createdAt: TimestampType;
senderId: string;
};
const Messages: React.FC<MessagesProps> = ({ ownerId, roomId, userId }) => { const Messages: React.FC<MessagesProps> = ({ ownerId, roomId, userId }) => {
const [chats, setChats] = useState<Message[]>([ const [chats, setChats] = useState<Message[]>([
{ id: '', senderId: userId, sender: '', content: 'You have joined the room.' }, { id: '', senderId: userId, sender: '', content: 'You have joined the room.' },
]); // All received messages ]); // All received messages
const [messages, setMessages] = useState<RawMessage[]>();
const [prevMessages, setPrevMessages] = useState<Message[]>([]); // Track previous messages const [prevMessages, setPrevMessages] = useState<Message[]>([]); // Track previous messages
const [newMessages, setNewMessages] = useState<Message[]>([]); // Newly retrieved messages
const contentRef = useRef<HTMLIonContentElement>(null); const contentRef = useRef<HTMLIonContentElement>(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 // Listen for new messages
useEffect(() => { useEffect(() => {
const chatUnsubscribe = db const chatUnsubscribe = db
.collection('rooms') .collection('chats')
.doc(roomId) .doc(roomId)
.collection('messages') .onSnapshot((docSnapshot) => {
.orderBy('createdAt') const data = docSnapshot.data();
.where('createdAt', '>', currTime) if (data !== undefined) {
.onSnapshot(async (querySnapshot) => { const messages = data.messages;
let newMsgs: Message[] = []; setMessages(messages);
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);
} }
}); });
@ -73,6 +48,10 @@ const Messages: React.FC<MessagesProps> = ({ ownerId, roomId, userId }) => {
}; };
}, [roomId, ownerId]); }, [roomId, ownerId]);
// Listen for list of users in the room
useEffect(() => {}, [messages]);
// Always scroll to most recent chat message (bottom) // Always scroll to most recent chat message (bottom)
useEffect(() => { useEffect(() => {
let content = contentRef.current; let content = contentRef.current;

@ -2,34 +2,31 @@ import { IonFabButton, IonIcon, IonInput, IonTitle, IonToolbar } from '@ionic/re
import { add } from 'ionicons/icons'; import { add } from 'ionicons/icons';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import { db, timestamp } from '../services/firebase'; import { db, timestamp, arrayUnion } from '../services/firebase';
import './RoomHeader.css'; import './RoomHeader.css';
type RoomHeaderProps = { type RoomHeaderProps = {
roomId: string; roomId: string;
userId: string; userId: string;
ownerId: string; ownerId: string;
videoId: string;
}; };
const RoomHeader: React.FC<RoomHeaderProps> = ({ roomId, userId, ownerId, videoId }) => { const RoomHeader: React.FC<RoomHeaderProps> = ({ roomId, userId, ownerId }) => {
const [videoUrl, setVideoUrl] = useState(''); const [videoUrl, setVideoUrl] = useState('');
let history = useHistory(); let history = useHistory();
const onSubmit = async () => { const onSubmit = async () => {
if (userId === ownerId) { if (userId === ownerId && videoUrl !== '') {
if (videoUrl !== '') { await db.collection('playlists').doc(roomId).update({
await db.collection('rooms').doc(roomId).collection('playlist').doc(videoId).update({ url: videoUrl,
url: videoUrl, });
});
await db.collection('rooms').doc(roomId).collection('messages').add({ await db
createdAt: timestamp, .collection('rooms')
senderId: userId, .doc(roomId)
content: 'changed the video', .update({
type: 'change', requests: arrayUnion({ createdAt: timestamp, senderId: userId, type: 'change' }),
}); });
}
setVideoUrl(''); setVideoUrl('');
} }

@ -1,16 +1,15 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import ReactPlayer from 'react-player'; import ReactPlayer from 'react-player';
import { db, timestamp } from '../services/firebase'; import { db, timestamp, arrayUnion } from '../services/firebase';
import { secondsToTimestamp, SYNC_MARGIN } from '../services/utilities'; import { SYNC_MARGIN } from '../services/utilities';
type VideoPlayerProps = { type VideoPlayerProps = {
ownerId: string; ownerId: string;
userId: string; userId: string;
roomId: string; roomId: string;
stateId: string;
}; };
const VideoPlayer: React.FC<VideoPlayerProps> = ({ ownerId, userId, roomId, stateId }) => { const VideoPlayer: React.FC<VideoPlayerProps> = ({ ownerId, userId, roomId }) => {
const player = useRef<ReactPlayer>(null); const player = useRef<ReactPlayer>(null);
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
const [videoUrl, setVideoUrl] = useState(''); const [videoUrl, setVideoUrl] = useState('');
@ -22,18 +21,13 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ ownerId, userId, roomId, stat
if (ownerId === userId) { if (ownerId === userId) {
const currTime = player.current?.getCurrentTime(); const currTime = player.current?.getCurrentTime();
if (currTime !== undefined) { if (currTime !== undefined) {
const roomRef = db.collection('rooms').doc(roomId); await db
await roomRef.collection('messages').add({ .collection('rooms')
createdAt: timestamp, .doc(roomId)
senderId: userId, .update({
content: 'started playing the video from ' + secondsToTimestamp(currTime), requests: arrayUnion({ createdAt: timestamp, senderId: userId, type: 'play' }),
type: 'play', state: { isPlaying: true, time: currTime },
}); });
await roomRef.collection('states').doc(stateId).update({
time: currTime,
isPlaying: true,
});
} }
} }
}; };
@ -44,18 +38,13 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ ownerId, userId, roomId, stat
if (ownerId === userId) { if (ownerId === userId) {
const currTime = player.current?.getCurrentTime(); const currTime = player.current?.getCurrentTime();
if (currTime !== undefined) { if (currTime !== undefined) {
const roomRef = db.collection('rooms').doc(roomId); await db
await roomRef.collection('messages').add({ .collection('rooms')
createdAt: timestamp, .doc(roomId)
senderId: userId, .update({
content: 'paused the video at ' + secondsToTimestamp(currTime), requests: arrayUnion({ createdAt: timestamp, senderId: userId, type: 'pause' }),
type: 'pause', state: { isPlaying: false, time: currTime },
}); });
await roomRef.collection('states').doc(stateId).update({
time: currTime,
isPlaying: false,
});
} }
} }
}; };
@ -71,37 +60,33 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ ownerId, userId, roomId, stat
} }
}; };
// Listen for video state updates (member only) // Subscribe member only listener
useEffect(() => { useEffect(() => {
if (ownerId !== userId) { if (ownerId !== userId) {
const stateRef = db.collection('rooms').doc(roomId).collection('states'); const stateRef = db.collection('states').doc(roomId);
const stateUnsubscribe = stateRef.onSnapshot((querySnapshot) => { const roomRef = db.collection('rooms').doc(roomId);
const changes = querySnapshot.docChanges();
const change = changes[changes.length - 1]; // Add a listener to 'states' collection, listening for video state changes
if (change.type === 'modified') { const stateUnsubscribe = stateRef.onSnapshot((docSnapshot) => {
const data = change.doc.data(); const docData = docSnapshot.data();
const currTime = player.current?.getCurrentTime();
if (currTime !== undefined) { const currTime = player.current?.getCurrentTime();
setPlaying(data.isPlaying); if (currTime !== undefined) {
if (!data.isPlaying) { const realPlayState: boolean = docData?.isPlaying;
player.current?.seekTo(data.time); const realTimeState: number = docData?.time;
} setPlaying(realPlayState);
// Continue requesting an update on the video state, until synced if (allowUpdate && Math.abs(currTime - realTimeState) > SYNC_MARGIN / 1000 && realPlayState) {
if (allowUpdate && Math.abs(currTime - data.time) > SYNC_MARGIN / 1000 && data.isPlaying) { setAllowUpdate(false);
setAllowUpdate(false); setTimeout(() => {
setTimeout(() => { // throttle update requests
setAllowUpdate(true); setAllowUpdate(true);
}, 3000); }, 3000);
player.current?.seekTo(data.time); player.current?.seekTo(realTimeState);
console.log('diff: ' + Math.abs(currTime - data.time)); roomRef.update({
db.collection('rooms').doc(roomId).collection('messages').add({ requests: arrayUnion({ createdAt: timestamp, senderId: userId, type: 'updateState' }),
createdAt: timestamp, });
senderId: userId,
type: 'updateState',
});
}
} }
} }
}); });
@ -110,48 +95,48 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ ownerId, userId, roomId, stat
stateUnsubscribe(); stateUnsubscribe();
}; };
} }
}, [ownerId, userId, roomId, allowUpdate]); }, [ownerId, roomId, userId, allowUpdate]);
// Listen for video updateState requests (owner only) // Subscribe owner only listener
useEffect(() => { useEffect(() => {
if (ownerId === userId) { if (ownerId === userId) {
const roomRef = db.collection('rooms').doc(roomId); const roomRef = db.collection('rooms').doc(roomId);
const videoUnsubscribe = roomRef const stateRef = db.collection('states').doc(roomId);
.collection('messages')
.where('type', '==', 'updateState') // Add a listener to 'rooms' collection, listening for updateState requests
.onSnapshot((querySnapshot) => { const roomUnsubscribe = roomRef.onSnapshot((docSnapshot) => {
const changes = querySnapshot.docChanges(); const requests = docSnapshot.data()?.requests;
const change = changes[changes.length - 1]; const req = requests[requests.length - 1];
if (change?.type === 'added') {
const currTime = player.current?.getCurrentTime(); if (req.type === 'updateState' && req.senderId !== userId) {
if (currTime !== undefined) { const currTime = player.current?.getCurrentTime();
roomRef.collection('states').doc(stateId).update({ if (currTime !== undefined) {
time: currTime, stateRef.update({
isPlaying: true, time: currTime,
}); isPlaying: true,
} });
} }
}); }
});
return () => { return () => {
videoUnsubscribe(); roomUnsubscribe();
}; };
} }
}, [ownerId, roomId, userId, stateId]); }, [ownerId, roomId, userId]);
// Listen for video URL changes // Listen for video URL changes
useEffect(() => { useEffect(() => {
const urlRef = db.collection('rooms').doc(roomId).collection('playlist'); const playlistRef = db.collection('playlist').doc(roomId);
const urlUnsubscribe = urlRef.onSnapshot((querySnapshot) => { const playlistUnsubscribe = playlistRef.onSnapshot((docSnapshot) => {
const changes = querySnapshot.docChanges(); const data = docSnapshot.data();
for (const change of changes) { if (data !== undefined) {
const data = change.doc.data();
setVideoUrl(data.url); setVideoUrl(data.url);
} }
}); });
return () => { return () => {
urlUnsubscribe(); playlistUnsubscribe();
}; };
}, [roomId]); }, [roomId]);

@ -13,29 +13,28 @@ const Home: React.FC = () => {
// Populate both Firestore and RealTimeDB before navigating to room // Populate both Firestore and RealTimeDB before navigating to room
const createRoom = async () => { const createRoom = async () => {
// Firestore preparations
const roomId = await db.collection('rooms').add({ const roomId = await db.collection('rooms').add({
createdAt: timestamp, createdAt: timestamp,
ownerId: userId, 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 db.collection('chats').doc(roomId.id).set({
await roomRef.collection('playlist').add({ messages: [],
createdAt: timestamp,
url: 'https://www.youtube.com/watch?v=ksHOjnopT_U',
});
await roomRef.collection('states').add({
time: 0,
isPlaying: false,
}); });
// RealTimeDB preparations
await rtdb.ref('/rooms/' + roomId.id).set({ userCount: 0 }); await rtdb.ref('/rooms/' + roomId.id).set({ userCount: 0 });
await rtdb.ref('/available/' + roomId.id).set({ name: 'Room Name', createdAt: new Date().toISOString() }); await rtdb.ref('/available/' + roomId.id).set({ name: 'Room Name', createdAt: new Date().toISOString() });
const path = '/room/' + roomId.id; const path = '/room/' + roomId.id;
return history.push(path); return history.push(path);
}; };
// Sign in anonymously before showing Create Room button // Sign in anonymously before finishing loading page content
useEffect(() => { useEffect(() => {
const authUnsubscribe = auth.onAuthStateChanged(async (user) => { const authUnsubscribe = auth.onAuthStateChanged(async (user) => {
if (user) { if (user) {

@ -17,34 +17,6 @@ const Room: React.FC<RouteComponentProps<{ roomId: string }>> = ({ match }) => {
const [ownerId, setOwnerId] = useState('undefined'); const [ownerId, setOwnerId] = useState('undefined');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [userCount, setUserCount] = useState(0); 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 // Handle logging in
useEffect(() => { useEffect(() => {
@ -64,7 +36,23 @@ const Room: React.FC<RouteComponentProps<{ roomId: string }>> = ({ 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(() => { useEffect(() => {
if (userId !== '' && validRoom) { if (userId !== '' && validRoom) {
const populateRoom = () => { const populateRoom = () => {
@ -143,7 +131,7 @@ const Room: React.FC<RouteComponentProps<{ roomId: string }>> = ({ match }) => {
return ( return (
<IonPage> <IonPage>
<IonHeader> <IonHeader>
<RoomHeader roomId={roomId} videoId={videoId} ownerId={ownerId} userId={userId}></RoomHeader> <RoomHeader roomId={roomId} ownerId={ownerId} userId={userId}></RoomHeader>
</IonHeader> </IonHeader>
{loading ? ( {loading ? (
<IonContent className="ion-padding">Loading...</IonContent> <IonContent className="ion-padding">Loading...</IonContent>
@ -151,7 +139,7 @@ const Room: React.FC<RouteComponentProps<{ roomId: string }>> = ({ match }) => {
<IonGrid class="room-grid"> <IonGrid class="room-grid">
<IonRow class="room-row"> <IonRow class="room-row">
<IonCol size="12" sizeLg="9" class="player-col"> <IonCol size="12" sizeLg="9" class="player-col">
<VideoPlayer ownerId={ownerId} userId={userId} roomId={roomId} stateId={stateId}></VideoPlayer> <VideoPlayer ownerId={ownerId} userId={userId} roomId={roomId}></VideoPlayer>
</IonCol> </IonCol>
<IonCol size="12" sizeLg="3" class="chat-col"> <IonCol size="12" sizeLg="3" class="chat-col">
<Chat ownerId={ownerId} roomId={roomId} userId={userId}></Chat> <Chat ownerId={ownerId} roomId={roomId} userId={userId}></Chat>

Loading…
Cancel
Save