WebRTC Perfect Negotiation Vue 2 - javascript

I'm implementing WebRTC Perfect Negotiation in my Vue 2 application. The app will have multiple viewers and a single streamer.
After a lot of logging and debugging, I've resolved some of the problems that I was having. I removed the TURN server in the iceServers configuration, and that allowed the ICE Candidate gathering to finish. Previously it was stuck at "gathering". Now, the two peers have exchanged local/remote descriptions and added ICE candidates, but there still is not a change in the connectionState.
Here is my RTCPeerConnection object:
RTCPeerConnection
canTrickleIceCandidates: true
connectionState: "new"
currentLocalDescription: RTCSessionDescription {type: 'offer', sdp: 'v=0\r\no=- 4764627134364341061 2 IN IP4 127.0.0.1\r\ns…4754 label:f12fee59-268c-4bc3-88c1-8ac27aec8a9c\r\n'}
currentRemoteDescription: RTCSessionDescription {type: 'answer', sdp: 'v=0\r\no=- 3069477756847576830 2 IN IP4 127.0.0.1\r\ns…Nd1pO\r\na=ssrc:1149065622 cname:VquHLgyd/d3Nd1pO\r\n'}
iceConnectionState: "new"
iceGatheringState: "complete"
localDescription: RTCSessionDescription {type: 'offer', sdp: 'v=0\r\no=- 4764627134364341061 2 IN IP4 127.0.0.1\r\ns…4754 label:f12fee59-268c-4bc3-88c1-8ac27aec8a9c\r\n'}
onaddstream: null
onconnectionstatechange: ƒ (e)
ondatachannel: null
onicecandidate: ƒ (_ref)
onicecandidateerror: ƒ (e)
oniceconnectionstatechange: ƒ ()
onicegatheringstatechange: ƒ (e)
onnegotiationneeded: ƒ ()
onremovestream: null
onsignalingstatechange: null
ontrack: ƒ (_ref3)
pendingLocalDescription: null
pendingRemoteDescription: null
remoteDescription: RTCSessionDescription {type: 'answer', sdp: 'v=0\r\no=- 3069477756847576830 2 IN IP4 127.0.0.1\r\ns…Nd1pO\r\na=ssrc:1149065622 cname:VquHLgyd/d3Nd1pO\r\n'}
sctp: null
signalingState: "stable"
[[Prototype]]: RTCPeerConnection
Here is LiveStream.vue:
<template>
<div>
<main>
<div>
<div id="video-container">
<h2>LiveStream</h2>
<video id="local-video" ref="localVideo" autoplay="true"></video>
</div>
</div>
</main>
<aside>
<div>
<div>
<p>ViewStream</p>
<div v-for="(item, key) in participants" :key="key">
<Video :videoId="key" :videoStream="participants[key].peerStream" />
</div>
<div></div>
</div>
</div>
</aside>
</div>
</template>
<script>
import { videoConfiguration } from "../mixins/WebRTC";
import Video from "../components/Video.vue";
export default {
name: "LiveStream",
components: {
Video,
},
data() {
return {
participants: {},
localStream: null,
pc: null,
roomInfo: {
room: undefined,
username: "testUser",
},
constraints: {
video: {
width: 450,
height: 348,
},
},
};
},
mixins: [videoConfiguration],
methods: {
async initializeWebRTC(user, desc) {
console.log("initializeWebRTC called", { user, desc });
this.participants[user] = {
...this.participants[user],
pc: this.setupRTCPeerConnection(
new RTCPeerConnection(this.configuration),
user,
this.roomInfo.username,
this.roomInfo.room
),
peerStream: null,
peerVideo: null,
};
for (const track of this.localStream.getTracks()) {
this.participants[user].pc.addTrack(track, this.localStream);
console.log("local track added", track);
}
this.createOffer(
this.participants[user].pc,
user,
this.roomInfo.room,
true
);
this.onIceCandidates(
this.participants[user].pc,
user,
this.roomInfo.room,
true
);
},
createPeerConnection() {
this.pc = new RTCPeerConnection(this.configuration);
},
},
created() {
this.roomInfo.room = this.getRoomName();
},
async mounted() {
this.myVideo = document.getElementById("local-video");
await this.getUserMedia();
await this.getAudioVideo();
this.$socket.client.emit("joinRoom", {
...this.roomInfo,
creator: true,
});
},
beforeDestroy() {
this.pc.close();
this.pc = null;
this.$socket.$unsubscribe("newParticipant");
this.$socket.$unsubscribe("onMessage");
this.$socket.client.emit("leaveRoom", {
to: this.to,
from: this.username,
room: this.roomInfo.room,
});
},
sockets: {
connect() {
console.log("connected socket");
},
newParticipant(userObject) {
if (userObject.username === this.roomInfo.username) return;
this.$set(this.participants, userObject.username, {
user: userObject.username,
});
this.initializeWebRTC(userObject.username);
},
async onMessage({ desc, from, room, candidate }) {
if (from === this.username) return;
try {
if (desc) {
const offerCollision =
desc.type === "offer" &&
(this.makingOffer ||
this.participants[from].pc.signalingState !== "stable");
this.ignoreOffer = !this.isPolitePeer && offerCollision;
if (this.ignoreOffer) {
return;
}
if (desc.type === "offer") {
this.handleAnswer(desc, this.participants[from].pc, from, room);
} else {
this.addRemoteTrack(this.participants[from], from);
await this.setRemoteDescription(desc, this.participants[from].pc);
}
} else if (candidate) {
try {
await this.addCandidate(
this.participants[from].pc,
candidate.candidate
);
} catch (err) {
if (!this.ignoreOffer) {
throw err;
}
}
}
} catch (err) {
console.error(err);
}
},
},
};
</script>
Here is the mixin I created to handle a lot of the connection functionality:
export const videoConfiguration = {
data() {
return {
// Media config
constraints: {
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: false
},
video: {
width: 400,
height: 250
}
},
configuration: {
iceServers: [
{
urls: [
"stun:stun1.l.google.com:19302",
"stun:stun2.l.google.com:19302"
]
}
]
},
offerOptions: {
offerToReceiveAudio: 1,
offerToReceiveVideo: 1
},
myVideo: null,
localStream: null,
username: null,
isPolitePeer: false,
makingOffer: false,
ignoreOffer: false
};
},
async created() {
this.username = await this.getUsername();
},
beforeDestroy() {
this.localStream.getTracks().forEach((track) => track.stop());
},
methods: {
/**
* Get permission to read from user's microphone and camera.
* Returns audio and video streams to be added to video element
*/
async getUserMedia() {
if ("mediaDevices" in navigator) {
try {
const stream = await navigator.mediaDevices.getUserMedia(
this.constraints
);
if ("srcObject" in this.myVideo) {
this.myVideo.srcObject = stream;
this.myVideo.volume = 0;
} else {
this.myVideo.src = stream;
}
this.localStream = stream;
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
}
}
},
getAudioVideo() {
const video = this.localStream.getVideoTracks();
// eslint-disable-next-line no-console
console.log(video);
const audio = this.localStream.getAudioTracks();
// eslint-disable-next-line no-console
console.log(audio);
},
async setRemoteDescription(remoteDesc, pc) {
try {
await pc.setRemoteDescription(remoteDesc);
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
}
},
addCandidate(pc, candidate) {
try {
const rtcIceCandidate = new RTCIceCandidate(candidate);
pc.addIceCandidate(rtcIceCandidate);
console.log(`${this.username} added a candidate`);
} catch (error) {
console.error(
`Error adding a candidate in ${this.username}. Error: ${error}`
);
}
},
onIceCandidates(pc, to, room) {
pc.onicecandidate = ({ candidate }) => {
if (!candidate) return;
this.$socket.client.emit("new-ice-candidate", {
candidate,
to: to,
from: this.username,
room: room
});
};
},
async createOffer(pc, to, room) {
console.log(`${this.roomInfo.username} wants to start a call with ${to}`);
pc.onnegotiationneeded = async () => {
try {
this.makingOffer = true;
await pc.setLocalDescription();
this.sendSignalingMessage(pc.localDescription, true, to, room);
} catch (err) {
console.error(err);
} finally {
this.makingOffer = false;
}
};
},
async createAnswer(pc, to, room) {
try {
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
this.sendSignalingMessage(pc.localDescription, false, to, room);
} catch (error) {
console.error(error);
}
},
async handleAnswer(desc, pc, from, room) {
await this.setRemoteDescription(desc, pc);
this.createAnswer(pc, from, room);
},
sendSignalingMessage(desc, offer, to, room) {
const isOffer = offer ? "offer" : "answer";
// Send the offer to the other peer
if (isOffer === "offer") {
this.$socket.client.emit("offer", {
desc: desc,
to: to,
from: this.username,
room: room,
offer: isOffer
});
} else {
this.$socket.client.emit("answer", {
desc: desc,
to: to,
from: this.username,
room: room,
offer: isOffer
});
}
},
addRemoteTrack(user, video) {
user.peerVideo = user.peerVideo || document.getElementById(video);
user.pc.ontrack = ({ track, streams }) => {
user.peerStream = streams[0];
track.onunmute = () => {
if (user.peerVideo.srcObject) {
return;
}
user.peerVideo.srcObject = streams[0];
};
};
},
/**
* Using handleRemoteTrack temporarily to add the tracks to the RTCPeerConnection
* for ViewStream since the location of pc is different.
* #param {*} user
*/
handleRemoteTrack(pc, user) {
this.peerVideo = document.getElementById(user);
pc.ontrack = ({ track, streams }) => {
this.peerStream = streams[0];
track.onunmute = () => {
if (this.peerVideo.srcObject) {
return;
}
this.peerVideo.srcObject = streams[0];
};
};
},
setupRTCPeerConnection(pc) {
pc.onconnectionstatechange = (e) => {
console.log(
"WebRTC: Signaling State Updated: ",
e.target.signalingState
);
};
pc.oniceconnectionstatechange = () => {
console.log("WebRTC: ICE Connection State Updated");
};
pc.onicegatheringstatechange = (e) => {
console.log(
"WebRTC: ICE Gathering State Updated: ",
e.target.iceGatheringState
);
};
pc.onicecandidateerror = (e) => {
if (e.errorCode === 701) {
console.log("ICE Candidate Error: ", e);
}
};
return pc;
}
}
};
I created a CodeSandbox that has the ViewStream.vue file and the directory structure for how I'm trying to set it up. (It's just too much code to post here.)
When the viewer joins the room created by the streamer, I can see that they exchange offer/answer and ice candidates. However, I still do not see any change in the connectionState or iceConnectionState. Is there a piece that I'm not doing?
One thing I noticed when logging data and digging through chrome://webrtc-internals/ is that the MediaStream ID's don't match.
I log out the tracks after the call to getUserMedia(), and note the track ID's.
This image shows the stream IDs for the caller (top) and the callee (bottom)
I then log when I'm adding the local tracks to the RTCPeerConnection, and they match what was generated for both peers.
Here, the tracks for the streamer are added to the RTCPeerConnection. The IDs match from above.
However, I'm also logging for each peer when I receive a remote track, and that's when the ID's don't match.
I don't know what is generating the ID in this picture. It's different from the ID of the callee in the first picture.
Is that normal behavior? Would the fact that the IDs don't match be the cause of the streams not starting on either end? I don't know what would cause this. The IDs are the same when added to the RTCPeerConnection on either end of the call.
Edit 5/1: I removed the TURN server from my config, and that fixed part of the connection process. Still having a problem getting media to flow between peers. But I can see that I've captured a MediaStream on each side of the connection.

