Merge pull request #1111 from thecodingmachine/svelteChat

FEATURE: migrated the chat window to svelte
This commit is contained in:
Kharhamel 2021-07-13 11:25:38 +02:00 committed by GitHub
commit 41a1f56bd5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 562 additions and 305 deletions

View file

@ -0,0 +1,52 @@
export function getRandomColor(): string {
const golden_ratio_conjugate = 0.618033988749895;
let hue = Math.random();
hue += golden_ratio_conjugate;
hue %= 1;
return hsv_to_rgb(hue, 0.5, 0.95);
}
//todo: test this.
function hsv_to_rgb(hue: number, saturation: number, brightness: number): string {
const h_i = Math.floor(hue * 6);
const f = hue * 6 - h_i;
const p = brightness * (1 - saturation);
const q = brightness * (1 - f * saturation);
const t = brightness * (1 - (1 - f) * saturation);
let r: number, g: number, b: number;
switch (h_i) {
case 0:
r = brightness;
g = t;
b = p;
break;
case 1:
r = q;
g = brightness;
b = p;
break;
case 2:
r = p;
g = brightness;
b = t;
break;
case 3:
r = p;
g = q;
b = brightness;
break;
case 4:
r = t;
g = p;
b = brightness;
break;
case 5:
r = brightness;
g = p;
b = q;
break;
default:
throw "h_i cannot be " + h_i;
}
return "#" + Math.floor(r * 256).toString(16) + Math.floor(g * 256).toString(16) + Math.floor(b * 256).toString(16);
}

View file

