2023-02-06 21:33:09 +00:00
// console.log("BareRTC!");
2023-01-11 06:38:48 +00:00
2023-01-27 06:54:02 +00:00
// WebRTC configuration.
const configuration = {
iceServers : [ {
urls : 'stun:stun.l.google.com:19302'
} ]
} ;
2023-03-22 04:29:24 +00:00
const FileUploadMaxSize = 1024 * 1024 * 8 ; // 8 MB
function setModalImage ( url ) {
let $modalImg = document . querySelector ( "#modalImage" ) ,
$modal = document . querySelector ( "#photo-modal" ) ;
$modalImg . src = url ;
$modal . classList . add ( "is-active" ) ;
return false ;
}
2023-01-27 06:54:02 +00:00
2023-01-11 06:38:48 +00:00
const app = Vue . createApp ( {
delimiters : [ '[[' , ']]' ] ,
data ( ) {
return {
2023-02-10 07:03:06 +00:00
// busy: false, // TODO: not used
2023-02-11 06:46:39 +00:00
disconnect : false , // don't try to reconnect (e.g. kicked)
2023-02-10 07:03:06 +00:00
windowFocused : true , // browser tab is active
windowFocusedAt : new Date ( ) ,
2023-01-11 06:38:48 +00:00
2023-02-20 05:36:36 +00:00
// Disconnect spamming: don't retry too many times.
disconnectLimit : 8 ,
disconnectCount : 0 ,
2023-02-05 08:53:50 +00:00
// Website configuration provided by chat.html template.
config : {
channels : PublicChannels ,
2023-02-06 01:42:09 +00:00
website : WebsiteURL ,
2023-02-11 06:46:39 +00:00
permitNSFW : PermitNSFW ,
2023-03-25 05:10:44 +00:00
fontSizeClasses : [
[ "" , "Default size" ] ,
[ "x1" , "50% larger chat room text" ] ,
[ "x2" , "2x larger chat room text" ] ,
[ "x3" , "3x larger chat room text" ] ,
[ "x4" , "4x larger chat room text" ] ,
] ,
2023-02-06 21:27:29 +00:00
sounds : {
available : SoundEffects ,
settings : DefaultSounds ,
ready : false ,
audioContext : null ,
audioTracks : { } ,
}
2023-02-06 01:42:09 +00:00
} ,
// User JWT settings if available.
jwt : {
token : UserJWTToken ,
valid : UserJWTValid ,
claims : UserJWTClaims
2023-02-05 08:53:50 +00:00
} ,
2023-01-27 04:34:58 +00:00
channel : "lobby" ,
username : "" , //"test",
2023-02-25 00:47:28 +00:00
autoLogin : false , // e.g. from JWT auth
2023-01-11 06:38:48 +00:00
message : "" ,
2023-02-10 07:03:06 +00:00
typingNotifDebounce : null ,
2023-01-11 06:38:48 +00:00
// WebSocket connection.
ws : {
conn : null ,
connected : false ,
} ,
2023-01-27 04:34:58 +00:00
// Who List for the room.
whoList : [ ] ,
2023-03-23 03:21:04 +00:00
whoTab : 'online' ,
2023-02-06 01:42:09 +00:00
whoMap : { } , // map username to wholist entry
2023-03-23 03:21:04 +00:00
muted : { } , // muted usernames for client side state
2023-01-27 04:34:58 +00:00
// My video feed.
webcam : {
busy : false ,
active : false ,
elem : null , // <video id="localVideo"> element
stream : null , // MediaStream object
2023-02-06 04:26:00 +00:00
muted : false , // our outgoing mic is muted, not by default
2023-02-11 06:46:39 +00:00
nsfw : false , // user has flagged their camera to be NSFW
2023-02-06 04:26:00 +00:00
// Who all is watching me? map of users.
watching : { } ,
2023-02-10 07:03:06 +00:00
// Scaling setting for the videos drawer, so the user can
// embiggen the webcam sizes so a suitable size.
videoScale : "" ,
videoScaleOptions : [
[ "" , "Default size" ] ,
[ "x1" , "50% larger videos" ] ,
[ "x2" , "2x larger videos" ] ,
[ "x3" , "3x larger videos" ] ,
[ "x4" , "4x larger videos (not recommended)" ] ,
] ,
2023-01-27 04:34:58 +00:00
} ,
2023-01-27 06:54:02 +00:00
// WebRTC sessions with other users.
WebRTC : {
// Streams per username.
streams : { } ,
2023-02-06 04:26:00 +00:00
muted : { } , // muted bool per username
2023-01-27 06:54:02 +00:00
// RTCPeerConnections per username.
pc : { } ,
} ,
2023-01-11 06:38:48 +00:00
// Chat history.
history : [ ] ,
2023-02-05 08:53:50 +00:00
channels : {
// There will be values here like:
// "lobby": {
// "history": [],
// "updated": timestamp,
// "unread": 4,
// },
// "@username": {
// "history": [],
// ...
// }
} ,
2023-01-27 04:34:58 +00:00
historyScrollbox : null ,
2023-03-25 04:56:40 +00:00
autoscroll : true , // scroll to bottom on new messages
2023-03-25 05:10:44 +00:00
fontSizeClass : "" , // font size magnification
2023-01-11 06:38:48 +00:00
DMs : { } ,
2023-02-10 07:03:06 +00:00
// Responsive CSS controls for mobile.
2023-02-05 08:53:50 +00:00
responsive : {
leftDrawerOpen : false ,
rightDrawerOpen : false ,
nodes : {
// DOM nodes for the CSS grid cells.
$container : null ,
$left : null ,
$center : null ,
$right : null ,
}
} ,
2023-01-11 06:38:48 +00:00
loginModal : {
visible : false ,
} ,
2023-02-06 21:27:29 +00:00
settingsModal : {
visible : false ,
} ,
2023-02-11 06:46:39 +00:00
nsfwModalCast : {
visible : false ,
} ,
nsfwModalView : {
visible : false ,
dontShowAgain : false ,
user : null , // staged User we wanted to open
} ,
2023-01-11 06:38:48 +00:00
}
} ,
mounted ( ) {
2023-02-06 21:27:29 +00:00
this . setupSounds ( ) ;
2023-03-25 05:10:44 +00:00
this . setupConfig ( ) ; // localSettings persisted settings
2023-02-06 21:27:29 +00:00
2023-01-27 04:34:58 +00:00
this . webcam . elem = document . querySelector ( "#localVideo" ) ;
this . historyScrollbox = document . querySelector ( "#chatHistory" ) ;
2023-02-05 08:53:50 +00:00
this . responsive . nodes = {
$container : document . querySelector ( ".chat-container" ) ,
$left : document . querySelector ( ".left-column" ) ,
$center : document . querySelector ( ".chat-column" ) ,
$right : document . querySelector ( ".right-column" ) ,
} ;
2023-02-10 07:03:06 +00:00
// Reset CSS overrides for responsive display on any window size change. In effect,
// making the chat panel the current screen again on phone rotation.
2023-02-05 08:53:50 +00:00
window . addEventListener ( "resize" , ( ) => {
this . resetResponsiveCSS ( ) ;
} ) ;
2023-02-10 07:03:06 +00:00
// Listen for window focus/unfocus events. Being on a different browser tab, for
// sound effect alert purposes, counts as not being "in" that chat channel when
// a message comes in.
window . addEventListener ( "focus" , ( ) => {
this . windowFocused = true ;
this . windowFocusedAt = new Date ( ) ;
} ) ;
window . addEventListener ( "blur" , ( ) => {
this . windowFocused = false ;
} )
2023-02-05 08:53:50 +00:00
for ( let channel of this . config . channels ) {
this . initHistory ( channel . ID ) ;
}
2023-02-06 04:26:00 +00:00
this . ChatClient ( "Welcome to BareRTC!" ) ;
2023-01-11 06:38:48 +00:00
2023-02-06 01:42:09 +00:00
// Auto login with JWT token?
// TODO: JWT validation on the WebSocket as well.
if ( this . jwt . valid && this . jwt . claims . sub ) {
this . username = this . jwt . claims . sub ;
2023-02-25 00:47:28 +00:00
this . autoLogin = true ;
2023-02-06 01:42:09 +00:00
}
// Scrub JWT token from query string parameters.
history . pushState ( null , "" , location . href . split ( "?" ) [ 0 ] ) ;
2023-02-25 00:47:28 +00:00
// XX: always show login dialog to test if this helps iOS devices.
2023-03-14 04:26:37 +00:00
// this.loginModal.visible = true;
2023-01-11 06:38:48 +00:00
if ( ! this . username ) {
this . loginModal . visible = true ;
2023-01-27 04:34:58 +00:00
} else {
this . signIn ( ) ;
2023-01-11 06:38:48 +00:00
}
2023-03-14 04:26:37 +00:00
} ,
watch : {
"webcam.videoScale" : ( ) => {
document . querySelectorAll ( ".video-feeds > .feed" ) . forEach ( node => {
node . style . width = null ;
node . style . height = null ;
} ) ;
} ,
2023-03-25 05:10:44 +00:00
fontSizeClass ( ) {
// Store the setting persistently.
localStorage . fontSizeClass = this . fontSizeClass ;
} ,
2023-01-11 06:38:48 +00:00
} ,
2023-02-05 08:53:50 +00:00
computed : {
chatHistory ( ) {
if ( this . channels [ this . channel ] == undefined ) {
return [ ] ;
}
let history = this . channels [ this . channel ] . history ;
// How channels work:
// - Everything going to a public channel like "lobby" goes
// into the "lobby" channel in the front-end
// - Direct messages are different: they are all addressed
// "to" the channel of the current @user, but they are
// divided into DM threads based on the username.
if ( this . channel . indexOf ( "@" ) === 0 ) {
// DM thread, divide them by sender.
// let username = this.channel.substring(1);
// return history.filter(v => {
// return v.username === username;
// });
}
return history ;
} ,
channelName ( ) {
// Return a suitable channel title.
if ( this . channel . indexOf ( "@" ) === 0 ) {
// A DM, return it directly as is.
return this . channel ;
}
// Find the friendly name from our config.
for ( let channel of this . config . channels ) {
if ( channel . ID === this . channel ) {
return channel . Name ;
}
}
return this . channel ;
} ,
2023-02-06 01:42:09 +00:00
isDM ( ) {
// Is the current channel a DM?
return this . channel . indexOf ( "@" ) === 0 ;
} ,
2023-02-05 08:53:50 +00:00
} ,
2023-01-11 06:38:48 +00:00
methods : {
2023-03-25 05:10:44 +00:00
// Load user prefs from localStorage, called on startup
setupConfig ( ) {
if ( localStorage . fontSizeClass != undefined ) {
this . fontSizeClass = localStorage . fontSizeClass ;
}
} ,
2023-01-11 06:38:48 +00:00
signIn ( ) {
this . loginModal . visible = false ;
this . dial ( ) ;
} ,
2023-01-27 06:54:02 +00:00
/ * *
* Chat API Methods ( WebSocket packets sent / received )
* /
2023-01-11 06:38:48 +00:00
sendMessage ( ) {
if ( ! this . message ) {
return ;
}
if ( ! this . ws . connected ) {
2023-01-27 04:34:58 +00:00
this . ChatClient ( "You are not connected to the server." ) ;
2023-01-11 06:38:48 +00:00
return ;
}
2023-02-06 21:33:09 +00:00
// console.debug("Send message: %s", this.message);
2023-01-11 06:38:48 +00:00
this . ws . conn . send ( JSON . stringify ( {
action : "message" ,
2023-02-05 08:53:50 +00:00
channel : this . channel ,
2023-01-11 06:38:48 +00:00
message : this . message ,
} ) ) ;
this . message = "" ;
} ,
2023-02-10 07:03:06 +00:00
sendTypingNotification ( ) {
// TODO
} ,
2023-01-27 04:34:58 +00:00
// Sync the current user state (such as video broadcasting status) to
// the backend, which will reload everybody's Who List.
sendMe ( ) {
this . ws . conn . send ( JSON . stringify ( {
action : "me" ,
videoActive : this . webcam . active ,
2023-02-11 06:46:39 +00:00
nsfw : this . webcam . nsfw ,
2023-01-27 04:34:58 +00:00
} ) ) ;
} ,
onMe ( msg ) {
// We have had settings pushed to us by the server, such as a change
// in our choice of username.
if ( this . username != msg . username ) {
this . ChatServer ( ` Your username has been changed to ${ msg . username } . ` ) ;
2023-01-27 06:54:02 +00:00
this . username = msg . username ;
2023-01-27 04:34:58 +00:00
}
2023-02-11 06:46:39 +00:00
// The server can set our webcam NSFW flag.
if ( this . webcam . nsfw != msg . nsfw ) {
this . webcam . nsfw = msg . nsfw ;
}
2023-02-05 08:53:50 +00:00
// this.ChatClient(`User sync from backend: ${JSON.stringify(msg)}`);
} ,
// WhoList updates.
onWho ( msg ) {
this . whoList = msg . whoList ;
2023-02-06 01:42:09 +00:00
this . whoMap = { } ;
2023-02-05 08:53:50 +00:00
// If we had a camera open with any of these and they have gone
// off camera, close our side of the connection.
for ( let row of this . whoList ) {
2023-02-06 01:42:09 +00:00
this . whoMap [ row . username ] = row ;
2023-02-05 08:53:50 +00:00
if ( this . WebRTC . streams [ row . username ] != undefined &&
row . videoActive !== true ) {
2023-02-06 23:02:23 +00:00
this . closeVideo ( row . username , "offerer" ) ;
2023-02-05 08:53:50 +00:00
}
}
2023-02-06 04:26:00 +00:00
// Has the back-end server forgotten we are on video? This can
// happen if we disconnect/reconnect while we were streaming.
if ( this . webcam . active && ! this . whoMap [ this . username ] ? . videoActive ) {
this . sendMe ( ) ;
}
2023-01-27 04:34:58 +00:00
} ,
2023-03-23 03:21:04 +00:00
// Mute or unmute a user.
muteUser ( username ) {
let mute = this . muted [ username ] == undefined ;
if ( mute ) {
this . muted [ username ] = true ;
} else {
delete this . muted [ username ] ;
}
this . sendMute ( username , mute ) ;
if ( mute ) {
this . ChatClient (
` You have muted <strong> ${ username } </strong> and will no longer see their chat messages, ` +
` and they will not see whether your webcam is active. You may unmute them via the Who Is Online list. ` ) ;
} else {
this . ChatClient (
` You have unmuted <strong> ${ username } </strong> and can see their chat messages from now on. ` ,
) ;
}
} ,
sendMute ( username , mute ) {
this . ws . conn . send ( JSON . stringify ( {
action : mute ? "mute" : "unmute" ,
username : username ,
} ) ) ;
} ,
isMutedUser ( username ) {
return this . muted [ username ] != undefined ;
} ,
2023-01-27 06:54:02 +00:00
// Send a video request to access a user's camera.
sendOpen ( username ) {
this . ws . conn . send ( JSON . stringify ( {
action : "open" ,
username : username ,
} ) ) ;
} ,
2023-03-23 03:21:04 +00:00
sendBoot ( username ) {
this . ws . conn . send ( JSON . stringify ( {
action : "boot" ,
username : username ,
} ) ) ;
} ,
2023-01-27 06:54:02 +00:00
onOpen ( msg ) {
// Response for the opener to begin WebRTC connection.
const secret = msg . openSecret ;
2023-02-06 21:33:09 +00:00
// console.log("OPEN: connect to %s with secret %s", msg.username, secret);
2023-02-05 08:53:50 +00:00
// this.ChatClient(`onOpen called for ${msg.username}.`);
2023-01-27 06:54:02 +00:00
this . startWebRTC ( msg . username , true ) ;
} ,
onRing ( msg ) {
// Message for the receiver to begin WebRTC connection.
const secret = msg . openSecret ;
2023-02-06 21:33:09 +00:00
// console.log("RING: connection from %s with secret %s", msg.username, secret);
2023-01-27 06:54:02 +00:00
this . ChatServer ( ` ${ msg . username } has opened your camera. ` ) ;
this . startWebRTC ( msg . username , false ) ;
} ,
2023-02-05 05:00:01 +00:00
onUserExited ( msg ) {
// A user has logged off the server. Clean up any WebRTC connections.
2023-02-05 08:53:50 +00:00
this . closeVideo ( msg . username ) ;
2023-02-05 05:00:01 +00:00
} ,
2023-01-27 06:54:02 +00:00
2023-01-27 04:34:58 +00:00
// Handle messages sent in chat.
onMessage ( msg ) {
2023-02-10 07:03:06 +00:00
// Play sound effects if this is not the active channel or the window is not focused.
2023-02-06 21:27:29 +00:00
if ( msg . channel . indexOf ( "@" ) === 0 ) {
2023-02-10 07:03:06 +00:00
if ( msg . channel !== this . channel || ! this . windowFocused ) {
2023-02-06 21:27:29 +00:00
this . playSound ( "DM" ) ;
}
2023-02-10 07:03:06 +00:00
} else if ( msg . channel !== this . channel || ! this . windowFocused ) {
2023-02-06 21:27:29 +00:00
this . playSound ( "Chat" ) ;
}
2023-01-27 04:34:58 +00:00
this . pushHistory ( {
2023-02-05 08:53:50 +00:00
channel : msg . channel ,
2023-01-27 04:34:58 +00:00
username : msg . username ,
message : msg . message ,
} ) ;
} ,
2023-02-06 01:42:09 +00:00
// User logged in or out.
onPresence ( msg ) {
// TODO: make a dedicated leave event
if ( msg . message . indexOf ( "has exited the room!" ) > - 1 ) {
// Clean up data about this user.
this . onUserExited ( msg ) ;
2023-02-06 21:27:29 +00:00
this . playSound ( "Leave" ) ;
} else {
this . playSound ( "Enter" ) ;
2023-02-06 01:42:09 +00:00
}
// Push it to the history of all public channels.
for ( let channel of this . config . channels ) {
this . pushHistory ( {
channel : channel . ID ,
action : msg . action ,
username : msg . username ,
message : msg . message ,
2023-02-06 21:27:29 +00:00
} ) ;
}
// Push also to any DM channels for this user.
let channel = "@" + msg . username ;
if ( this . channels [ channel ] != undefined ) {
this . pushHistory ( {
channel : channel ,
action : msg . action ,
username : msg . username ,
message : msg . message ,
2023-02-06 01:42:09 +00:00
} ) ;
}
} ,
2023-01-11 06:38:48 +00:00
// Dial the WebSocket connection.
dial ( ) {
2023-02-09 04:01:06 +00:00
this . ChatClient ( "Establishing connection to server..." ) ;
2023-02-05 05:00:01 +00:00
const proto = location . protocol === 'https:' ? 'wss' : 'ws' ;
const conn = new WebSocket ( ` ${ proto } :// ${ location . host } /ws ` ) ;
2023-01-11 06:38:48 +00:00
conn . addEventListener ( "close" , ev => {
2023-02-06 01:42:09 +00:00
// Lost connection to server - scrub who list.
this . onWho ( { whoList : [ ] } ) ;
2023-03-23 03:21:04 +00:00
this . muted = { } ;
2023-02-06 01:42:09 +00:00
2023-01-11 06:38:48 +00:00
this . ws . connected = false ;
2023-01-27 04:34:58 +00:00
this . ChatClient ( ` WebSocket Disconnected code: ${ ev . code } , reason: ${ ev . reason } ` ) ;
2023-01-11 06:38:48 +00:00
2023-02-20 05:36:36 +00:00
this . disconnectCount ++ ;
if ( this . disconnectCount > this . disconnectLimit ) {
this . ChatClient ( ` It seems there's a problem connecting to the server. Please try some other time. Note that iPhones and iPads currently have issues connecting to the chat room in general. ` ) ;
return ;
}
2023-02-11 06:46:39 +00:00
if ( ! this . disconnect ) {
if ( ev . code !== 1001 ) {
this . ChatClient ( "Reconnecting in 5s" ) ;
setTimeout ( this . dial , 5000 ) ;
}
2023-01-11 06:38:48 +00:00
}
} ) ;
conn . addEventListener ( "open" , ev => {
this . ws . connected = true ;
2023-01-27 04:34:58 +00:00
this . ChatClient ( "Websocket connected!" ) ;
2023-01-11 06:38:48 +00:00
// Tell the server our username.
this . ws . conn . send ( JSON . stringify ( {
action : "login" ,
username : this . username ,
2023-02-06 01:42:09 +00:00
jwt : this . jwt . token ,
2023-01-11 06:38:48 +00:00
} ) ) ;
} ) ;
conn . addEventListener ( "message" , ev => {
if ( typeof ev . data !== "string" ) {
console . error ( "unexpected message type" , typeof ev . data ) ;
return ;
}
let msg = JSON . parse ( ev . data ) ;
2023-02-06 21:27:29 +00:00
try {
// Cast timestamp to date.
msg . at = new Date ( msg . at ) ;
} catch ( e ) {
console . error ( "Parsing timestamp '%s' on msg: %s" , msg . at , e ) ;
}
2023-01-27 04:34:58 +00:00
switch ( msg . action ) {
case "who" :
2023-02-05 08:53:50 +00:00
this . onWho ( msg ) ;
2023-01-27 04:34:58 +00:00
break ;
case "me" :
this . onMe ( msg ) ;
break ;
case "message" :
this . onMessage ( msg ) ;
break ;
case "presence" :
2023-02-06 01:42:09 +00:00
this . onPresence ( msg ) ;
2023-01-27 04:34:58 +00:00
break ;
2023-01-27 06:54:02 +00:00
case "ring" :
this . onRing ( msg ) ;
break ;
case "open" :
this . onOpen ( msg ) ;
break ;
case "candidate" :
this . onCandidate ( msg ) ;
break ;
case "sdp" :
this . onSDP ( msg ) ;
break ;
2023-02-06 04:26:00 +00:00
case "watch" :
this . onWatch ( msg ) ;
break ;
case "unwatch" :
this . onUnwatch ( msg ) ;
break ;
2023-01-27 06:54:02 +00:00
case "error" :
this . pushHistory ( {
2023-02-06 01:42:09 +00:00
channel : msg . channel ,
2023-01-27 06:54:02 +00:00
username : msg . username || 'Internal Server Error' ,
message : msg . message ,
2023-02-06 01:42:09 +00:00
isChatServer : true ,
2023-01-27 06:54:02 +00:00
} ) ;
2023-02-06 21:27:29 +00:00
break ;
2023-02-11 06:46:39 +00:00
case "disconnect" :
this . disconnect = true ;
break ;
2023-02-06 21:27:29 +00:00
case "ping" :
break ;
2023-01-27 04:34:58 +00:00
default :
console . error ( "Unexpected action: %s" , JSON . stringify ( msg ) ) ;
}
2023-01-11 06:38:48 +00:00
} ) ;
this . ws . conn = conn ;
} ,
2023-01-27 06:54:02 +00:00
/ * *
* WebRTC concerns .
* /
// Start WebRTC with the other username.
startWebRTC ( username , isOfferer ) {
2023-02-05 08:53:50 +00:00
// this.ChatClient(`Begin WebRTC with ${username}.`);
2023-01-27 06:54:02 +00:00
let pc = new RTCPeerConnection ( configuration ) ;
2023-02-06 23:02:23 +00:00
// Store uni-directional PeerConnections:
// - If we are reading video from the other (offerer)
// - If we are sending video to the other (answerer)
if ( this . WebRTC . pc [ username ] == undefined ) {
this . WebRTC . pc [ username ] = { } ;
}
if ( isOfferer ) {
this . WebRTC . pc [ username ] . offerer = pc ;
} else {
this . WebRTC . pc [ username ] . answerer = pc ;
}
// Keep a pointer to the current channel being established (for candidate/SDP).
this . WebRTC . pc [ username ] . connecting = pc ;
2023-01-27 06:54:02 +00:00
2023-02-05 05:00:01 +00:00
// Create a data channel so we have something to connect over even if
// the local user is not broadcasting their own camera.
let dataChannel = pc . createDataChannel ( "data" ) ;
dataChannel . addEventListener ( "open" , event => {
// beginTransmission(dataChannel);
} )
2023-01-27 06:54:02 +00:00
// 'onicecandidate' notifies us whenever an ICE agent needs to deliver a
// message to the other peer through the signaling server.
pc . onicecandidate = event => {
if ( event . candidate ) {
this . ws . conn . send ( JSON . stringify ( {
action : "candidate" ,
username : username ,
2023-02-25 01:42:38 +00:00
candidate : JSON . stringify ( event . candidate ) ,
2023-01-27 06:54:02 +00:00
} ) ) ;
}
} ;
// If the user is offerer let the 'negotiationneeded' event create the offer.
if ( isOfferer ) {
pc . onnegotiationneeded = ( ) => {
pc . createOffer ( ) . then ( this . localDescCreated ( pc , username ) ) . catch ( this . ChatClient ) ;
} ;
}
// When a remote stream arrives.
pc . ontrack = event => {
const stream = event . streams [ 0 ] ;
// Do we already have it?
2023-02-05 08:53:50 +00:00
// this.ChatClient(`Received a video stream from ${username}.`);
2023-01-27 06:54:02 +00:00
if ( this . WebRTC . streams [ username ] == undefined ||
this . WebRTC . streams [ username ] . id !== stream . id ) {
this . WebRTC . streams [ username ] = stream ;
}
2023-02-05 05:00:01 +00:00
window . requestAnimationFrame ( ( ) => {
let $ref = document . getElementById ( ` videofeed- ${ username } ` ) ;
$ref . srcObject = stream ;
} ) ;
2023-02-06 04:26:00 +00:00
// Inform them they are being watched.
this . sendWatch ( username , true ) ;
2023-01-27 06:54:02 +00:00
} ;
// If we were already broadcasting video, send our stream to
// the connecting user.
2023-02-05 05:00:01 +00:00
// TODO: currently both users need to have video on for the connection
// to succeed - if offerer doesn't addTrack it won't request a video channel
// and so the answerer (who has video) won't actually send its
2023-02-05 08:53:50 +00:00
if ( ! isOfferer && this . webcam . active ) {
// this.ChatClient(`Sharing our video stream to ${username}.`);
2023-02-05 05:00:01 +00:00
let stream = this . webcam . stream ;
stream . getTracks ( ) . forEach ( track => {
pc . addTrack ( track , stream )
} ) ;
2023-01-27 06:54:02 +00:00
}
// If we are the offerer, begin the connection.
if ( isOfferer ) {
2023-02-05 08:53:50 +00:00
pc . createOffer ( {
offerToReceiveVideo : true ,
offerToReceiveAudio : true ,
} ) . then ( this . localDescCreated ( pc , username ) ) . catch ( this . ChatClient ) ;
2023-01-27 06:54:02 +00:00
}
} ,
// Common handler function for
localDescCreated ( pc , username ) {
return ( desc ) => {
2023-02-05 08:53:50 +00:00
// this.ChatClient(`setLocalDescription ${JSON.stringify(desc)}`);
2023-01-27 06:54:02 +00:00
pc . setLocalDescription (
new RTCSessionDescription ( desc ) ,
( ) => {
this . ws . conn . send ( JSON . stringify ( {
action : "sdp" ,
username : username ,
2023-02-25 01:42:38 +00:00
description : JSON . stringify ( pc . localDescription ) ,
2023-01-27 06:54:02 +00:00
} ) ) ;
} ,
console . error ,
)
} ;
} ,
// Handle inbound WebRTC signaling messages proxied by the websocket.
onCandidate ( msg ) {
2023-02-06 23:02:23 +00:00
if ( this . WebRTC . pc [ msg . username ] == undefined || ! this . WebRTC . pc [ msg . username ] . connecting ) {
2023-02-05 05:00:01 +00:00
return ;
}
2023-02-06 23:02:23 +00:00
let pc = this . WebRTC . pc [ msg . username ] . connecting ;
2023-01-27 06:54:02 +00:00
2023-02-25 01:42:38 +00:00
// XX: WebRTC candidate/SDP messages JSON stringify their inner payload so that the
// Go back-end server won't re-order their json keys (Safari on Mac OS is very sensitive
// to the keys being re-ordered during the handshake, in ways that NO OTHER BROWSER cares
// about at all). Re-parse the JSON stringified object here.
let candidate = JSON . parse ( msg . candidate ) ;
2023-01-27 06:54:02 +00:00
// Add the new ICE candidate.
pc . addIceCandidate (
new RTCIceCandidate (
2023-02-25 01:42:38 +00:00
candidate ,
2023-02-05 08:53:50 +00:00
( ) => { } ,
2023-01-27 06:54:02 +00:00
console . error ,
)
) ;
} ,
onSDP ( msg ) {
2023-02-06 23:02:23 +00:00
if ( this . WebRTC . pc [ msg . username ] == undefined || ! this . WebRTC . pc [ msg . username ] . connecting ) {
2023-02-05 05:00:01 +00:00
return ;
}
2023-02-06 23:02:23 +00:00
let pc = this . WebRTC . pc [ msg . username ] . connecting ;
2023-02-25 01:42:38 +00:00
// XX: WebRTC candidate/SDP messages JSON stringify their inner payload so that the
// Go back-end server won't re-order their json keys (Safari on Mac OS is very sensitive
// to the keys being re-ordered during the handshake, in ways that NO OTHER BROWSER cares
// about at all). Re-parse the JSON stringified object here.
let message = JSON . parse ( msg . description ) ;
2023-01-27 06:54:02 +00:00
// Add the new ICE candidate.
2023-02-05 08:53:50 +00:00
// this.ChatClient(`Received a Remote Description from ${msg.username}: ${JSON.stringify(msg.description)}.`);
2023-02-05 05:00:01 +00:00
pc . setRemoteDescription ( new RTCSessionDescription ( message ) , ( ) => {
2023-01-27 06:54:02 +00:00
// When receiving an offer let's answer it.
if ( pc . remoteDescription . type === 'offer' ) {
pc . createAnswer ( ) . then ( this . localDescCreated ( pc , msg . username ) ) . catch ( this . ChatClient ) ;
}
} , console . error ) ;
} ,
2023-02-06 04:26:00 +00:00
onWatch ( msg ) {
// The user has our video feed open now.
this . webcam . watching [ msg . username ] = true ;
} ,
onUnwatch ( msg ) {
// The user has closed our video feed.
delete ( this . webcam . watching [ msg . username ] ) ;
} ,
sendWatch ( username , watching ) {
// Send the watch or unwatch message to backend.
this . ws . conn . send ( JSON . stringify ( {
action : watching ? "watch" : "unwatch" ,
username : username ,
} ) ) ;
} ,
2023-01-27 06:54:02 +00:00
/ * *
* Front - end web app concerns .
* /
2023-02-06 21:27:29 +00:00
// Settings modal.
showSettings ( ) {
this . settingsModal . visible = true ;
} ,
hideSettings ( ) {
this . settingsModal . visible = false ;
} ,
2023-02-05 08:53:50 +00:00
// Set active chat room.
setChannel ( channel ) {
this . channel = typeof ( channel ) === "string" ? channel : channel . ID ;
this . scrollHistory ( ) ;
this . channels [ this . channel ] . unread = 0 ;
2023-02-06 01:42:09 +00:00
// Responsive CSS: switch back to chat panel upon selecting a channel.
this . openChatPanel ( ) ;
2023-02-12 00:02:48 +00:00
// Edit hyperlinks to open in a new window.
this . makeLinksExternal ( ) ;
2023-02-05 08:53:50 +00:00
} ,
hasUnread ( channel ) {
if ( this . channels [ channel ] == undefined ) {
return 0 ;
}
return this . channels [ channel ] . unread ;
} ,
openDMs ( user ) {
let channel = "@" + user . username ;
this . initHistory ( channel ) ;
this . setChannel ( channel ) ;
2023-02-06 01:42:09 +00:00
// Responsive CSS: switch back to chat panel upon opening a DM.
this . openChatPanel ( ) ;
} ,
openProfile ( user ) {
let url = this . profileURLForUsername ( user . username ) ;
if ( url ) {
window . open ( url ) ;
}
} ,
avatarURL ( user ) {
// Resolve the avatar URL of this user.
if ( user . avatar . match ( /^https?:/i ) ) {
return user . avatar ;
} else if ( user . avatar . indexOf ( "/" ) === 0 ) {
return this . config . website . replace ( /\/+$/ , "" ) + user . avatar ;
}
return "" ;
} ,
avatarForUsername ( username ) {
if ( this . whoMap [ username ] != undefined && this . whoMap [ username ] . avatar ) {
return this . avatarURL ( this . whoMap [ username ] ) ;
}
return null ;
} ,
profileURLForUsername ( username ) {
if ( ! username ) return ;
username = username . replace ( /^@/ , "" ) ;
if ( this . whoMap [ username ] != undefined && this . whoMap [ username ] . profileURL ) {
let url = this . whoMap [ username ] . profileURL ;
if ( url . match ( /^https?:/i ) ) {
return url ;
} else if ( url . indexOf ( "/" ) === 0 ) {
// Subdirectory relative to our WebsiteURL
return this . config . website . replace ( /\/+$/ , "" ) + url ;
} else {
this . ChatClient ( "Didn't know how to open profile URL: " + url ) ;
}
return url ;
}
return null ;
2023-02-05 08:53:50 +00:00
} ,
leaveDM ( ) {
// Validate we're in a DM currently.
if ( this . channel . indexOf ( "@" ) !== 0 ) return ;
if ( ! window . confirm (
"Do you want to close this chat thread? Your conversation history will " +
"be forgotten on your computer, but your chat partner may still have " +
"your chat thread open on their end."
) ) {
return ;
}
let channel = this . channel ;
this . setChannel ( this . config . channels [ 0 ] . ID ) ;
delete ( this . channels [ channel ] ) ;
} ,
activeChannels ( ) {
// List of current channels, unread indicators etc.
let result = [ ] ;
for ( let channel of this . config . channels ) {
let data = {
ID : channel . ID ,
Name : channel . Name ,
} ;
if ( this . channels [ channel ] != undefined ) {
data . Unread = this . channels [ channel ] . unread ;
data . Updated = this . channels [ channel ] . updated ;
}
result . push ( data ) ;
}
return result ;
} ,
activeDMs ( ) {
// List your currently open DM threads, sorted by most recent.
let result = [ ] ;
for ( let channel of Object . keys ( this . channels ) ) {
// @mentions only
if ( channel . indexOf ( "@" ) !== 0 ) {
continue ;
}
result . push ( {
channel : channel ,
name : channel . substring ( 1 ) ,
updated : this . channels [ channel ] . updated ,
unread : this . channels [ channel ] . unread ,
} ) ;
}
result . sort ( ( a , b ) => {
return a . updated < b . updated ;
} ) ;
return result ;
} ,
2023-01-27 04:34:58 +00:00
// Start broadcasting my webcam.
2023-02-11 06:46:39 +00:00
startVideo ( force ) {
2023-01-27 04:34:58 +00:00
if ( this . webcam . busy ) return ;
2023-02-11 06:46:39 +00:00
// If we are running in PermitNSFW mode, show the user the modal.
if ( this . config . permitNSFW && ! force ) {
this . nsfwModalCast . visible = true ;
return ;
}
this . webcam . busy = true ;
2023-01-27 04:34:58 +00:00
navigator . mediaDevices . getUserMedia ( {
audio : true ,
video : true ,
} ) . then ( stream => {
this . webcam . active = true ;
this . webcam . elem . srcObject = stream ;
this . webcam . stream = stream ;
// Tell backend the camera is ready.
this . sendMe ( ) ;
} ) . catch ( err => {
this . ChatClient ( ` Webcam error: ${ err } ` ) ;
} ) . finally ( ( ) => {
this . webcam . busy = false ;
} )
} ,
2023-01-27 06:54:02 +00:00
// Begin connecting to someone else's webcam.
2023-02-11 06:46:39 +00:00
openVideo ( user , force ) {
2023-01-27 06:54:02 +00:00
if ( user . username === this . username ) {
this . ChatClient ( "You can already see your own webcam." ) ;
return ;
}
2023-02-11 06:46:39 +00:00
// Is the target user NSFW? Go thru the modal.
let dontShowAgain = localStorage [ "skip-nsfw-modal" ] == "true" ;
if ( user . nsfw && ! dontShowAgain && ! force ) {
this . nsfwModalView . user = user ;
this . nsfwModalView . visible = true ;
return ;
}
if ( this . nsfwModalView . dontShowAgain ) {
// user doesn't want to see the modal again.
localStorage [ "skip-nsfw-modal" ] = "true" ;
}
2023-02-05 08:53:50 +00:00
// Camera is already open? Then disconnect the connection.
2023-02-06 23:02:23 +00:00
if ( this . WebRTC . pc [ user . username ] != undefined && this . WebRTC . pc [ user . username ] . offerer != undefined ) {
this . closeVideo ( user . username , "offerer" ) ;
2023-02-06 04:26:00 +00:00
return ;
2023-02-05 05:00:01 +00:00
}
2023-01-27 06:54:02 +00:00
this . sendOpen ( user . username ) ;
2023-02-06 04:26:00 +00:00
// Responsive CSS -> go to chat panel to see the camera
this . openChatPanel ( ) ;
2023-01-27 06:54:02 +00:00
} ,
2023-02-06 23:02:23 +00:00
closeVideo ( username , name ) {
if ( name === "offerer" ) {
// We are closing another user's video stream.
delete ( this . WebRTC . streams [ username ] ) ;
if ( this . WebRTC . pc [ username ] != undefined && this . WebRTC . pc [ username ] . offerer != undefined ) {
this . WebRTC . pc [ username ] . offerer . close ( ) ;
delete ( this . WebRTC . pc [ username ] ) ;
}
// Inform backend we have closed it.
this . sendWatch ( username , false ) ;
return ;
} else if ( name === "answerer" ) {
// We have turned off our camera, kick off viewers.
if ( this . WebRTC . pc [ username ] != undefined && this . WebRTC . pc [ username ] . answerer != undefined ) {
this . WebRTC . pc [ username ] . answerer . close ( ) ;
delete ( this . WebRTC . pc [ username ] ) ;
}
return ;
}
2023-02-05 08:53:50 +00:00
// A user has logged off the server. Clean up any WebRTC connections.
delete ( this . WebRTC . streams [ username ] ) ;
2023-02-06 04:26:00 +00:00
delete ( this . webcam . watching [ username ] ) ;
2023-02-05 08:53:50 +00:00
if ( this . WebRTC . pc [ username ] != undefined ) {
2023-02-06 23:02:23 +00:00
if ( this . WebRTC . pc [ username ] . offerer ) {
this . WebRTC . pc [ username ] . offerer . close ( ) ;
}
if ( this . WebRTC . pc [ username ] . answerer ) {
this . WebRTC . pc [ username ] . answerer . close ( ) ;
}
2023-02-05 08:53:50 +00:00
delete ( this . WebRTC . pc [ username ] ) ;
}
2023-02-06 04:26:00 +00:00
// Inform backend we have closed it.
this . sendWatch ( username , false ) ;
} ,
// Show who watches our video.
showViewers ( ) {
// TODO: for now, ChatClient is our bro.
let users = Object . keys ( this . webcam . watching ) ;
if ( users . length === 0 ) {
this . ChatClient ( "There is currently nobody viewing your camera." ) ;
} else {
this . ChatClient ( "Your current webcam viewers are:<br><br>" + users . join ( ", " ) ) ;
}
2023-03-23 03:21:04 +00:00
// Also focus the Watching list.
this . whoTab = 'watching' ;
// TODO: if mobile, show the panel - this width matches
// the media query in chat.css
if ( screen . width < 1024 ) {
this . openWhoPanel ( ) ;
}
} ,
// Boot someone off yourn video.
bootUser ( username ) {
if ( ! window . confirm (
` Kick ${ username } off your camera? This will also prevent them ` +
` from seeing that your camera is active for the remainder of your ` +
` chat session. ` ) ) {
return ;
}
this . sendBoot ( username ) ;
// Close the WebRTC peer connection.
if ( this . WebRTC . pc [ username ] != undefined ) {
this . closeVideo ( username , "answerer" ) ;
}
this . ChatClient (
` You have booted ${ username } off your camera. They will no longer be able ` +
` to connect to your camera, or even see that your camera is active at all -- ` +
` to them it appears as though you had turned yours off.<br><br>This will be ` +
` in place for the remainder of your current chat session. `
) ;
2023-02-05 08:53:50 +00:00
} ,
2023-01-27 06:54:02 +00:00
2023-01-27 04:34:58 +00:00
// Stop broadcasting.
stopVideo ( ) {
this . webcam . elem . srcObject = null ;
this . webcam . stream = null ;
this . webcam . active = false ;
2023-03-23 03:21:04 +00:00
this . whoTab = "online" ;
2023-01-27 04:34:58 +00:00
2023-02-05 08:53:50 +00:00
// Close all WebRTC sessions.
for ( username of Object . keys ( this . WebRTC . pc ) ) {
2023-02-06 23:02:23 +00:00
this . closeVideo ( username , "answerer" ) ;
2023-02-05 08:53:50 +00:00
}
2023-01-27 04:34:58 +00:00
// Tell backend our camera state.
this . sendMe ( ) ;
} ,
2023-02-06 04:26:00 +00:00
// Mute my microphone if broadcasting.
muteMe ( ) {
this . webcam . muted = ! this . webcam . muted ;
this . webcam . stream . getAudioTracks ( ) . forEach ( track => {
track . enabled = ! this . webcam . muted ;
} ) ;
} ,
isMuted ( username ) {
return this . WebRTC . muted [ username ] === true ;
} ,
muteVideo ( username ) {
this . WebRTC . muted [ username ] = ! this . isMuted ( username ) ;
// Find the <video> tag to mute it.
let $ref = document . getElementById ( ` videofeed- ${ username } ` ) ;
if ( $ref ) {
$ref . muted = this . WebRTC . muted [ username ] ;
}
} ,
2023-02-05 08:53:50 +00:00
initHistory ( channel ) {
if ( this . channels [ channel ] == undefined ) {
this . channels [ channel ] = {
history : [ ] ,
updated : Date . now ( ) ,
unread : 0 ,
} ;
}
} ,
2023-02-10 07:03:06 +00:00
pushHistory ( { channel , username , message , action = "message" , isChatServer , isChatClient } ) {
2023-02-05 08:53:50 +00:00
// Default channel = your current channel.
if ( ! channel ) {
channel = this . channel ;
}
// Initialize this channel's history?
this . initHistory ( channel ) ;
// Append the message.
this . channels [ channel ] . updated = Date . now ( ) ;
this . channels [ channel ] . history . push ( {
2023-01-27 04:34:58 +00:00
action : action ,
2023-01-11 06:38:48 +00:00
username : username ,
message : message ,
2023-02-10 07:03:06 +00:00
at : new Date ( ) ,
2023-01-27 04:34:58 +00:00
isChatServer ,
isChatClient ,
2023-01-11 06:38:48 +00:00
} ) ;
2023-01-27 04:34:58 +00:00
this . scrollHistory ( ) ;
2023-02-05 08:53:50 +00:00
// Mark unread notifiers if this is not our channel.
if ( this . channel !== channel ) {
2023-02-06 01:42:09 +00:00
// Don't notify about presence broadcasts.
2023-03-17 02:02:59 +00:00
if ( action !== "presence" && ! isChatServer ) {
2023-02-06 01:42:09 +00:00
this . channels [ channel ] . unread ++ ;
}
2023-02-05 08:53:50 +00:00
}
2023-02-12 00:02:48 +00:00
// Edit hyperlinks to open in a new window.
this . makeLinksExternal ( ) ;
2023-01-27 04:34:58 +00:00
} ,
scrollHistory ( ) {
2023-03-25 04:56:40 +00:00
if ( ! this . autoscroll ) return ;
2023-01-27 04:34:58 +00:00
window . requestAnimationFrame ( ( ) => {
this . historyScrollbox . scroll ( {
top : this . historyScrollbox . scrollHeight ,
behavior : 'smooth' ,
} ) ;
} ) ;
} ,
2023-02-05 08:53:50 +00:00
// Responsive CSS controls for mobile. Notes for maintenance:
// - The chat.css has responsive CSS to hide the left/right panels
// and set the grid-template-columns for devices < 1024px width
// - These functions override w/ style tags to force the drawer to
// be visible and change the grid-template-columns.
// - On window resize (e.g. device rotation) or when closing one
// of the side drawers, we reset our CSS overrides to default so
// the main chat window reappears.
openChannelsPanel ( ) {
// Open the left drawer
let $container = this . responsive . nodes . $container ,
$drawer = this . responsive . nodes . $left ;
$container . style . gridTemplateColumns = "1fr 0 0" ;
$drawer . style . display = "block" ;
} ,
openWhoPanel ( ) {
// Open the right drawer
let $container = this . responsive . nodes . $container ,
$drawer = this . responsive . nodes . $right ;
$container . style . gridTemplateColumns = "0 0 1fr" ;
$drawer . style . display = "block" ;
} ,
openChatPanel ( ) {
this . resetResponsiveCSS ( ) ;
} ,
resetResponsiveCSS ( ) {
let $container = this . responsive . nodes . $container ,
$left = this . responsive . nodes . $left ,
$right = this . responsive . nodes . $right ;
$left . style . removeProperty ( "display" ) ;
$right . style . removeProperty ( "display" ) ;
$container . style . removeProperty ( "grid-template-columns" ) ;
} ,
2023-01-27 04:34:58 +00:00
// Send a chat message as ChatServer
ChatServer ( message ) {
this . pushHistory ( {
username : "ChatServer" ,
message : message ,
isChatServer : true ,
} ) ;
} ,
ChatClient ( message ) {
this . pushHistory ( {
username : "ChatClient" ,
message : message ,
isChatClient : true ,
} ) ;
} ,
2023-02-06 21:27:29 +00:00
// Format a datetime nicely for chat timestamp.
prettyDate ( date ) {
let hours = date . getHours ( ) ,
minutes = String ( date . getMinutes ( ) ) . padStart ( 2 , '0' ) ,
seconds = String ( date . getSeconds ( ) ) . padStart ( 2 , '0' ) ,
ampm = hours >= 11 ? "pm" : "am" ;
2023-02-10 07:03:06 +00:00
let hour = hours % 12 || 12 ;
return ` ${ ( hour ) } : ${ minutes } : ${ seconds } ${ ampm } ` ;
2023-02-06 21:27:29 +00:00
} ,
2023-03-22 04:29:24 +00:00
/ * *
* Image sharing in chat
* /
// The image upload button handler.
uploadFile ( ) {
let input = document . createElement ( 'input' ) ;
input . type = 'file' ;
input . accept = 'image/*' ;
input . onchange = e => {
let file = e . target . files [ 0 ] ;
if ( file . size > FileUploadMaxSize ) {
this . ChatClient ( ` Please share an image smaller than ${ FileUploadMaxSize / 1024 / 1024 } MB in size! ` ) ;
return ;
}
this . ChatClient ( ` <em>Uploading file to chat: ${ file . name } - ${ file . size } bytes, ${ file . type } format.</em> ` ) ;
// Get image file data.
let reader = new FileReader ( ) ;
let rawData = new ArrayBuffer ( ) ;
reader . onload = e => {
rawData = e . target . result ;
let fileByteArray = [ ] ,
u8array = new Uint8Array ( rawData ) ;
for ( let i = 0 ; i < u8array . length ; i ++ ) {
fileByteArray . push ( u8array [ i ] ) ;
}
let msg = JSON . stringify ( {
action : "file" ,
channel : this . channel ,
message : file . name ,
bytes : fileByteArray , //btoa(fileByteArray),
} ) ;
// Send it to the chat server.
this . ws . conn . send ( msg ) ;
} ;
reader . readAsArrayBuffer ( file ) ;
} ;
input . click ( ) ;
} ,
2023-02-06 21:27:29 +00:00
/ * *
* Sound effect concerns .
* /
setupSounds ( ) {
2023-02-09 05:23:08 +00:00
try {
if ( AudioContext ) {
this . config . sounds . audioContext = new AudioContext ( ) ;
} else {
this . config . sounds . audioContext = window . AudioContext || window . webkitAudioContext ;
}
} catch { }
2023-02-06 21:27:29 +00:00
if ( ! this . config . sounds . audioContext ) {
console . error ( "Couldn't set up AudioContext! No sound effects will be supported." ) ;
return ;
}
// Create <audio> elements for all the sounds.
for ( let effect of this . config . sounds . available ) {
if ( ! effect . filename ) continue ; // 'Quiet' has no audio
let elem = document . createElement ( "audio" ) ;
elem . autoplay = false ;
elem . src = ` /static/sfx/ ${ effect . filename } ` ;
document . body . appendChild ( elem ) ;
let track = this . config . sounds . audioContext . createMediaElementSource ( elem ) ;
track . connect ( this . config . sounds . audioContext . destination ) ;
this . config . sounds . audioTracks [ effect . name ] = elem ;
}
// Apply the user's saved preferences if any.
for ( let setting of Object . keys ( this . config . sounds . settings ) ) {
if ( localStorage [ ` sound: ${ setting } ` ] != undefined ) {
this . config . sounds . settings [ setting ] = localStorage [ ` sound: ${ setting } ` ] ;
}
}
} ,
playSound ( event ) {
let filename = this . config . sounds . settings [ event ] ;
// Do we have an audio track?
if ( this . config . sounds . audioTracks [ filename ] != undefined ) {
let track = this . config . sounds . audioTracks [ filename ] ;
track . play ( ) ;
}
} ,
setSoundPref ( event ) {
this . playSound ( event ) ;
// Store the user's setting in localStorage.
localStorage [ ` sound: ${ event } ` ] = this . config . sounds . settings [ event ] ;
} ,
2023-02-12 00:02:48 +00:00
makeLinksExternal ( ) {
window . requestAnimationFrame ( ( ) => {
let $history = document . querySelector ( "#chatHistory" ) ;
// Make all <a> links appearing in chat into external links.
( $history . querySelectorAll ( "a" ) || [ ] ) . forEach ( node => {
let href = node . attributes . href ,
target = node . attributes . target ;
if ( href == undefined || target != undefined ) return ;
node . target = "_blank" ;
} ) ;
} )
} ,
2023-01-11 06:38:48 +00:00
}
} ) ;
app . mount ( "#BareRTC-App" ) ;