It looks like the connectionState is not updating. There maybe a race condition between creating the offer and answer that is not allowing connectionState to update.
You may want see look into adding a promise on creating the offer and answer, and on when the ice candidates are completed to handle this race condition.

Can I have the Server side code? So that I can recreate whats going on?
Edit:
If you are talking about this in your second question.
I guess it just explains how the collision between a polite and impolite peer is resolved by polite peer becoming callee from caller.
And that is just the definition of Polite Peer and I thing you don't need to worry much about explicitly changing the roles in this context.
A polite peer, essentially, is one which may send out offers, but then responds if an offer arrives from the other peer with "Okay, never mind, drop my offer and I'll consider yours instead. ~ MDN Docs
I hope this answers your Second Question

Found the solution:
I was getting this error: TypeError: Failed to execute 'addIceCandidate' on 'RTCPeerConnection': The provided value is not of type 'RTCIceCandidateInit'.
Googling that led me to this SO article which led me to believe that it was an error that I could safely ignore.
However, the MDN docs said that I'd get a TypeError if I was missing a required part of the object, and that led me to realize that on both sides of the call I was destructuring {candidate}, passing in an incomplete object. So the candidates that were being passed to each party weren't really being added. Once I fixed that everything worked.

Related

ref is stacking in sockets react native