@ -1,232 +1,12 @@
import { HtmlUtils } from "./HtmlUtils";
import type { UserInputManager } from "../Phaser/UserInput/UserInputManager";
import { connectionManager } from "../Connexion/ConnectionManager";
import { GameConnexionTypes } from "../Url/UrlManager";
import { iframeListener } from "../Api/IframeListener";
import { showReportScreenStore } from "../Stores/ShowReportScreenStore";
export type SendMessageCallback = (message: string) => void;
import { chatMessagesStore, chatVisibilityStore } from "../Stores/ChatStore";
export class DiscussionManager {
private mainContainer: HTMLDivElement;
private divDiscuss?: HTMLDivElement;
private divParticipants?: HTMLDivElement;
private nbpParticipants?: HTMLParagraphElement;
private divMessages?: HTMLParagraphElement;
private participants: Map<number | string, HTMLDivElement> = new Map<number | string, HTMLDivElement>();
private activeDiscussion: boolean = false;
private sendMessageCallBack: Map<number | string, SendMessageCallback> = new Map<
number | string,
SendMessageCallback
>();
private userInputManager?: UserInputManager;
constructor() {
this.mainContainer = HtmlUtils.getElementByIdOrFail<HTMLDivElement>("main-container");
this.createDiscussPart(""); //todo: why do we always use empty string?
iframeListener.chatStream.subscribe((chatEvent) => {
this.addMessage(chatEvent.author, chatEvent.message, false);
this.showDiscussion();
chatMessagesStore.addExternalMessage(parseInt(chatEvent.author), chatEvent.message);
chatVisibilityStore.set(true);
});
this.onSendMessageCallback("iframe_listener", (message) => {
iframeListener.sendUserInputChat(message);
});
}
private createDiscussPart(name: string) {
this.divDiscuss = document.createElement("div");
this.divDiscuss.classList.add("discussion");
const buttonCloseDiscussion: HTMLButtonElement = document.createElement("button");
buttonCloseDiscussion.classList.add("close-btn");
buttonCloseDiscussion.innerHTML = `<img src="resources/logos/close.svg"/>`;
buttonCloseDiscussion.addEventListener("click", () => {
this.hideDiscussion();
});
this.divDiscuss.appendChild(buttonCloseDiscussion);
const myName: HTMLParagraphElement = document.createElement("p");
myName.innerText = name.toUpperCase();
this.nbpParticipants = document.createElement("p");
this.nbpParticipants.innerText = "PARTICIPANTS (1)";
this.divParticipants = document.createElement("div");
this.divParticipants.classList.add("participants");
this.divMessages = document.createElement("div");
this.divMessages.classList.add("messages");
this.divMessages.innerHTML = "<h2>Local messages</h2>";
this.divDiscuss.appendChild(myName);
this.divDiscuss.appendChild(this.nbpParticipants);
this.divDiscuss.appendChild(this.divParticipants);
this.divDiscuss.appendChild(this.divMessages);
const sendDivMessage: HTMLDivElement = document.createElement("div");
sendDivMessage.classList.add("send-message");
const inputMessage: HTMLInputElement = document.createElement("input");
inputMessage.onfocus = () => {
if (this.userInputManager) {
this.userInputManager.disableControls();
}
};
inputMessage.onblur = () => {
if (this.userInputManager) {
this.userInputManager.restoreControls();
}
};
inputMessage.type = "text";
inputMessage.addEventListener("keyup", (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
if (inputMessage.value === null || inputMessage.value === "" || inputMessage.value === undefined) {
return;
}
this.addMessage(name, inputMessage.value, true);
for (const callback of this.sendMessageCallBack.values()) {
callback(inputMessage.value);
}
inputMessage.value = "";
}
});
sendDivMessage.appendChild(inputMessage);
this.divDiscuss.appendChild(sendDivMessage);
//append in main container
this.mainContainer.appendChild(this.divDiscuss);
this.addParticipant("me", "Moi", undefined, true);
}
public addParticipant(
userId: number | "me",
name: string | undefined,
img?: string | undefined,
isMe: boolean = false
) {
const divParticipant: HTMLDivElement = document.createElement("div");
divParticipant.classList.add("participant");
divParticipant.id = `participant-${userId}`;
const divImgParticipant: HTMLImageElement = document.createElement("img");
divImgParticipant.src = "resources/logos/boy.svg";
if (img !== undefined) {
divImgParticipant.src = img;
}
const divPParticipant: HTMLParagraphElement = document.createElement("p");
if (!name) {
name = "Anonymous";
}
divPParticipant.innerText = name;
divParticipant.appendChild(divImgParticipant);
divParticipant.appendChild(divPParticipant);
if (
!isMe &&
connectionManager.getConnexionType &&
connectionManager.getConnexionType !== GameConnexionTypes.anonymous &&
userId !== "me"
) {
const reportBanUserAction: HTMLButtonElement = document.createElement("button");
reportBanUserAction.classList.add("report-btn");
reportBanUserAction.innerText = "Report";
reportBanUserAction.addEventListener("click", () => {
showReportScreenStore.set({ userId: userId, userName: name ? name : "" });
});
divParticipant.appendChild(reportBanUserAction);
}
this.divParticipants?.appendChild(divParticipant);
this.participants.set(userId, divParticipant);
this.updateParticipant(this.participants.size);
}
public updateParticipant(nb: number) {
if (!this.nbpParticipants) {
return;
}
this.nbpParticipants.innerText = `PARTICIPANTS (${nb})`;
}
public addMessage(name: string, message: string, isMe: boolean = false) {
const divMessage: HTMLDivElement = document.createElement("div");
divMessage.classList.add("message");
if (isMe) {
divMessage.classList.add("me");
}
const pMessage: HTMLParagraphElement = document.createElement("p");
const date = new Date();
if (isMe) {
name = "Me";
} else {
name = HtmlUtils.escapeHtml(name);
}
pMessage.innerHTML = `<span style="font-weight: bold">${name}</span>
<span style="color:#bac2cc;display:inline-block;font-size:12px;">
${date.getHours()}:${date.getMinutes()}
</span>`;
divMessage.appendChild(pMessage);
const userMessage: HTMLParagraphElement = document.createElement("p");
userMessage.innerHTML = HtmlUtils.urlify(message);
userMessage.classList.add("body");
divMessage.appendChild(userMessage);
this.divMessages?.appendChild(divMessage);
//automatic scroll when there are new message
setTimeout(() => {
this.divMessages?.scroll({
top: this.divMessages?.scrollTop + divMessage.getBoundingClientRect().y,
behavior: "smooth",
});
}, 200);
}
public removeParticipant(userId: number | string) {
const element = this.participants.get(userId);
if (element) {
element.remove();
this.participants.delete(userId);
}
//if all participant leave, hide discussion button
this.sendMessageCallBack.delete(userId);
}
public onSendMessageCallback(userId: string | number, callback: SendMessageCallback): void {
this.sendMessageCallBack.set(userId, callback);
}
get activatedDiscussion() {
return this.activeDiscussion;
}
private showDiscussion() {
this.activeDiscussion = true;
this.divDiscuss?.classList.add("active");
}
private hideDiscussion() {
this.activeDiscussion = false;
this.divDiscuss?.classList.remove("active");
}
public setUserInputManager(userInputManager: UserInputManager) {
this.userInputManager = userInputManager;
}
public showDiscussionPart() {
this.showDiscussion();
}
}

View file

