Abstract WebSocket client into library

This commit is contained in:
Noah 2023-12-10 16:09:00 -08:00
parent 00c6015148
commit d57d41ea3a
2 changed files with 308 additions and 166 deletions

View File

@ -13,6 +13,7 @@ import WhoListRow from './components/WhoListRow.vue';
import VideoFeed from './components/VideoFeed.vue'; import VideoFeed from './components/VideoFeed.vue';
import ProfileModal from './components/ProfileModal.vue'; import ProfileModal from './components/ProfileModal.vue';
import ChatClient from './lib/ChatClient';
import LocalStorage from './lib/LocalStorage'; import LocalStorage from './lib/LocalStorage';
import VideoFlag from './lib/VideoFlag'; import VideoFlag from './lib/VideoFlag';
import { SoundEffects, DefaultSounds } from './lib/sounds'; import { SoundEffects, DefaultSounds } from './lib/sounds';
@ -129,10 +130,8 @@ export default {
idleThreshold: 300, // number of seconds you must be idle idleThreshold: 300, // number of seconds you must be idle
// WebSocket connection. // WebSocket connection.
ws: { // Initialized in the dial() function.
conn: null, client: {},
connected: false,
},
// Who List for the room. // Who List for the room.
whoList: [], whoList: [],
@ -828,7 +827,7 @@ export default {
return; return;
} }
if (!this.ws.connected) { if (!this.client.connected()) {
this.ChatClient("You are not connected to the server."); this.ChatClient("You are not connected to the server.");
return; return;
} }
@ -842,12 +841,12 @@ export default {
// If they do it twice, kick them from the room. // If they do it twice, kick them from the room.
if (this.spamWarningCount >= 1) { if (this.spamWarningCount >= 1) {
// Walk of shame. // Walk of shame.
this.ws.conn.send(JSON.stringify({ this.client.send({
action: "message", action: "message",
channel: "lobby", channel: "lobby",
message: "**(Message of Shame)** I have been naughty and posted spam in chat despite being warned, " + message: "**(Message of Shame)** I have been naughty and posted spam in chat despite being warned, " +
"and I am now being kicked from the room in shame. ☹️", "and I am now being kicked from the room in shame. ☹️",
})); });
this.ChatServer( this.ChatServer(
"It is <strong>not allowed</strong> to promote your Onlyfans (or similar) " + "It is <strong>not allowed</strong> to promote your Onlyfans (or similar) " +
@ -861,9 +860,9 @@ export default {
action: "presence", action: "presence",
}); });
this.disconnect = true; this.disconnect = true;
this.ws.connected = false; this.client.ws.connected = false;
setTimeout(() => { setTimeout(() => {
this.ws.conn.close(); this.client.disconnect();
}, 1000); }, 1000);
return; return;
} }
@ -922,11 +921,11 @@ export default {
} }
// console.debug("Send message: %s", this.message); // console.debug("Send message: %s", this.message);
this.ws.conn.send(JSON.stringify({ this.client.send({
action: "message", action: "message",
channel: this.channel, channel: this.channel,
message: this.message, message: this.message,
})); });
this.message = ""; this.message = "";
}, },
@ -937,11 +936,11 @@ export default {
// Emoji reactions // Emoji reactions
sendReact(message, emoji) { sendReact(message, emoji) {
this.ws.conn.send(JSON.stringify({ this.client.send({
action: 'react', action: 'react',
msgID: message.msgID, msgID: message.msgID,
message: emoji, message: emoji,
})); });
}, },
onReact(msg) { onReact(msg) {
// Search all channels for this message ID and append the reaction. // Search all channels for this message ID and append the reaction.
@ -980,13 +979,13 @@ export default {
// Sync the current user state (such as video broadcasting status) to // Sync the current user state (such as video broadcasting status) to
// the backend, which will reload everybody's Who List. // the backend, which will reload everybody's Who List.
sendMe() { sendMe() {
if (!this.ws.connected) return; if (!this.client.connected()) return;
this.ws.conn.send(JSON.stringify({ this.client.send({
action: "me", action: "me",
video: this.myVideoFlag, video: this.myVideoFlag,
status: this.status, status: this.status,
dnd: this.prefs.closeDMs, dnd: this.prefs.closeDMs,
})); });
}, },
onMe(msg) { onMe(msg) {
// We have had settings pushed to us by the server, such as a change // We have had settings pushed to us by the server, such as a change
@ -1145,10 +1144,10 @@ export default {
} }
}, },
sendMute(username, mute) { sendMute(username, mute) {
this.ws.conn.send(JSON.stringify({ this.client.send({
action: mute ? "mute" : "unmute", action: mute ? "mute" : "unmute",
username: username, username: username,
})); });
}, },
isMutedUser(username) { isMutedUser(username) {
return this.muted[this.normalizeUsername(username)] != undefined; return this.muted[this.normalizeUsername(username)] != undefined;
@ -1169,30 +1168,30 @@ export default {
} }
// Send the username list to the server. // Send the username list to the server.
this.ws.conn.send(JSON.stringify({ this.client.send({
action: "blocklist", action: "blocklist",
usernames: blocklist, usernames: blocklist,
})) });
}, },
// Send a video request to access a user's camera. // Send a video request to access a user's camera.
sendOpen(username) { sendOpen(username) {
this.ws.conn.send(JSON.stringify({ this.client.send({
action: "open", action: "open",
username: username, username: username,
})); });
}, },
sendBoot(username) { sendBoot(username) {
this.ws.conn.send(JSON.stringify({ this.client.send({
action: "boot", action: "boot",
username: username, username: username,
})); });
}, },
sendUnboot(username) { sendUnboot(username) {
this.ws.conn.send(JSON.stringify({ this.client.send({
action: "unboot", action: "unboot",
username: username, username: username,
})); });
}, },
onOpen(msg) { onOpen(msg) {
// Response for the opener to begin WebRTC connection. // Response for the opener to begin WebRTC connection.
@ -1291,139 +1290,38 @@ export default {
dial() { dial() {
this.ChatClient("Establishing connection to server..."); this.ChatClient("Establishing connection to server...");
const proto = location.protocol === 'https:' ? 'wss' : 'ws'; // Set up the ChatClient connection.
const conn = new WebSocket(`${proto}://${location.host}/ws`); this.client = new ChatClient({
onClientError: this.ChatClient,
conn.addEventListener("close", ev => { jwt: this.jwt,
// Lost connection to server - scrub who list. prefs: this.prefs,
this.onWho({ whoList: [] });
this.muted = {};
this.ws.connected = false; onWho: this.onWho,
this.ChatClient(`WebSocket Disconnected code: ${ev.code}, reason: ${ev.reason}`); onMe: this.onMe,
onMessage: this.onMessage,
onTakeback: this.onTakeback,
onReact: this.onReact,
onPresence: this.onPresence,
onRing: this.onRing,
onOpen: this.onOpen,
onCandidate: this.onCandidate,
onSDP: this.onSDP,
onWatch: this.onWatch,
onUnwatch: this.onUnwatch,
onBlock: this.onBlock,
this.disconnectCount++; bulkMuteUsers: this.bulkMuteUsers,
if (this.disconnectCount > this.disconnectLimit) { focusMessageBox: () => {
this.ChatClient(`It seems there's a problem connecting to the server. Please try some other time.`);
return;
}
if (!this.disconnect) {
if (ev.code !== 1001 && ev.code !== 1000) {
this.ChatClient("Reconnecting in 5s");
setTimeout(this.dial, 5000);
}
}
});
conn.addEventListener("open", ev => {
this.ws.connected = true;
this.ChatClient("Websocket connected!");
// Upload our blocklist to the server before login. This resolves a bug where if a block
// was added recently (other user still online in chat), that user would briefly see your
// "has entered the room" message followed by you immediately not being online.
this.bulkMuteUsers();
// Tell the server our username.
this.ws.conn.send(JSON.stringify({
action: "login",
username: this.username,
jwt: this.jwt.token,
dnd: this.prefs.closeDMs,
}));
// Focus the message entry box.
window.requestAnimationFrame(() => {
this.messageBox.focus(); this.messageBox.focus();
}); },
}); pushHistory: this.pushHistory,
onNewJWT: jwt => {
conn.addEventListener("message", ev => {
if (typeof ev.data !== "string") {
console.error("unexpected message type", typeof ev.data);
return;
}
let msg = JSON.parse(ev.data);
try {
// Cast timestamp to date.
msg.at = new Date(msg.at);
} catch (e) {
console.error("Parsing timestamp '%s' on msg: %s", msg.at, e);
}
switch (msg.action) {
case "who":
this.onWho(msg);
break;
case "me":
this.onMe(msg);
break;
case "message":
this.onMessage(msg);
break;
case "takeback":
this.onTakeback(msg);
break;
case "react":
this.onReact(msg);
break;
case "presence":
this.onPresence(msg);
break;
case "ring":
this.onRing(msg);
break;
case "open":
this.onOpen(msg);
break;
case "candidate":
this.onCandidate(msg);
break;
case "sdp":
this.onSDP(msg);
break;
case "watch":
this.onWatch(msg);
break;
case "unwatch":
this.onUnwatch(msg);
break;
case "block":
this.onBlock(msg);
break;
case "error":
this.pushHistory({
channel: msg.channel,
username: msg.username || 'Internal Server Error',
message: msg.message,
isChatServer: true,
});
break;
case "disconnect":
this.onWho({ whoList: [] });
this.disconnect = true;
this.ws.connected = false;
this.ws.conn.close(1000, "server asked to close the connection");
break;
case "ping":
// New JWT token?
if (msg.jwt) {
this.jwt.token = msg.jwt; this.jwt.token = msg.jwt;
} },
// Reset disconnect retry counter: if we were on long enough to get
// a ping, we're well connected and can reconnect no matter how many
// times the chat server is rebooted.
this.disconnectCount = 0;
break;
default:
console.error("Unexpected action: %s", JSON.stringify(msg));
}
}); });
this.ws.conn = conn; this.client.dial();
}, },
/** /**
@ -1462,11 +1360,11 @@ export default {
// message to the other peer through the signaling server. // message to the other peer through the signaling server.
pc.onicecandidate = event => { pc.onicecandidate = event => {
if (event.candidate) { if (event.candidate) {
this.ws.conn.send(JSON.stringify({ this.client.send({
action: "candidate", action: "candidate",
username: username, username: username,
candidate: JSON.stringify(event.candidate), candidate: JSON.stringify(event.candidate),
})); });
} }
}; };
@ -1587,11 +1485,11 @@ export default {
localDescCreated(pc, username) { localDescCreated(pc, username) {
return (desc) => { return (desc) => {
pc.setLocalDescription(desc).then(() => { pc.setLocalDescription(desc).then(() => {
this.ws.conn.send(JSON.stringify({ this.client.send({
action: "sdp", action: "sdp",
username: username, username: username,
description: JSON.stringify(pc.localDescription), description: JSON.stringify(pc.localDescription),
})); });
}).catch(e => { }).catch(e => {
console.error(`Error sending WebRTC negotiation message (SDP): ${e}`); console.error(`Error sending WebRTC negotiation message (SDP): ${e}`);
}); });
@ -1659,10 +1557,10 @@ export default {
}, },
sendWatch(username, watching) { sendWatch(username, watching) {
// Send the watch or unwatch message to backend. // Send the watch or unwatch message to backend.
this.ws.conn.send(JSON.stringify({ this.client.send({
action: watching ? "watch" : "unwatch", action: watching ? "watch" : "unwatch",
username: username, username: username,
})); });
}, },
isWatchingMe(username) { isWatchingMe(username) {
// Return whether the user is watching your camera // Return whether the user is watching your camera
@ -1825,10 +1723,10 @@ export default {
"Do you want to take this message back? Doing so will remove this message from everybody's view in the chat room." "Do you want to take this message back? Doing so will remove this message from everybody's view in the chat room."
)) return; )) return;
this.ws.conn.send(JSON.stringify({ this.client.send({
action: "takeback", action: "takeback",
msgID: msg.msgID, msgID: msg.msgID,
})); });
}, },
removeMessage(msg) { removeMessage(msg) {
if (!window.confirm( if (!window.confirm(
@ -2863,10 +2761,9 @@ export default {
// Attach the file to the message. // Attach the file to the message.
msg.message = file.name; msg.message = file.name;
msg.bytes = fileByteArray; msg.bytes = fileByteArray;
msg = JSON.stringify(msg);
// Send it to the chat server. // Send it to the chat server.
this.ws.conn.send(msg); this.client.send(msg);
}; };
reader.readAsArrayBuffer(file); reader.readAsArrayBuffer(file);
@ -3066,7 +2963,7 @@ export default {
let msg = this.reportModal.message; let msg = this.reportModal.message;
this.ws.conn.send(JSON.stringify({ this.client.send({
action: "report", action: "report",
channel: msg.channel, channel: msg.channel,
username: msg.username, username: msg.username,
@ -3074,7 +2971,7 @@ export default {
reason: classification, reason: classification,
message: msg.message, message: msg.message,
comment: comment, comment: comment,
})); });
this.reportModal.busy = false; this.reportModal.busy = false;
this.reportModal.visible = false; this.reportModal.visible = false;
@ -3992,7 +3889,7 @@ export default {
<!-- My text box --> <!-- My text box -->
<input type="text" class="input" id="messageBox" v-model="message" <input type="text" class="input" id="messageBox" v-model="message"
placeholder="Write a message" @keydown="sendTypingNotification()" autocomplete="off" placeholder="Write a message" @keydown="sendTypingNotification()" autocomplete="off"
:disabled="!ws.connected"> :disabled="!client.connected">
<!-- At Mention templates--> <!-- At Mention templates-->
<template #no-result> <template #no-result>

245
src/lib/ChatClient.js Normal file
View File

@ -0,0 +1,245 @@
// WebSocket chat client handler.
class ChatClient {
/**
* Constructor for the client.
*
* @param usePolling: instead of WebSocket use the ajax polling API.
* @param onClientError: function to receive 'ChatClient' messages to
* add to the chat room (this.ChatClient())
*/
constructor({
usePolling=false,
onClientError,
jwt, // JWT token for authorization
prefs, // User preferences for 'me' action (close DMs, etc)
// Chat Protocol handler functions for the caller.
onWho,
onMe,
onMessage,
onTakeback,
onReact,
onPresence,
onRing,
onOpen,
onCandidate,
onSDP,
onWatch,
onUnwatch,
onBlock,
// Misc function registrations for callback.
onNewJWT, // new JWT token from ping response
bulkMuteUsers, // Upload our blocklist on connect.
focusMessageBox, // Tell caller to focus the message entry box.
pushHistory,
}) {
this.usePolling = usePolling;
// Pointer to the 'ChatClient(message)' command from the main app.
this.ChatClient = onClientError;
this.jwt = jwt;
this.prefs = prefs;
// Register the handler functions.
this.onWho = onWho;
this.onMe = onMe;
this.onMessage = onMessage;
this.onTakeback = onTakeback;
this.onReact = onReact;
this.onPresence = onPresence;
this.onRing = onRing;
this.onOpen = onOpen;
this.onCandidate = onCandidate;
this.onSDP = onSDP;
this.onWatch = onWatch;
this.onUnwatch = onUnwatch;
this.onBlock = onBlock;
this.onNewJWT = onNewJWT;
this.bulkMuteUsers = bulkMuteUsers;
this.focusMessageBox = focusMessageBox;
this.pushHistory = pushHistory;
// WebSocket connection.
this.ws = {
conn: null,
connected: false,
};
}
// Connected polls if the client is connected.
connected() {
if (this.usePolling) {
return true;
}
return this.ws.connected;
}
// Disconnect from the server.
disconnect() {
if (this.usePolling) {
throw new Exception("Not implemented");
}
this.ws.conn.close();
}
// Common function to send a message to the server. The message
// is a JSON object before stringify.
send(message) {
if (this.usePolling) {
throw new Exception("Not implemented");
}
if (!this.ws.connected) {
this.ChatClient("Couldn't send WebSocket message: not connected.");
return;
}
console.log("send:", message);
if (typeof(message) !== "string") {
message = JSON.stringify(message);
}
this.ws.conn.send(message);
}
// Common function to handle a message from the server.
handle(msg) {
switch (msg.action) {
case "who":
this.onWho(msg);
break;
case "me":
this.onMe(msg);
break;
case "message":
this.onMessage(msg);
break;
case "takeback":
this.onTakeback(msg);
break;
case "react":
this.onReact(msg);
break;
case "presence":
this.onPresence(msg);
break;
case "ring":
this.onRing(msg);
break;
case "open":
this.onOpen(msg);
break;
case "candidate":
this.onCandidate(msg);
break;
case "sdp":
this.onSDP(msg);
break;
case "watch":
this.onWatch(msg);
break;
case "unwatch":
this.onUnwatch(msg);
break;
case "block":
this.onBlock(msg);
break;
case "error":
this.pushHistory({
channel: msg.channel,
username: msg.username || 'Internal Server Error',
message: msg.message,
isChatServer: true,
});
break;
case "disconnect":
this.onWho({ whoList: [] });
this.disconnect = true;
this.ws.connected = false;
this.ws.conn.close(1000, "server asked to close the connection");
break;
case "ping":
// New JWT token?
if (msg.jwt) {
this.onNewJWT(msg.jwt);
}
// Reset disconnect retry counter: if we were on long enough to get
// a ping, we're well connected and can reconnect no matter how many
// times the chat server is rebooted.
this.disconnectCount = 0;
break;
default:
console.error("Unexpected action: %s", JSON.stringify(msg));
}
}
// Dial the WebSocket.
dial() {
this.ChatClient("Establishing connection to server...");
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const conn = new WebSocket(`${proto}://${location.host}/ws`);
conn.addEventListener("close", ev => {
// Lost connection to server - scrub who list.
this.onWho({ whoList: [] });
this.ws.connected = false;
this.ChatClient(`WebSocket Disconnected code: ${ev.code}, reason: ${ev.reason}`);
this.disconnectCount++;
if (this.disconnectCount > this.disconnectLimit) {
this.ChatClient(`It seems there's a problem connecting to the server. Please try some other time.`);
return;
}
if (!this.disconnect) {
if (ev.code !== 1001 && ev.code !== 1000) {
this.ChatClient("Reconnecting in 5s");
setTimeout(this.dial, 5000);
}
}
});
conn.addEventListener("open", ev => {
this.ws.connected = true;
this.ChatClient("Websocket connected!");
// Upload our blocklist to the server before login. This resolves a bug where if a block
// was added recently (other user still online in chat), that user would briefly see your
// "has entered the room" message followed by you immediately not being online.
this.bulkMuteUsers();
// Tell the server our username.
this.send({
action: "login",
username: this.username,
jwt: this.jwt.token,
dnd: this.prefs.closeDMs,
});
// Focus the message entry box.
window.requestAnimationFrame(() => {
this.focusMessageBox();
});
});
conn.addEventListener("message", ev => {
if (typeof ev.data !== "string") {
console.error("unexpected message type", typeof ev.data);
return;
}
let msg = JSON.parse(ev.data);
this.handle(msg);
});
this.ws.conn = conn;
}
}
export default ChatClient;