Im trying to build chat application using sockets and everything is working except when im trying to add new session I beilieve it is stacking the reference but I don't know what I'm missing
here is my code
const [sessions, setSessions] = useState([]);
const userSocketRef = useRef(null);
useEffect(() => {
async function getUser() {
const user = await authStorage.getUser();
const URL = "ws://192.168.1.176:3001/" + "users";
if (userSocketRef.current === null) {
userSocketRef.current = io(URL, {
auth: { user: user.uuid },
transports: ["polling", "websocket"],
});
userSocketRef.current.on("disconnect", () => {
console.log("disconnected");
});
userSocketRef.current.on("connect", () => {
console.log("connected");
});
userSocketRef.current.onAny((event, ...args) => {
console.log("event");
});
userSocketRef.current.on("connect_error", (err) => {
console.log("connect_error");
});
}
}
console.log("current sessions after handle: "+Object.keys(sessions))
getUser();
if (userSocketRef.current !== null ) {
userSocketRef.current.on(
"private message",
(message, sessionUuid) => {
console.log("private message");
handleUpdateSession(message, sessionUuid);
}
);
userSocketRef.current.on("new session", async (session) => {
console.log(Object.keys(sessions));
console.log(Object.keys(session));
await handleNewSession(session);
});
}
}, [sessions]);
useEffect(() => {
async function getSessions() {
const user = await authStorage.getUser();
const ret = await getUserSessions(user?.uuid);
setSessions(ret.data.reverse());
}
getSessions();
}, []);
const handleNewSession = async (newSession) => {
console.log("current sessions: " + Object.keys(sessions));
console.log("new session: " + Object.keys(newSession));
setSessions([newSession, ...sessions]);
};
const handleUpdateSession = (message, sessionUuid) => {
try {
console.log(sessionUuid, Object.keys(sessions));
const temp = sessions;
const session = temp.find((s) => s.uuid === sessionUuid);
session.messages.push(message);
const filteredSessions = temp.filter((s) => s.uuid !== sessionUuid);
setSessions([session, ...filteredSessions]);
} catch (error) {
console.log(error);
}
};
now when i try to open new session it works great and I can send messages but when the user tries to send message it duplicates so many times and it gets errors because they are the same key I tried to debug and here is my conclusion
LOG current sessions after handle:
LOG current sessions after handle:
LOG connected
LOG event
LOG []
LOG ["uuid", "createdAt", "updatedAt", "expirationDate", "name", "device", "messages"]
LOG current sessions:
LOG new session: uuid,createdAt,updatedAt,expirationDate,name,device,messages
LOG current sessions after handle: 0
LOG event
LOG private message
LOG a0d35995-8d79-433a-aab9-1d911d20e756 []
LOG [TypeError: undefined is not an object (evaluating 'session.messages')]
LOG private message
LOG a0d35995-8d79-433a-aab9-1d911d20e756 ["0"]
LOG current sessions after handle: 0
as you can see the useEffect loads twice I don't know why then i trigger new session and then session set to the state then I try to send message from the sender you notice that the session.messages is undefined then it finds the session
I don't know why does it stack like this
thanks

How to identify if this code for implementing webrtc is working correctly?