@ -1,16 +1,11 @@
import { DivImportance, layoutManager } from "./LayoutManager";
import { layoutManager } from "./LayoutManager";
import { HtmlUtils } from "./HtmlUtils";
import { discussionManager, SendMessageCallback } from "./DiscussionManager";
import type { UserInputManager } from "../Phaser/UserInput/UserInputManager";
import { localUserStore } from "../Connexion/LocalUserStore";
import type { UserSimplePeerInterface } from "./SimplePeer";
import { SoundMeter } from "../Phaser/Components/SoundMeter";
import { DISABLE_NOTIFICATIONS } from "../Enum/EnvironmentVariable";
import { localStreamStore } from "../Stores/MediaStore";
import { screenSharingLocalStreamStore } from "../Stores/ScreenSharingStore";
import { helpCameraSettingsVisibleStore } from "../Stores/HelpCameraSettingsStore";
export type UpdatedLocalStreamCallback = (media: MediaStream | null) => void;
export type StartScreenSharingCallback = (media: MediaStream) => void;
export type StopScreenSharingCallback = (media: MediaStream) => void;
@ -182,22 +177,8 @@ export class MediaManager {
}
}
public addNewMessage(name: string, message: string, isMe: boolean = false) {
discussionManager.addMessage(name, message, isMe);
//when there are new message, show discussion
if (!discussionManager.activatedDiscussion) {
discussionManager.showDiscussionPart();
}
}
public addSendMessageCallback(userId: string | number, callback: SendMessageCallback) {
discussionManager.onSendMessageCallback(userId, callback);
}
public setUserInputManager(userInputManager: UserInputManager) {
this.userInputManager = userInputManager;
discussionManager.setUserInputManager(userInputManager);
}
public getNotification() {

View file

@ -12,6 +12,7 @@ import { localStreamStore, LocalStreamStoreValue, obtainedMediaConstraintStore }
import { screenSharingLocalStreamStore } from "../Stores/ScreenSharingStore";
import { discussionManager } from "./DiscussionManager";
import { playersStore } from "../Stores/PlayersStore";
import { newChatMessageStore } from "../Stores/ChatStore";
export interface UserSimplePeerInterface {
userId: number;
@ -155,27 +156,11 @@ export class SimplePeer {
const name = this.getName(user.userId);
discussionManager.removeParticipant(user.userId);
this.lastWebrtcUserName = user.webRtcUser;
this.lastWebrtcPassword = user.webRtcPassword;
const peer = new VideoPeer(user, user.initiator ? user.initiator : false, name, this.Connection, localStream);
//permit to send message
mediaManager.addSendMessageCallback(user.userId, (message: string) => {
peer.write(
new Buffer(
JSON.stringify({
type: MESSAGE_TYPE_MESSAGE,
name: this.myName.toUpperCase(),
userId: this.userId,
message: message,
})
)
);
});
peer.toClose = false;
// When a connection is established to a video stream, and if a screen sharing is taking place,
// the user sharing screen should also initiate a connection to the remote user!

View file

@ -5,10 +5,11 @@ import type { RoomConnection } from "../Connexion/RoomConnection";
import { blackListManager } from "./BlackListManager";
import type { Subscription } from "rxjs";
import type { UserSimplePeerInterface } from "./SimplePeer";
import { get, readable, Readable } from "svelte/store";
import { get, readable, Readable, Unsubscriber } from "svelte/store";
import { obtainedMediaConstraintStore } from "../Stores/MediaStore";
import { discussionManager } from "./DiscussionManager";
import { playersStore } from "../Stores/PlayersStore";
import { chatMessagesStore, chatVisibilityStore, newChatMessageStore } from "../Stores/ChatStore";
const Peer: SimplePeerNamespace.SimplePeer = require("simple-peer");
@ -34,6 +35,7 @@ export class VideoPeer extends Peer {
public readonly streamStore: Readable<MediaStream | null>;
public readonly statusStore: Readable<PeerStatus>;
public readonly constraintsStore: Readable<MediaStreamConstraints | null>;
private newMessageunsubscriber: Unsubscriber | null = null;
constructor(
public user: UserSimplePeerInterface,
@ -147,6 +149,20 @@ export class VideoPeer extends Peer {
this.on("connect", () => {
this._connected = true;
chatMessagesStore.addIncomingUser(this.userId);
this.newMessageunsubscriber = newChatMessageStore.subscribe((newMessage) => {
if (!newMessage) return;
this.write(
new Buffer(
JSON.stringify({
type: MESSAGE_TYPE_MESSAGE,
message: newMessage,
})
)
); //send more data
newChatMessageStore.set(null); //This is to prevent a newly created SimplePeer to send an old message a 2nd time. Is there a better way?
});
});
this.on("data", (chunk: Buffer) => {
@ -164,8 +180,9 @@ export class VideoPeer extends Peer {
mediaManager.disabledVideoByUserId(this.userId);
}
} else if (message.type === MESSAGE_TYPE_MESSAGE) {
if (!blackListManager.isBlackListed(message.userId)) {
mediaManager.addNewMessage(message.name, message.message);
if (!blackListManager.isBlackListed(this.userUuid)) {
chatMessagesStore.addExternalMessage(this.userId, message.message);
chatVisibilityStore.set(true);
}
} else if (message.type === MESSAGE_TYPE_BLOCKED) {
//FIXME when A blacklists B, the output stream from A is muted in B's js client. This is insecure since B can manipulate the code to unmute A stream.
@ -253,7 +270,9 @@ export class VideoPeer extends Peer {
}
this.onBlockSubscribe.unsubscribe();
this.onUnBlockSubscribe.unsubscribe();
discussionManager.removeParticipant(this.userId);
if (this.newMessageunsubscriber) this.newMessageunsubscriber();
chatMessagesStore.addOutcomingUser(this.userId);
//discussionManager.removeParticipant(this.userId);
// FIXME: I don't understand why "Closing connection with" message is displayed TWICE before "Nb users in peerConnectionArray"
// I do understand the method closeConnection is called twice, but I don't understand how they manage to run in parallel.
super.destroy(error);