started database changes

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

@ -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: {

@ -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<MessagesProps> = ({ ownerId, roomId, userId }) => {
const [chats, setChats] = useState<Message[]>([
{ id: '', senderId: userId, sender: '', content: 'You have joined the room.' },
]); // All received messages
const [messages, setMessages] = useState<RawMessage[]>();
const [prevMessages, setPrevMessages] = useState<Message[]>([]); // Track previous messages
const [newMessages, setNewMessages] = useState<Message[]>([]); // Newly retrieved messages
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
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<MessagesProps> = ({ 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;

@ -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<RoomHeaderProps> = ({ roomId, userId, ownerId, videoId }) => {
const RoomHeader: React.FC<RoomHeaderProps> = ({ 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('');
}

@ -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<VideoPlayerProps> = ({ ownerId, userId, roomId, stateId }) => {
const VideoPlayer: React.FC<VideoPlayerProps> = ({ ownerId, userId, roomId }) => {
const player = useRef<ReactPlayer>(null);
const [playing, setPlaying] = useState(false);
const [videoUrl, setVideoUrl] = useState('');
@ -22,18 +21,13 @@ const VideoPlayer: React.FC<VideoPlayerProps> = ({ 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<VideoPlayerProps> = ({ 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<VideoPlayerProps> = ({ 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<VideoPlayerProps> = ({ 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]);

@ -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) {

@ -17,34 +17,6 @@ const Room: React.FC<RouteComponentProps<{ roomId: string }>> = ({ 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<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(() => {
if (userId !== '' && validRoom) {
const populateRoom = () => {
@ -143,7 +131,7 @@ const Room: React.FC<RouteComponentProps<{ roomId: string }>> = ({ match }) => {
return (
<IonPage>
<IonHeader>
<RoomHeader roomId={roomId} videoId={videoId} ownerId={ownerId} userId={userId}></RoomHeader>
<RoomHeader roomId={roomId} ownerId={ownerId} userId={userId}></RoomHeader>
</IonHeader>
{loading ? (
<IonContent className="ion-padding">Loading...</IonContent>
@ -151,7 +139,7 @@ const Room: React.FC<RouteComponentProps<{ roomId: string }>> = ({ match }) => {
<IonGrid class="room-grid">
<IonRow class="room-row">
<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 size="12" sizeLg="3" class="chat-col">
<Chat ownerId={ownerId} roomId={roomId} userId={userId}></Chat>

Loading…
Cancel
Save