I'm currently working on a webrtc project that was written before from another developer.
While I'm currently new to webrtc and I tried hard to make it work after deleting obsolete functions and other things, now can I identify what is wrong here in my steps?
I'm following steps in here
my code here is about trigger .call button
$(dod)
.find(".call")
and I run throw signaling
wbsc.emit("SEND_EVENT_EMIT_CALL_AUDIO", {
data: { type: "login", id: id },
});
$(dod).hide();
//call*donecallProccess 1
setTimeout(() => {
wbsc.emit("SEND_EVENT_EMIT_CALL_AUDIO", {
data: { type: "doneoif", id: id },
});
}, 2e3);
that can trigger and process here
case "donecall":
call(data.id);
break;
case "showcall":
handleLogin(data.success, data.id);
break;
case "offercall":
handleOffer(data.offer, data.name);
break;
case "answercall":
handleAnswer(data.answer);
break;
case "candidatecall":
handleCandidate(data.candidate);
break;
case "leavecall":
handleLeave();
then this code run one after each handle login and getUserMediaSuccess
gather permission from media stream and create a new RTCPeerConnection(servers)
get tracks from my streams with addtrack if I implement it correctly
and if ontrack happened can I collect streaming to add to remote peer like this way or should just add
yourConn.ontrack = (event) => {
if (event.candidate !== null) {
remoteVideo.srcObject = event.streams[0];
} else {
console.log("there is an error with on trackevent", event);
}
};
complete code for previous handlelogin and call is
let handleLogin = async (success) => {
try {
if (success) {
localVideo = document.getElementById("wbrtclocal");
remoteVideo = document.getElementById("wbrtcremote");
var getUserMedia = navigator.mediaDevices.getUserMedia|| navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia ;
if (getUserMedia) {
getUserMedia({
audio: { noiseSuppression: false },
})
.then(getUserMediaSuccess)
.catch(errorHandler);
} else {
alert("Your browser does not support getUserMedia API");
}
} else {
alert("Ooops...try a different username");
}
} catch (err) {
errorHandler(error);
}
};
let getUserMediaSuccess = async (streams) => {
try {
yourConn = new RTCPeerConnection(servers);
if(streams){
localStream = streams;
localVideo.srcObject = streams;
streams.getTracks().forEach((track) => yourConn.addTrack(track, streams));
}
yourConn.onicecandidate = (event) => {
if (event.candidate) {
wbsc.emit("SEND_EVENT_EMIT_CALL_AUDIO", {
data: {
type: "candidate",
candidate: event.candidate,
id: connectedUser,
},
});
}
console.log("nwe ice candidate is", event.candidate);
console.log("nwe stream is", streams);
};
remoteStream = new MediaStream();
remoteVideo.srcObject = remoteStream;
yourConn.ontrack = (event) => {
if (event.candidate !== null) {
event.streams[0].getTracks().forEach((track) => {
remoteStream.addTrack(track);
});
} else {
console.log("there is an error with on trackevent", event);
}
};
} catch (err) {
errorHandler(error);
}
console.log("stream is", streams);
};
call function
async function call(id) {
$("#videoCall").show();
if (id.length > 0) {
connectedUser = id;
try {
RTCPeerConnection.createOffer().
offer.
await yourConn
.createOffer()
.then((offer) => successCallback)
.catch((e) => {
fl(e);
});
let successCallback = async (offer) => {
try {
yourConn
.setLocalDescription(offer)
.then(
wbsc.emit("SEND_EVENT_EMIT_CALL_AUDIO", {
data: { type: "offer", offer: offer, id: connectedUser },
})
)
.catch((e) => {
fl(e);
});
} catch (e) {
fl(e);
}
};
} catch (e) {
fl(e);
}
const user = U_CASH[id];
if (user) {
$("#videoCall")
.find(".u-pic")
.css("background-image", "url(" + removegifpic(user.pic + ")"));
$("#videoCall").find(".u-topic").text(user.topic);
}
$(".statecall").text("جاري الإتصال");
hl($(".statecall"), "warning");
} else {
alert("username can't be blank!");
}
console.log("connectedUser", id);
console.log("offer", offer);
console.log();
console.log();
console.log();
}
and this about each one for those
[handleOffer , handleAnswer , handleCandidate , handleLeave]
let handleOffer = async (offer, name) => {
$("#callvideonot").show();
const user = U_CASH[name];
if (user) {
$("#callvideonot")
.find(".u-pic")
.css("background-image", "url(" + removegifpic(user.pic + ")"));
$("#callvideonot").find(".u-topic").text(user.topic);
}
$(".callvideoaccept").on("click", async () => {
connectedUser = name;
await yourConn.setRemoteDescription(offer);
yourConn
.createAnswer()
.then((answer) => t.setLocalDescription(answer))
.then(() => {
wbsc.emit("SEND_EVENT_EMIT_CALL_AUDIO", {
data: { type: "answer", answer: answer, id: connectedUser },
});
})
.catch(fl);
const user = U_CASH[name];
if (user) {
$(".statecall").text("متصل");
hl($(".statecall"), "success");
$("#videoCall")
.find(".u-pic")
.css("background-image", "url(" + removegifpic(user.pic + ")"));
$("#videoCall").find(".u-topic").text(user.topic);
}
$("#callvideonot").hide();
$("#videoCall").show();
});
$(".callvideodeny").on("click", function () {
wbsc.emit("SEND_EVENT_EMIT_CALL_AUDIO", {
data: { type: "leave", id: name },
});
});
};
let handleAnswer = async (answer) => {
try {
$(".statecall").text("متصل");
hl($(".statecall"), "success");
//here we delete new RTCSessionDescription because constructor is deprecated.
await yourConn.setRemoteDescription(answer);
} catch (e) {
fl(e);
}
};
let handleCandidate = async (candidate) => {
try {
var NewlyIceCandidate = new RTCIceCandidate(candidate)
.setRemoteDescription().
await yourConn.addIceCandidate(NewlyIceCandidate);
} catch (e) {
fl(e);
}
};
function handleLeave() {
$("#callvideonot").hide();
$(".statecall").text("رفض");
hl($(".statecall"), "danger");
$(".vloumemic").removeClass("fa-volume-off");
$(".vloumemic").addClass("fa-volume-up");
$(".mutemic").removeClass("fa-microphone-slash");
$(".mutemic").addClass("fa-microphone");
setTimeout(() => {
$("#videoCall").hide();
}, 1e3);
if (localStream) {
localStream.getTracks().forEach((e) => e.stop());
}
if (connectedUser) {
connectedUser = null;
}
remoteVideo.src = null;
if (yourConn) {
yourConn.close();
yourConn.onicecandidate = null;
yourConn.ontrack = null;
localStream = null;
}
}
in here here number 7 instruction they said should I add Wait for an incoming remote SDP description from the signaling service and set it using RTCPeerConnection.setRemoteDescription(). as the caller where can I add it? in handleCandidate function?
and in the callee side number 1 instruction the said that I should
Create a new RTCPeerConnection instance with the appropriate ICE configuration.
can I reuse
yourConn = new RTCPeerConnection(servers);
or should instantiate a new one to prevent conflict in website server process
as a not yourConn it's global value and in top level of this file and reuse it over all
the connection, is peers 2 or the callee should have another new RTCPeerConnection?
and for remoteVideo.srcObject = remoteStream; the remoteStream value is global and I overwrite it. can I here add new media stream or just it's good to dealing with the present one which is remoteVideo element?
// remoteStream = new MediaStream();
remoteVideo.srcObject = remoteStream;
the issue that was here is to split the RTCPeerConnection object and just create new one for each peer local and remote that what i did and it working corectly after i remove addtrack completly and replace it with addtranceiver and gettranceiver
u can find it here from previous issue's answer and i fix it here
and no need to overwrite or reset the srcObect value. The track transition on receiver side in the same MediaStream should be "seamless" RTCRtpSender.replaceTrack
This allows you to seamlessly change which track is being sent without having to renegotiate at the expense of another offer/answer cycle

How to extract data when onSnapshot is returned in realtime firestore?

I have two files contact.js and functions.js. I am using firestore realtime functionality.
Here is my functions.js file code:
export const getUserContacts = () => {
const contactDetailsArr = [];
return db.collection("users").doc(userId)
.onSnapshot(docs => {
const contactsObject = docs.data().contacts;
for (let contact in contactsObject) {
db.collection("users").doc(contact).get()
.then(userDetail => {
contactDetailsArr.push({
userId: contact,
lastMessage: contactsObject[contact].lastMsg,
time: contactsObject[contact].lastMsgTime,
userName:userDetail.data().userName,
email: userDetail.data().emailId,
active: userDetail.data().active,
img: userDetail.data().imageUrl,
unreadMsg:contactsObject[contact].unreadMsg
})
})
}
console.log(contactDetailsArr);
return contactDetailsArr;
})
}
in contact.js when I do:
useEffect(() => {
let temp = getUserContacts();
console.log(temp);
}, [])
I want to extract data of contactDetailsArr in contacts.js but I get the value of temp consoled as:
ƒ () {
i.Zl(), r.cs.ws(function () {
return Pr(r.q_, o);
});
}
How do I extract the array data in my case?
The onSnapshot() returns a function that can be used to detach the Firestore listener. When using a listener, it's best to set the data directly into state rather than returning something from that function. Try refactoring the code as shown below:
const [contacts, setContacts] = useState([]);
useEffect(() => {
const getUserContacts = () => {
const contactDetailsArr = [];
const detach = db.collection("users").doc(userId)
.onSnapshot(docs => {
const contactsObject = docs.data().contacts;
const contactsSnap = await Promise.all(contactsObject.map((c) => db.collection("users").doc(c).get()))
const contactDetails = contactsSnap.map((d) => ({
id: d.id,
...d.data()
// other fields like unreadMsg, time
}))
// Update in state
setContacts(contactDetails);
})
}
getUserContacts();
}, [])
Then use contacts array to map data in to UI directly.
Assumptions
This answer assumes a user's data looks like this in your Firestore:
// Document at /users/someUserId
{
"active": true,
"contacts": {
"someOtherUserId": {
"lastMsg": "This is a message",
"lastMsgTime": /* Timestamp */,
"unreadMsg": true // unclear if this is a boolean or a count of messages
},
"anotherUserId": {
"lastMsg": "Hi some user! How are you?",
"lastMsgTime": /* Timestamp */,
"unreadMsg": false
}
},
"emailId": "someuser#example.com",
"imageUrl": "https://firebasestorage.googleapis.com/b/bucket/o/images%20stars.jpg",
"userName": "Some User"
}
Note: When asking questions in the future, please add examples of your data structure similar to the above
Attaching Listeners with Current Structure
The structure as shown above has a number of flaws. The "contacts" object in the user's data should be moved to a sub-collection of the user's main document. The reasons for this include:
Any user can read another user's (latest) messages (which can't be blocked with security rules)
Any user can read another user's contacts list (which can't be blocked with security rules)
As an individual user messages more users, their user data will grow rapidly in size
Each time you want to read a user's data, you have to download their entire message map even if not using it
As you fill out a user's contacts array, you are fetching their entire user data document even though you only need their active, email, imageUrl, and userName properties
Higher chance of encountering document write conflicts when two users are editing the contact list of the same user (such as when sending a message)
Hard to (efficiently) detect changes to a user's contact list (e.g. new addition, deletion)
Hard to (efficiently) listen to changes to another user's active status, email, profile image and display name as the listeners would be fired for every message update they receive
To fetch a user's contacts once in your functions.js library, you would use:
// Utility function: Used to hydrate an entry in a user's "contacts" map
const getContactFromContactMapEntry = (db, [contactId, msgInfo]) => {
return db.collection("users")
.doc(contactId)
.get()
.then((contactDocSnapshot) => {
const { lastMsg, lastMsgTime, unreadMsg, userName } = msgInfo;
const baseContactData = {
lastMessage: lastMsg,
time: lastMsgTime,
unreadMsg,
userId: contactId
}
if (!contactDocSnapshot.exists) {
// TODO: Decide how to handle unknown/deleted users
return {
...baseContactData,
active: false, // deleted users are inactive, nor do they
email: null, // have an email, image or display name
img: null,
userName: "Deleted user"
};
}
const { active, emailId, imageUrl, userName } = contactDocSnapshot.data();
return {
...baseContactData,
active,
email: emailId,
img: imageUrl,
userName
};
});
};
export const getUserContacts = (db, userId) => { // <-- note that db and userId are passed in
return db.collection("users")
.doc(userId)
.get()
.then((userDataSnapshot) => {
const contactsMetadataMap = userDataSnapshot.get("contacts");
return Promise.all( // <-- waits for each Promise to complete
Object.entries(contactsMetadataMap) // <-- used to get an array of id-value pairs that we can iterate over
.map(getContactFromContactMapEntry.bind(null, db)); // for each contact, call the function (reusing db), returning a Promise with the data
);
});
}
Example Usage:
getUserContacts(db, userId)
.then((contacts) => console.log("Contacts data:", contacts))
.catch((err) => console.error("Failed to get contacts:", err))
// OR
try {
const contacts = await getUserContacts(db, userId);
console.log("Contacts data:", contacts);
} catch (err) {
console.error("Failed to get contacts:", err)
}
To fetch a user's contacts, and keep the list updated, using a function in your functions.js library, you would use:
// reuse getContactFromContactMapEntry as above
export const useUserContacts = (db, userId) => {
if (!db) throw new TypeError("Parameter 'db' is required");
const [userContactsData, setUserContactsData] = useState({ loading: true, contacts: [], error: null });
useEffect(() => {
// no user signed in?
if (!userId) {
setUserContactsData({ loading: false, contacts: [], error: "No user signed in" });
return;
}
// update loading status (as needed)
if (!userContactsData.loading) {
setUserContactsData({ loading: true, contacts: [], error: null });
}
let detached = false;
const detachListener = db.collection("users")
.doc(userId)
.onSnapshot({
next: (userDataSnapshot) => {
const contactsMetadataMap = userDataSnapshot.get("contacts");
const hydrateContactsPromise = Promise.all( // <-- waits for each Promise to complete
Object.entries(contactsMetadataMap) // <-- used to get an array of id-value pairs that we can iterate over
.map(getContactFromContactMapEntry.bind(null, db)); // for each contact, call the function (reusing db), returning a Promise with the data
);
hydrateContactsPromise
.then((contacts) => {
if (detached) return; // detached already, do nothing.
setUserContactsData({ loading: false, contacts, error: null });
})
.catch((err) => {
if (detached) return; // detached already, do nothing.
setUserContactsData({ loading: false, contacts: [], error: err });
});
},
error: (err) => {
setUserContactsData({ loading: false, contacts: [], error: err });
}
});
return () => {
detached = true;
detachListener();
}
}, [db, userId])
}
Note: The above code will not (due to complexity):
react to changes in another user's active status, email or profile image
properly handle when the setUserContactsData method is called out of order due to network issues
handle when db instance is changed on every render
Example Usage:
const { loading, contacts, error } = useUserContacts(db, userId);
Attaching Listeners with Sub-collection Structure
To restructure your data for efficiency, your structure would be updated to the following:
// Document at /users/someUserId
{
"active": true,
"emailId": "someuser#example.com",
"imageUrl": "https://firebasestorage.googleapis.com/b/bucket/o/images%20stars.jpg",
"userName": "Some User"
}
// Document at /users/someUserId/contacts/someOtherUserId
{
"lastMsg": "This is a message",
"lastMsgTime": /* Timestamp */,
"unreadMsg": true // unclear if this is a boolean or a count of messages
}
// Document at /users/someUserId/contacts/anotherUserId
{
"lastMsg": "Hi some user! How are you?",
"lastMsgTime": /* Timestamp */,
"unreadMsg": false
}
Using the above structure provides the following benefits:
Significantly better network performance when hydrating the contacts list
Security rules can be used to ensure users can't read each others contacts lists
Security rules can be used to ensure a message stays private between the two users
Listening to another user's profile updates can be done without reading or being notified of any changes to their other private messages
You can partially fetch a user's message inbox rather than the whole list
The contacts list is easy to update as two users updating the same contact entry is unlikely
Easy to detect when a user's contact entry has been added, deleted or modified (such as receiving a new message or marking a message read)
To fetch a user's contacts once in your functions.js library, you would use:
// Utility function: Merges the data from an entry in a user's "contacts" collection with that user's data
const mergeContactEntrySnapshotWithUserSnapshot = (contactEntryDocSnapshot, contactDocSnapshot) => {
const { lastMsg, lastMsgTime, unreadMsg } = contactEntryDocSnapshot.data();
const baseContactData = {
lastMessage: lastMsg,
time: lastMsgTime,
unreadMsg,
userId: contactEntryDocSnapshot.id
}
if (!contactDocSnapshot.exists) {
// TODO: Handle unknown/deleted users
return {
...baseContactData,
active: false, // deleted users are inactive, nor do they
email: null, // have an email, image or display name
img: null,
userName: "Deleted user"
};
}
const { active, emailId, imageUrl, userName } = contactDocSnapshot.data();
return {
...baseContactData,
active,
email: emailId,
img: imageUrl,
userName
};
}
// Utility function: Used to hydrate an entry in a user's "contacts" collection
const getContactFromContactsEntrySnapshot = (db, contactEntryDocSnapshot) => {
return db.collection("users")
.doc(contactEntry.userId)
.get()
.then((contactDocSnapshot) => mergeContactEntrySnapshotWithUserSnapshot(contactEntryDocSnapshot, contactDocSnapshot));
};
export const getUserContacts = (db, userId) => { // <-- note that db and userId are passed in
return db.collection("users")
.doc(userId)
.collection("contacts")
.get()
.then((userContactsQuerySnapshot) => {
return Promise.all( // <-- waits for each Promise to complete
userContactsQuerySnapshot.docs // <-- used to get an array of entry snapshots that we can iterate over
.map(getContactFromContactsEntrySnapshot.bind(null, db)); // for each contact, call the function (reusing db), returning a Promise with the data
);
});
}
Example Usage:
getUserContacts(db, userId)
.then((contacts) => console.log("Contacts data:", contacts))
.catch((err) => console.error("Failed to get contacts:", err))
// OR
try {
const contacts = await getUserContacts(db, userId);
console.log("Contacts data:", contacts);
} catch (err) {
console.error("Failed to get contacts:", err)
}
To fetch a user's contacts in a way where it's kept up to date, we first need to introduce a couple of utility useEffect wrappers (there are libraries for more robust implementations):
export const useFirestoreDocument = ({ db, path }) => {
if (!db) throw new TypeError("Property 'db' is required");
const [documentInfo, setDocumentInfo] = useState({ loading: true, snapshot: null, error: null });
useEffect(() => {
if (!path) {
setDocumentInfo({ loading: false, snapshot: null, error: "Invalid path" });
return;
}
// update loading status (as needed)
if (!documentInfo.loading) {
setDocumentInfo({ loading: true, snapshot: null, error: null });
}
return db.doc(path)
.onSnapshot({
next: (docSnapshot) => {
setDocumentInfo({ loading: false, snapshot, error: null });
},
error: (err) => {
setDocumentInfo({ loading: false, snapshot: null, error: err });
}
});
}, [db, path]);
return documentInfo;
}
export const useFirestoreCollection = ({ db, path }) => {
if (!db) throw new TypeError("Property 'db' is required");
const [collectionInfo, setCollectionInfo] = useState({ loading: true, docs: null, error: null });
useEffect(() => {
if (!path) {
setCollectionInfo({ loading: false, docs: null, error: "Invalid path" });
return;
}
// update loading status (as needed)
if (!collectionInfo.loading) {
setCollectionInfo({ loading: true, docs: null, error: null });
}
return db.collection(path)
.onSnapshot({
next: (querySnapshot) => {
setCollectionInfo({ loading: false, docs: querySnapshot.docs, error: null });
},
error: (err) => {
setCollectionInfo({ loading: false, docs: null, error: err });
}
});
}, [db, path]);
return collectionInfo;
}
To use that method to hydrate a contact, you would call it from a ContactEntry component:
// mergeContactEntrySnapshotWithUserSnapshot is the same as above
const ContactEntry = ({ db, userId, key: contactId }) => {
if (!db) throw new TypeError("Property 'db' is required");
if (!userId) throw new TypeError("Property 'userId' is required");
if (!contactId) throw new TypeError("Property 'key' (the contact's user ID) is required");
const contactEntryInfo = useFirestoreDocument(db, `/users/${userId}/contacts/${contactId}`);
const contactUserInfo = useFirestoreDocument(db, `/users/${contactId}`);
if ((contactEntryInfo.loading && !contactEntryInfo.error) && (contactUserInfo.loading && !contactUserInfo.error)) {
return (<div>Loading...</div>);
}
const error = contactEntryInfo.error || contactUserInfo.error;
if (error) {
return (<div>Contact unavailable: {error.message}</div>);
}
const contact = mergeContactEntrySnapshotWithUserSnapshot(contactEntryInfo.snapshot, contactUserInfo.snapshot);
return (<!-- contact content here -->);
}
Those ContactEntry components would be populated from a Contacts component:
const Contacts = ({db}) => {
if (!db) throw new TypeError("Property 'db' is required");
const { user } = useFirebaseAuth();
const contactsCollectionInfo = useFirestoreCollection(db, user ? `/users/${user.uid}/contacts` : null);
if (!user) {
return (<div>Not signed in!</div>);
}
if (contactsCollectionInfo.loading) {
return (<div>Loading contacts...</div>);
}
if (contactsCollectionInfo.error) {
return (<div>Contacts list unavailable: {contactsCollectionInfo.error.message}</div>);
}
const contactEntrySnapshots = contactsCollectionInfo.docs;
return (
<>{
contactEntrySnapshots.map(snapshot => {
return (<ContactEntry {...{ db, key: snapshot.id, userId: user.uid }} />);
})
}</>
);
}
Example Usage:
const db = firebase.firestore();
return (<Contacts db={db} />);
Your code seems to be not written with async/await or promise like style
e.g. contactDetailsArr will be returned as empty array
also onSnapshot creates long term subscription to Firestore collection and could be replaced with simple get()
See example on firestore https://firebase.google.com/docs/firestore/query-data/get-data#web-version-9_1

Issue with WebRTC RTCIceCandidate answer configuration

I am currently figuring out how to configure webRTC for my test application. I am able to get the user media API part of webrtc to work but the ICE config is what causes me issues. I have tried to look here on stackoverflow and it seems like noone else have had a similar error returned from the client. I am testing it on a live server where the inital communcation is through WSS. I have left out the markup and websocket config because it's irrelevant.
let myPeerConnection;
/* step 1: get users media stream inputs */
function getUserMediaClient(type = ""){
let mediaConstraints = {video:true, audio:true};
createPeerConnection();
navigator.mediaDevices.getUserMedia(mediaConstraints)
.then(function(localStream) {
document.getElementById("myVideo").srcObject = localStream;
document.getElementById("myVideo").onloadedmetadata = function(){
document.getElementById("myVideo").play();
}
localStream.getTracks().forEach(track => myPeerConnection.addTrack(track, localStream));
})
.catch(handleGetUserMediaError);
}
getUserMediaClient();
/*
preparation for step 2:
get users Peer connection information when the user calls createPeerCandidate()
to send the offer or handleVideoOfferMsg() to send the answer
*/
function createPeerConnection(){
myPeerConnection = new RTCPeerConnection(
{
iceServers: [
{
urls: [
'stun:stun.l.google.com:19302',
'stun:stun1.l.google.com:19302'
]
}/*,
{
urls: 'turn:192.158.29.39:3478?transport=tcp',
credential: 'JZEOEt2V3Qb0y27GRntt2u2PAYA=',
username: '28224511:1379330808'
}*/
]
}
);
return myPeerConnection;
}
/*
step 2:
send information to opposite part through websocket with SDP info
*/
function handleNegotiationNeededEvent(myUsername = "", targetUsername = "") {
myPeerConnection.createOffer().then(function(offer) {
return myPeerConnection.setLocalDescription(offer);
})
.then(function() {
sendToServer({
name: myUsername,
target: targetUsername,
type: "video-offer",
sdp: myPeerConnection.localDescription
});
})
.catch(reportError);
}
/*
step 3:
"if" answering user accepts - load tracks to stream and respond with SDP config info
*/
function handleVideoOfferMsg(msg) {
let mediaConstraints = {video:true, audio:true};
var localStream = null;
targetUsername = msg.name;
let myUsername = document.getElementById("user1").value;
createPeerConnection();
var desc = new RTCSessionDescription(msg.sdp);
myPeerConnection.setRemoteDescription(desc).then(function () {
return navigator.mediaDevices.getUserMedia(mediaConstraints);
})
.then(function(stream) {
localStream = stream;
document.getElementById("myVideo").srcObject = localStream;
localStream.getTracks().forEach(track => myPeerConnection.addTrack(track, localStream));
})
.then(function() {
return myPeerConnection.createAnswer();
})
.then(function(answer) {
return myPeerConnection.setLocalDescription(answer);
})
.then(function() {
var msg = {
name: myUsername,
target: targetUsername,
type: "video-answer",
sdp: myPeerConnection.localDescription
};
sendToServer(msg);
})
.catch(handleGetUserMediaError);
}
/*
step 4:
when both users have exchanged information - the ice processing can begin
the user that initially sent the request can now reply with a communication method
*/
var candidateData = null;
function handleICECandidateEvent(event) {
if ((event.sdp)||(event.candidate)){
if (event.candidate){
candidateData = event.candidate;
} else if (event.sdp) {
candidateData = event.sdp;
}
sendToServer({
type: "new-ice-candidate",
target: event.target,
candidate: candidateData
});
}
}
///////////////////// non functional part under ////////////////////////////
function handleNewICECandidateMsg(msg) {
candidateData = msg.candidate;
myPeerConnection.addIceCandidate(new RTCIceCandidate({sdpMLineIndex:1,candidate: candidateData})).catch(e => {
console.log("Failure during addIceCandidate(): " + JSON.stringify(e));
});
////////////// non functional part above ///////////////
console.log("MSG: " + JSON.stringify(msg));
}
function handleGetUserMediaError(e){
//removed for simplicity
}
//wss connection estabilshment from client removed
wss.onmessage = function(e){
if (type == "video-offer" && document.getElementById("user1").value == target){
// create counteroffer
handleVideoOfferMsg(data);
} else if (type == "video-answer"){
handleICECandidateEvent(data);
} else if (type == "new-ice-candidate"){
handleNewICECandidateMsg(data);
}
}
Most of the code is from MDN's tutorial here: https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Signaling_and_video_calling
The issue is within the handleNewICECandidateMsg() function (or so i think). Because when i enter sdpMLineIndex and sdpMid i get an empty json string and when i leave it with only the candidates sdp info it throws a typeerror saying it needs either sdpMid or sdpMLineIndex.
Any thoughts, links. Anything is appreciated!

Firebase cloud function not doing anything with Xstate

I'm trying to write a cloud function on Firebase that updates a document in Firebase when another one is written. I use the trigger method onWrite for this. I use Xstate for this, as my original code is more complex, but the idea is the same (and the problem as well). This is the code I use (Typescript):
import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
import { Machine, interpret } from "xstate";
admin.initializeApp({});
// This function will trigger as soon as a product of a company owner gets updated.
exports.productsOnUpdate = functions.firestore
.document(`companies/{companyId}/{products}/{productId}`)
.onWrite((change: any) => {
let done = false;
const PromiseFunction = (docIn: any) => {
console.log(docIn);
return admin
.firestore()
.collection("test")
.doc("testdoc")
.set({ products: docIn.products }, { merge: true })
.then((doc: FirebaseFirestore.WriteResult) => doc.writeTime);
};
const myMachine = Machine<any>({
id: "myMachine",
initial: "idle",
context: { doc: { products: "a product" } },
states: {
idle: {
on: {
INIT: "init"
}
},
init: {
invoke: {
id: "setDoc",
src: (context, event) => PromiseFunction(context.doc),
onDone: {
target: "success"
},
onError: {
target: "failure"
}
}
},
success: {},
failure: {}
}
}); // end of machine
const MyMachine = interpret(myMachine).onTransition(state => {
console.log(state.value);
// actually do something here
switch (state.value) {
case "INIT":
break;
case "success":
done = true;
console.log("Success");
case "failure":
console.log("Something went wrong");
default:
break;
}
});
MyMachine.start();
MyMachine.send("INIT");
while (done === false);
return "ok";
});
So, when trying to update a document in the subCollection 'products', this should trigger the function. In the log I see the following:
Absolutely nothing happens. When I make a small error in the context of MyMachine (change context: { doc: { products: "a product" } }, to context: { doc: { product: "a product" } }, I do see this:
So there seems to be something wrong with the promise handling or so. I already spent a day on this; any help is appreciated!
You should let xstate resolve your promises. Remove then statement from your PromiseFunction:
const PromiseFunction = (docIn: any) => {
console.log(docIn);
return admin
.firestore()
.collection("test")
.doc("testdoc")
.set({ products: docIn.products }, { merge: true })
};
Do handling with your resolved Promise in onDone block
onDone: {
target: "success",
actions: (ctx, e) => console.log(e), // do stuff with your resolved Promise
}

Categories

Resources