BareRTC/src/App.vue

3995 lines
169 KiB
Vue

<script>
import interact from 'interactjs';
import FloatingVue from 'floating-vue';
import 'floating-vue/dist/style.css';
import { Mentionable } from 'vue-mention';
import EmojiPicker from 'vue3-emoji-picker';
import LoginModal from './components/LoginModal.vue';
import ExplicitOpenModal from './components/ExplicitOpenModal.vue';
import ReportModal from './components/ReportModal.vue';
import MessageBox from './components/MessageBox.vue';
import WhoListRow from './components/WhoListRow.vue';
import VideoFeed from './components/VideoFeed.vue';
import ProfileModal from './components/ProfileModal.vue';
import LocalStorage from './lib/LocalStorage';
import VideoFlag from './lib/VideoFlag';
import { SoundEffects, DefaultSounds } from './lib/sounds';
import { isAppleWebkit } from './lib/browsers';
// WebRTC configuration.
const configuration = {
iceServers: TURN.URLs.map(val => {
let row = {
urls: val,
};
if (val.indexOf('turn:') === 0) {
row.username = TURN.Username;
row.credential = TURN.Credential;
}
return row;
})
};
const FileUploadMaxSize = 1024 * 1024 * 8; // 8 MB
export default {
name: 'BareRTC',
components: {
// Third party components
FloatingVue,
Mentionable,
EmojiPicker,
// My components
LoginModal,
ExplicitOpenModal,
ReportModal,
MessageBox,
WhoListRow,
VideoFeed,
ProfileModal,
},
data() {
return {
// busy: false, // TODO: not used
disconnect: false, // don't try to reconnect (e.g. kicked)
windowFocused: true, // browser tab is active
windowFocusedAt: new Date(),
// Disconnect spamming: don't retry too many times.
disconnectLimit: 2,
disconnectCount: 0,
// Temp: spam counting for OF links
spamWarningCount: 0,
// Website configuration provided by chat.html template.
config: {
branding: Branding,
channels: PublicChannels,
website: WebsiteURL,
permitNSFW: PermitNSFW,
webhookURLs: WebhookURLs,
VIP: VIP,
fontSizeClasses: [
["x-2", "Very small chat room text"],
["x-1", "50% smaller chat room text"],
["", "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"],
],
messageStyleSettings: [
["cards", "Card style (default)"],
["compact", "Compact style (with display names)"],
["compact2", "Compact style (usernames only)"],
],
imageDisplaySettings: [
["show", "Always show images in chat"],
["collapse", "Collapse images in chat, clicking to expand (default)"],
["hide", "Never show images shared in chat"],
],
sounds: {
available: SoundEffects,
settings: DefaultSounds,
ready: false,
audioContext: null,
audioTracks: {},
},
// Cached blocklist for the current user sent by your website.
CachedBlocklist: CachedBlocklist,
},
// User JWT settings if available.
jwt: {
token: UserJWTToken,
valid: UserJWTValid,
claims: UserJWTClaims
},
channel: "lobby",
username: "", //"test",
autoLogin: false, // e.g. from JWT auth
message: "",
messageBox: null, // HTML element for message entry box
typingNotifDebounce: null,
status: "online", // away/idle status
// Emoji picker visible for messages
showEmojiPicker: false,
// Idle detection variables
idleTimeout: null,
idleThreshold: 300, // number of seconds you must be idle
// WebSocket connection.
ws: {
conn: null,
connected: false,
},
// Who List for the room.
whoList: [],
whoTab: 'online',
whoSort: 'a-z',
whoMap: {}, // map username to wholist entry
whoOnline: {}, // map users actually online right now
muted: {}, // muted usernames for client side state
// Misc. user preferences (TODO: move all of them here)
prefs: {
joinMessages: true, // show "has entered the room" in public channels
exitMessages: false, // hide exit messages by default in public channels
watchNotif: true, // notify in chat about cameras being watched
closeDMs: false, // ignore unsolicited DMs
muteSounds: false, // mute all sound effects
},
// My video feed.
webcam: {
busy: false,
active: false,
elem: null, // <video id="localVideo"> element
stream: null, // MediaStream object
muted: false, // our outgoing mic is muted, not by default
autoMute: false, // mute our mic automatically when going live (user option)
nsfw: false, // user has flagged their camera to be NSFW
mutual: false, // user wants viewers to share their own videos
mutualOpen: false, // user wants to open video mutually
vipOnly: false, // only show camera to fellow VIP users
// Who all is watching me? map of users.
watching: {},
// 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)"],
],
// Available cameras and microphones for the Settings modal.
gettingDevices: false, // busy indicator for refreshing devices
videoDevices: [],
videoDeviceID: null,
audioDevices: [],
audioDeviceID: null,
// After we get a device selected, remember it (by name) so that we
// might hopefully re-select it by default IF we are able to enumerate
// devices before they go on camera the first time.
preferredDeviceNames: {
video: null,
audio: null,
},
},
// Video flag constants (sync with values in messages.go)
VideoFlag: VideoFlag,
// WebRTC sessions with other users.
WebRTC: {
// Streams per username.
streams: {},
muted: {}, // muted bool per username
booted: {}, // booted bool per username
poppedOut: {}, // popped-out video per username
// RTCPeerConnections per username.
pc: {},
// Video stream freeze detection.
frozenStreamInterval: {}, // map usernames to intervals
frozenStreamDetected: {}, // map usernames to bools
// Debounce connection attempts since now every click = try to connect.
debounceOpens: {}, // map usernames to bools
// Timeouts for open camera attempts. e.g.: when you click to view
// a camera, the icon changes to a spinner for a few seconds to see
// whether the video goes on to open.
openTimeouts: {}, // map usernames to timeouts
},
// Chat history.
history: [],
channels: {
// There will be values here like:
// "lobby": {
// "history": [],
// "updated": timestamp,
// "unread": 4,
// },
// "@username": {
// "history": [],
// ...
// }
},
historyScrollbox: null,
autoscroll: true, // scroll to bottom on new messages
fontSizeClass: "", // font size magnification
messageStyle: "cards", // message display style
imageDisplaySetting: "collapse", // image show/hide setting
scrollback: 1000, // scrollback buffer (messages to keep per channel)
DMs: {},
messageReactions: {
// Will look like:
// "123": { (message ID)
// "❤️": [ (reaction emoji)
// "username" // users who reacted
// ]
// }
},
// Responsive CSS controls for mobile.
responsive: {
leftDrawerOpen: false,
rightDrawerOpen: false,
nodes: {
// DOM nodes for the CSS grid cells.
$container: null,
$left: null,
$center: null,
$right: null,
}
},
loginModal: {
visible: false,
},
settingsModal: {
visible: false,
tab: 'prefs', // selected setting tab
},
nsfwModalCast: {
visible: false,
},
nsfwModalView: {
visible: false,
dontShowAgain: false,
user: null, // staged User we wanted to open
},
reportModal: {
visible: false,
busy: false,
user: {}, // full copy of the user for the modal component
message: {},
origMessage: {}, // pointer, so we can set the "reported" flag
},
profileModal: {
visible: false,
user: {},
username: "",
},
}
},
mounted() {
this.setupConfig(); // localSettings persisted settings
this.setupIdleDetection();
this.webcam.elem = document.querySelector("#localVideo");
this.historyScrollbox = document.querySelector("#chatHistory");
this.responsive.nodes = {
$container: document.querySelector(".chat-container"),
$left: document.querySelector(".left-column"),
$center: document.querySelector(".chat-column"),
$right: document.querySelector(".right-column"),
};
this.messageBox = document.getElementById("messageBox");
// Reset CSS overrides for responsive display on any window size change. In effect,
// making the chat panel the current screen again on phone rotation.
window.addEventListener("resize", () => {
this.resetResponsiveCSS();
});
// 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;
});
// Set up sound effects on first page interaction.
window.addEventListener("click", () => {
this.setupSounds();
});
window.addEventListener("keydown", () => {
this.setupSounds();
});
for (let channel of this.config.channels) {
this.initHistory(channel.ID);
}
this.ChatClient("Welcome to BareRTC!");
// 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;
this.autoLogin = true;
}
// Scrub JWT token from query string parameters.
history.pushState(null, "", location.href.split("?")[0]);
// XX: always show login dialog to test if this helps iOS devices.
// this.loginModal.visible = true;
if (!this.username) {
this.loginModal.visible = true;
} else {
this.signIn(this.username);
}
},
watch: {
"webcam.videoScale": function () {
document.querySelectorAll(".video-feeds > .feed").forEach(node => {
node.style.width = null;
node.style.height = null;
});
LocalStorage.set('videoScale', this.webcam.videoScale);
},
whoSort() {
LocalStorage.set('whoSort', this.whoSort);
},
fontSizeClass() {
// Store the setting persistently.
LocalStorage.set('fontSizeClass', this.fontSizeClass);
},
messageStyle() {
LocalStorage.set('messageStyle', this.messageStyle);
},
imageDisplaySetting() {
LocalStorage.set('imageDisplaySetting', this.imageDisplaySetting);
},
scrollback() {
LocalStorage.set('scrollback', this.scrollback);
},
status() {
// Send presence updates to the server.
this.sendMe();
},
// Webcam preferences that the user can edit while live.
"webcam.nsfw": function () {
if (this.webcam.active) {
this.sendMe();
}
},
"webcam.mutual": function () {
if (this.webcam.active) {
this.sendMe();
}
},
"webcam.mutualOpen": function () {
if (this.webcam.active) {
this.sendMe();
}
},
"webcam.vipOnly": function () {
if (this.webcam.active) {
this.sendMe();
}
// If we have toggled this while already connected to people:
// Hang up on any that have a mutual viewership requirement, if
// they can not see our VIP-only camera.
for (let username of Object.keys(this.WebRTC.pc)) {
if (this.whoMap[username] != undefined && this.isVideoNotAllowed(this.whoMap[username])) {
this.closeVideo(username);
}
}
},
// Misc preference watches
"prefs.joinMessages": function () {
LocalStorage.set('joinMessages', this.prefs.joinMessages);
},
"prefs.exitMessages": function () {
LocalStorage.set('exitMessages', this.prefs.exitMessages);
},
"prefs.watchNotif": function () {
LocalStorage.set('watchNotif', this.prefs.watchNotif);
},
"prefs.muteSounds": function () {
LocalStorage.set('muteSounds', this.prefs.muteSounds);
},
"prefs.closeDMs": function () {
LocalStorage.set('closeDMs', this.prefs.closeDMs);
// Tell ChatServer if we have gone to/from DND.
this.sendMe();
},
},
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;
},
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) => b.updated - a.updated);
return result;
},
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;
},
isDM() {
// Is the current channel a DM?
return this.channel.indexOf("@") === 0;
},
canUploadFile() {
// Public channels: OK
if (!this.channel.indexOf('@') === 0) {
return true;
}
// User is an admin?
if (this.jwt.claims.op) {
return true;
}
// User is in a DM thread with an admin?
if (this.isDM) {
let partner = this.normalizeUsername(this.channel);
if (this.whoMap[partner] != undefined && this.whoMap[partner].op) {
return true;
}
}
return !this.isDM;
},
isOp() {
// Returns if the current user has operator rights
return this.jwt.claims.op;
},
isVIP() {
// Returns if the current user has VIP rights.
return this.jwt.claims.vip;
},
myVideoFlag() {
// Compute the current user's video status flags.
let status = 0;
if (!this.webcam.active) return 0; // unset all flags if not active now
if (this.webcam.active) status |= this.VideoFlag.Active;
if (this.webcam.muted) status |= this.VideoFlag.Muted;
if (this.webcam.nsfw) status |= this.VideoFlag.NSFW;
if (this.webcam.mutual) status |= this.VideoFlag.MutualRequired;
if (this.webcam.mutualOpen) status |= this.VideoFlag.MutualOpen;
if (this.webcam.vipOnly && this.isVIP) status |= this.VideoFlag.VipOnly;
return status;
},
numVideosOpen() {
// Return the count of other peoples videos we have open.
return Object.keys(this.WebRTC.streams).length;
},
atMentionItems() {
// Available users in chat for the at-mentions support.
let result = [
{
value: "all",
label: "All people in the current room",
searchText: "all people in the current room"
},
{
value: "here",
label: "Everybody here in the current room",
searchText: "all people in the current room"
},
];
for (let user of this.whoList) {
if (user.username === this.username) continue;
result.push({
value: user.username,
label: user.nickname,
searchText: user.username + " " + user.nickname,
});
}
return result;
},
sortedWhoList() {
let result = [...this.whoList];
switch (this.whoSort) {
case "broadcasting":
result.sort((a, b) => {
return (b.video & this.VideoFlag.Active) - (a.video & this.VideoFlag.Active);
});
break;
case "nsfw":
result.sort((a, b) => {
let left = (a.video & (this.VideoFlag.Active | this.VideoFlag.NSFW)),
right = (b.video & (this.VideoFlag.Active | this.VideoFlag.NSFW));
return right - left;
});
break;
case "status":
result.sort((a, b) => {
if (a.status === b.status) return 0;
return b.status < a.status ? -1 : 1;
});
break;
case "op":
result.sort((a, b) => {
return b.op - a.op;
});
break;
case "emoji":
result.sort((a, b) => {
if (a.emoji === b.emoji) return 0;
return a.emoji < b.emoji ? -1 : 1;
})
break;
case "login":
result.sort((a, b) => {
return b.loginAt - a.loginAt;
});
break;
case "gender":
result.sort((a, b) => {
if (a.gender === b.gender) return 0;
let left = a.gender || 'z',
right = b.gender || 'z';
return left < right ? -1 : 1;
})
break;
case "z-a":
result = result.reverse();
}
// Default ordering from ChatServer = a-z
return result;
},
sortedWatchingList() {
let result = [];
for (let username of Object.keys(this.webcam.watching)) {
let user = this.getUser(username);
result.push(user);
}
switch (this.whoSort) {
case "broadcasting":
result.sort((a, b) => {
return (b.video & this.VideoFlag.Active) - (a.video & this.VideoFlag.Active);
});
break;
case "nsfw":
result.sort((a, b) => {
let left = (a.video & (this.VideoFlag.Active | this.VideoFlag.NSFW)),
right = (b.video & (this.VideoFlag.Active | this.VideoFlag.NSFW));
return right - left;
});
break;
case "status":
result.sort((a, b) => {
if (a.status === b.status) return 0;
return b.status < a.status ? -1 : 1;
});
break;
case "op":
result.sort((a, b) => {
return b.op - a.op;
});
break;
case "emoji":
result.sort((a, b) => {
if (a.emoji === b.emoji) return 0;
return a.emoji < b.emoji ? -1 : 1;
})
break;
case "login":
result.sort((a, b) => {
return b.loginAt - a.loginAt;
});
break;
case "gender":
result.sort((a, b) => {
if (a.gender === b.gender) return 0;
let left = a.gender || 'z',
right = b.gender || 'z';
return left < right ? -1 : 1;
})
break;
case "z-a":
result = result.reverse();
}
// Default ordering from ChatServer = a-z
return result;
},
},
methods: {
// Load user prefs from localStorage, called on startup
setupConfig() {
const settings = LocalStorage.getSettings();
if (settings.fontSizeClass != undefined) {
this.fontSizeClass = settings.fontSizeClass;
}
if (settings.messageStyle != undefined) {
this.messageStyle = settings.messageStyle;
}
if (settings.videoScale != undefined) {
this.webcam.videoScale = settings.videoScale;
}
if (settings.imageDisplaySetting != undefined) {
this.imageDisplaySetting = settings.imageDisplaySetting;
}
if (settings.scrollback != undefined) {
this.scrollback = parseInt(settings.scrollback);
}
// Stored user preferred device names for webcam/audio.
if (settings.preferredDeviceNames != undefined) {
let dev = settings.preferredDeviceNames;
this.webcam.preferredDeviceNames.video = dev.video;
this.webcam.preferredDeviceNames.audio = dev.audio;
}
// Webcam mutality preferences from last broadcast.
if (settings.videoMutual === true) {
this.webcam.mutual = true;
}
if (settings.videoMutualOpen === true) {
this.webcam.mutualOpen = true;
}
if (settings.videoAutoMute === true) {
this.webcam.autoMute = true;
}
if (settings.videoVipOnly === true) {
this.webcam.vipOnly = true;
}
if (settings.videoExplicit === true) {
this.webcam.nsfw = true;
}
// Misc preferences
if (settings.joinMessages != undefined) {
this.prefs.joinMessages = settings.joinMessages === true;
}
if (settings.exitMessages != undefined) {
this.prefs.exitMessages = settings.exitMessages === true;
}
if (settings.watchNotif != undefined) {
this.prefs.watchNotif = settings.watchNotif === true;
}
if (settings.muteSounds != undefined) {
this.prefs.muteSounds = settings.muteSounds === true;
}
if (settings.closeDMs != undefined) {
this.prefs.closeDMs = settings.closeDMs === true;
}
if (settings.whoSort != undefined) {
this.whoSort = settings.whoSort;
}
},
signIn(username) {
this.username = username;
this.loginModal.visible = false;
this.dial();
},
// Normalize a DM channel name into a username (remove the @ prefix)
normalizeUsername(channel) {
return channel.replace(/^@+/, '');
},
/**
* Chat API Methods (WebSocket packets sent/received)
*/
onSelectEmoji(e) {
// Callback from EmojiPicker to add an emoji to the user textbox.
let selectionStart = this.messageBox.selectionStart;
this.message = this.message.slice(0, selectionStart) + e.i + this.message.slice(selectionStart);
this.hideEmojiPicker();
},
hideEmojiPicker() {
// Hide the emoji menu (after sending an emoji or clicking the react button again)
if (!this.showEmojiPicker) return;
window.requestAnimationFrame(() => {
this.showEmojiPicker = false;
this.messageBox.focus();
this.messageBox.selectionStart = this.messageBox.selectionEnd = this.messageBox.value.length;
});
},
sendMessage() {
if (!this.message) {
return;
}
if (!this.ws.connected) {
this.ChatClient("You are not connected to the server.");
return;
}
// Spammy links.
if (this.message.toLowerCase().indexOf("onlyfans.com") > -1 ||
this.message.toLowerCase().indexOf("justfor.fans") > -1 ||
this.message.toLowerCase().indexOf("justforfans") > -1 ||
this.message.toLowerCase().match(/fans[^A-Za-z0-9]+dot/)) {
// If they do it twice, kick them from the room.
if (this.spamWarningCount >= 1) {
// Walk of shame.
this.ws.conn.send(JSON.stringify({
action: "message",
channel: "lobby",
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. ☹️",
}));
this.ChatServer(
"It is <strong>not allowed</strong> to promote your Onlyfans (or similar) " +
"site on the chat room. You have been removed from the chat room, and this " +
"incident has been reported to the site admin.",
);
this.pushHistory({
channel: this.channel,
username: this.username,
message: "has been kicked from the room!",
action: "presence",
});
this.disconnect = true;
this.ws.connected = false;
setTimeout(() => {
this.ws.conn.close();
}, 1000);
return;
}
this.spamWarningCount++;
this.ChatClient(
"Please <strong>do not</strong> send links to your Onlyfans (or similar sites) in the chat room. " +
"Those links are widely regarded to be spam and make a lot of people uncomfortable. " +
"If you violate this again, your account will be suspended.",
);
this.message = "";
return;
}
// DEBUGGING: fake set the freeze indicator.
let match = this.message.match(/^\/freeze (.+?)$/i);
if (match) {
let username = match[1];
this.WebRTC.frozenStreamDetected[username] = true;
this.ChatClient(`DEBUG: Marked ${username} stream as frozen.`);
this.message = "";
return;
}
// DEBUGGING: test whether the page thinks you're Apple Webkit.
if (this.message.toLowerCase().indexOf("/ipad") === 0) {
if (isAppleWebkit()) {
this.ChatClient("I have detected that you are probably an iPad or iPhone browser.");
} else {
this.ChatClient("I have detected that you <strong>are not</strong> an iPad or iPhone browser.");
}
this.message = "";
return;
}
// console.debug("Send message: %s", this.message);
this.ws.conn.send(JSON.stringify({
action: "message",
channel: this.channel,
message: this.message,
}));
this.message = "";
},
sendTypingNotification() {
// Send typing indicator for DM threads.
},
// Emoji reactions
sendReact(message, emoji) {
this.ws.conn.send(JSON.stringify({
action: 'react',
msgID: message.msgID,
message: emoji,
}));
},
onReact(msg) {
// Search all channels for this message ID and append the reaction.
let msgID = msg.msgID,
who = msg.username,
emoji = msg.message;
if (this.messageReactions[msgID] == undefined) {
this.messageReactions[msgID] = {};
}
if (this.messageReactions[msgID][emoji] == undefined) {
this.messageReactions[msgID][emoji] = [];
}
// if they double sent the same reaction, it counts as a removal
let unreact = false;
for (let i = 0; i < this.messageReactions[msgID][emoji].length; i++) {
let reactor = this.messageReactions[msgID][emoji][i];
if (reactor === who) {
this.messageReactions[msgID][emoji].splice(i, 1);
unreact = true;
}
}
// if this emoji reaction is empty, clean it up
if (unreact) {
if (this.messageReactions[msgID][emoji].length === 0) {
delete (this.messageReactions[msgID][emoji]);
}
return;
}
this.messageReactions[msgID][emoji].push(who);
},
// Sync the current user state (such as video broadcasting status) to
// the backend, which will reload everybody's Who List.
sendMe() {
if (!this.ws.connected) return;
this.ws.conn.send(JSON.stringify({
action: "me",
video: this.myVideoFlag,
status: this.status,
dnd: this.prefs.closeDMs,
}));
},
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}.`);
this.username = msg.username;
}
// The server can set our webcam NSFW flag.
let myNSFW = this.webcam.nsfw;
let theirNSFW = (msg.video & this.VideoFlag.NSFW) > 0;
if (this.webcam.active && myNSFW != theirNSFW) {
this.webcam.nsfw = theirNSFW;
}
// Note: Me events only come when we join the server or a moderator has
// flagged our video. This is as good of an "on connected" handler as we
// get, so push over our cached blocklist from the website now.
this.bulkMuteUsers();
},
// WhoList updates.
onWho(msg) {
this.whoList = msg.whoList;
this.whoOnline = {};
if (this.whoList == undefined) {
this.whoList = [];
}
// 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) {
this.whoMap[row.username] = row;
this.whoOnline[row.username] = true;
if (this.WebRTC.streams[row.username] != undefined &&
!(row.video & this.VideoFlag.Active)) {
this.closeVideo(row.username, "offerer");
}
}
// Hang up on mutual cameras, if they changed their setting while we
// are already watching them.
this.unMutualVideo();
// 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]?.video & this.VideoFlag.Active)) {
this.sendMe();
}
},
// Server side "block" event: for when the main website sends a BlockNow API request.
onBlock(msg) {
// Close any video connections we had with this user.
this.closeVideo(msg.username);
// Add it to our CachedBlocklist so in case the server reboots, we continue to sync it on reconnect.
for (let existing of this.config.CachedBlocklist) {
if (existing === msg.username) {
return;
}
}
this.config.CachedBlocklist.push(msg.username);
},
// Mute or unmute a user.
muteUser(username) {
username = this.normalizeUsername(username);
let mute = this.muted[username] == undefined;
// If the user is muted because they were blocked on your main website (CachedBlocklist),
// do not allow a temporary unmute in chat: make them live with their choice.
if (this.config.CachedBlocklist.length > 0) {
for (let user of this.config.CachedBlocklist) {
if (user === username) {
this.ChatClient(
`You can not unmute <strong>${username}</strong> because you have blocked them on the main website. ` +
`To unmute them, you will need to unblock them on the website and then reload the chat room.`
);
return;
}
}
}
if (mute) {
if (!window.confirm(
`Do you want to mute ${username}? If muted, you will no longer see their ` +
`chat messages or any DMs they send you going forward. Also, ${username} will ` +
`not be able to see whether your webcam is active until you unmute them.`
)) {
return;
}
this.muted[username] = true;
} else {
if (!window.confirm(
`Do you want to remove your mute on ${username}? If you un-mute them, you ` +
`will be able to see their chat messages or DMs going forward, but most importantly, ` +
`they may be able to watch your webcam now if you are broadcasting!\n\n` +
`Note: currently you can only re-mute them the next time you see one of their ` +
`chat messages, or you can only boot them off your cam after they have already ` +
`opened it. If you are concerned about this, click Cancel and do not remove ` +
`the mute on ${username}.`
)) {
return;
}
delete this.muted[username];
}
// Hang up videos both ways.
this.closeVideo(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[this.normalizeUsername(username)] != undefined;
},
bulkMuteUsers() {
// On page load, if the website sent you a CachedBlocklist, mute all
// of these users in bulk when the server connects.
// this.ChatClient("BulkMuteUsers: sending our blocklist " + this.config.CachedBlocklist);
if (this.config.CachedBlocklist.length === 0) {
return; // nothing to do
}
// Set the client side mute.
let blocklist = this.config.CachedBlocklist;
for (let username of blocklist) {
this.muted[username] = true;
}
// Send the username list to the server.
this.ws.conn.send(JSON.stringify({
action: "blocklist",
usernames: blocklist,
}))
},
// Send a video request to access a user's camera.
sendOpen(username) {
this.ws.conn.send(JSON.stringify({
action: "open",
username: username,
}));
},
sendBoot(username) {
this.ws.conn.send(JSON.stringify({
action: "boot",
username: username,
}));
},
sendUnboot(username) {
this.ws.conn.send(JSON.stringify({
action: "unboot",
username: username,
}));
},
onOpen(msg) {
// Response for the opener to begin WebRTC connection.
this.startWebRTC(msg.username, true);
},
onRing(msg) {
// Request from a viewer to see our broadcast.
this.startWebRTC(msg.username, false);
},
onUserExited(msg) {
// A user has logged off the server. Clean up any WebRTC connections.
this.closeVideo(msg.username);
},
// Handle messages sent in chat.
onMessage(msg) {
// Play sound effects if this is not the active channel or the window is not focused.
if (msg.channel.indexOf("@") === 0) {
if (msg.channel !== this.channel || !this.windowFocused) {
// If we are ignoring unsolicited DMs, don't play the sound effect here.
if (this.prefs.closeDMs && this.channels[msg.channel] == undefined) {
console.log("Unsolicited DM received");
} else {
this.playSound("DM");
}
}
} else if (msg.channel !== this.channel || !this.windowFocused) {
this.playSound("Chat");
}
this.pushHistory({
channel: msg.channel,
username: msg.username,
message: msg.message,
messageID: msg.msgID,
});
},
// A user deletes their message for everybody
onTakeback(msg) {
// Search all channels for this message ID and remove it.
for (let channel of Object.keys(this.channels)) {
for (let i = 0; i < this.channels[channel].history.length; i++) {
let cmp = this.channels[channel].history[i];
if (cmp.msgID === msg.msgID) {
this.channels[channel].history.splice(i, 1);
return;
}
}
}
console.error("Got a takeback for msgID %d but did not find it!", msg.msgID);
},
// User logged in or out.
onPresence(msg) {
// TODO: make a dedicated leave event
let isLeave = false,
isJoin = false;
if (msg.message.indexOf("has exited the room!") > -1) {
// Clean up data about this user.
this.onUserExited(msg);
this.playSound("Leave");
isLeave = true;
} else if (msg.message.indexOf("has joined the room!") > -1) {
this.playSound("Enter");
isJoin = true;
}
// Push it to the history of all public channels (depending on user preference).
if ((isJoin && this.prefs.joinMessages) || (isLeave && this.prefs.exitMessages)
|| (!isJoin && !isLeave)) {
for (let channel of this.config.channels) {
this.pushHistory({
channel: channel.ID,
action: msg.action,
username: msg.username,
message: msg.message,
});
}
}
// Push also to any DM channels for this user (leave events do push to DM thread for visibility).
let channel = "@" + msg.username;
if (this.channels[channel] != undefined) {
this.pushHistory({
channel: channel,
action: msg.action,
username: msg.username,
message: msg.message,
});
}
},
// Dial the WebSocket connection.
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.muted = {};
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) {
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();
});
});
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.disconnect = true;
break;
case "ping":
// New JWT token?
if (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;
},
/**
* WebRTC concerns.
*/
// Start WebRTC with the other username.
startWebRTC(username, isOfferer) {
// this.ChatClient(`Begin WebRTC with ${username}.`);
let pc = new RTCPeerConnection(configuration);
// 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;
// Create a data channel so we have something to connect over even if
// the local user is not broadcasting their own camera.
// TODO: adding a dummy data channel might allow iPad to open single directional video
let dataChannel = pc.createDataChannel("data");
dataChannel.addEventListener("open", event => {
// beginTransmission(dataChannel);
})
// '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,
candidate: JSON.stringify(event.candidate),
}));
}
};
// 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];
// We've received a video! If we had an "open camera spinner timeout",
// clear it before it expires.
if (this.WebRTC.openTimeouts[username] != undefined) {
clearTimeout(this.WebRTC.openTimeouts[username]);
delete (this.WebRTC.openTimeouts[username]);
}
// Do we already have it?
// this.ChatClient(`Received a video stream from ${username}.`);
if (this.WebRTC.streams[username] == undefined ||
this.WebRTC.streams[username].id !== stream.id) {
this.WebRTC.streams[username] = stream;
}
window.requestAnimationFrame(() => {
let $ref = document.getElementById(`videofeed-${username}`);
$ref.srcObject = stream;
});
// Inform them they are being watched.
this.sendWatch(username, true);
// Set a mute video handler to detect freezes.
stream.getVideoTracks().forEach(videoTrack => {
let freezeDetected = () => {
console.log("FREEZE DETECTED:", username);
// Wait some seconds to see if the stream has recovered on its own
setTimeout(() => {
// Flag it as likely frozen.
if (videoTrack.muted) {
this.WebRTC.frozenStreamDetected[username] = true;
}
}, 7500); // 7.5s
};
console.log("Apply onmute handler for", username);
videoTrack.onmute = freezeDetected;
// Double check for frozen streams on an interval.
if (this.WebRTC.frozenStreamInterval[username]) {
// Clear the existing interval (e.g. audio+video track sets up the
// interval twice right now, don't overwrite and lose the interval)
clearInterval(this.WebRTC.frozenStreamInterval[username]);
}
this.WebRTC.frozenStreamInterval[username] = setInterval(() => {
if (videoTrack.muted) freezeDetected();
}, 3000);
})
};
// ANSWERER: add our video to the connection so that the offerer (the one who
// clicked on our video icon to watch us) can receive it.
if (!isOfferer && this.webcam.active) {
let stream = this.webcam.stream;
stream.getTracks().forEach(track => {
pc.addTrack(track, stream)
});
}
// OFFERER: If we were already broadcasting our own video, and the answerer
// has the "auto-open your video" setting enabled, attach our video to the initial
// offer right now.
//
// NOTE: this will force open our video on the answerer's side, and this workflow
// is also the only way that iPads/iPhones/Safari browsers can make a call
// (two-way video is the only option for them; send-only/receive-only channels seem
// not to work in Safari).
if (isOfferer && (this.whoMap[username].video & this.VideoFlag.MutualOpen) && this.webcam.active) {
let stream = this.webcam.stream;
stream.getTracks().forEach(track => {
pc.addTrack(track, stream)
});
}
// If we are the offerer, begin the connection.
if (isOfferer) {
pc.createOffer({
offerToReceiveVideo: true,
offerToReceiveAudio: true,
}).then(this.localDescCreated(pc, username)).catch(this.ChatClient);
}
},
// Common handler function for
localDescCreated(pc, username) {
return (desc) => {
pc.setLocalDescription(desc).then(() => {
this.ws.conn.send(JSON.stringify({
action: "sdp",
username: username,
description: JSON.stringify(pc.localDescription),
}));
}).catch(e => {
console.error(`Error sending WebRTC negotiation message (SDP): ${e}`);
});
};
},
// Handle inbound WebRTC signaling messages proxied by the websocket.
onCandidate(msg) {
if (this.WebRTC.pc[msg.username] == undefined || !this.WebRTC.pc[msg.username].connecting) {
return;
}
let pc = this.WebRTC.pc[msg.username].connecting;
// 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);
// Add the new ICE candidate.
pc.addIceCandidate(candidate).catch(e => {
this.ChatClient(`addIceCandidate: ${e}`);
});
},
onSDP(msg) {
if (this.WebRTC.pc[msg.username] == undefined || !this.WebRTC.pc[msg.username].connecting) {
return;
}
let pc = this.WebRTC.pc[msg.username].connecting;
// 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);
// Add the new ICE candidate.
// this.ChatClient(`Received a Remote Description from ${msg.username}: ${JSON.stringify(msg.description)}.`);
pc.setRemoteDescription(new RTCSessionDescription(message), () => {
// 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);
},
onWatch(msg) {
// The user has our video feed open now.
if (this.isBootedAdmin(msg.username)) return;
// Notify in chat if this was the first watch (viewer may send multiple per each track they received)
if (this.prefs.watchNotif && this.webcam.watching[msg.username] != true) {
this.ChatServer(
`<strong>${msg.username}</strong> is now watching your camera.`,
);
}
this.webcam.watching[msg.username] = true;
this.playSound("Watch");
},
onUnwatch(msg) {
// The user has closed our video feed.
delete (this.webcam.watching[msg.username]);
this.playSound("Unwatch");
},
sendWatch(username, watching) {
// Send the watch or unwatch message to backend.
this.ws.conn.send(JSON.stringify({
action: watching ? "watch" : "unwatch",
username: username,
}));
},
isWatchingMe(username) {
// Return whether the user is watching your camera
return this.webcam.watching[username] === true;
},
/**
* Front-end web app concerns.
*/
// Settings modal.
showSettings() {
this.settingsModal.visible = true;
},
hideSettings() {
this.settingsModal.visible = false;
},
// Set active chat room.
setChannel(channel) {
this.channel = typeof (channel) === "string" ? channel : channel.ID;
this.scrollHistory(this.channel, true);
this.channels[this.channel].unread = 0;
// Responsive CSS: switch back to chat panel upon selecting a channel.
this.openChatPanel();
// Edit hyperlinks to open in a new window.
this.makeLinksExternal();
// Focus the message entry box.
this.messageBox.focus();
},
hasUnread(channel) {
if (this.channels[channel] == undefined) {
return 0;
}
return this.channels[channel].unread;
},
hasAnyUnread() {
// Returns total unread count (for mobile responsive view to show in the left drawer button)
let count = 0;
for (let channel of Object.keys(this.channels)) {
count += this.channels[channel].unread;
}
return count;
},
anyUnreadDMs() {
// Returns true if any unread messages are DM threads
for (let channel of Object.keys(this.channels)) {
if (channel.indexOf("@") === 0 && this.channels[channel].unread > 0) {
return true;
}
}
return false;
},
openDMs(user) {
let channel = "@" + user.username;
this.initHistory(channel);
this.setChannel(channel);
// 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 "";
},
getUser(username) {
// Return the full User object from the Who List, or a dummy placeholder if not online.
if (this.whoMap[username] != undefined) {
return this.whoMap[username];
}
return {
username: username,
};
},
isUserOffline(username) {
// Return if the username is not presently online in the chat.
return this.whoOnline[username] !== true;
},
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;
},
nicknameForUsername(username) {
if (!username) return;
username = username.replace(/^@/, "");
if (this.whoMap[username] != undefined && this.whoMap[username].profileURL) {
let nick = this.whoMap[username].nickname;
if (nick) {
return nick;
}
} else if (this.whoMap[username] == undefined && username !== 'ChatServer' && username !== 'ChatClient') {
// User is not even logged in! Add this note to their name
username += " (offline)";
}
return username;
},
isUsernameDND(username) {
if (!username) return false;
return this.whoMap[username] != undefined && this.whoMap[username].dnd;
},
isUsernameCamNSFW(username) {
// returns true if the username is broadcasting and NSFW, false otherwise.
// (used for the color coding of their nickname on their video box - so assumed they are broadcasting)
if (this.whoMap[username] != undefined && this.whoMap[username].video & this.VideoFlag.NSFW) {
return true;
}
return false;
},
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]);
},
/* Take back messages (for everyone) or remove locally */
takeback(msg) {
if (!window.confirm(
"Do you want to take this message back? Doing so will remove this message from everybody's view in the chat room."
)) return;
this.ws.conn.send(JSON.stringify({
action: "takeback",
msgID: msg.msgID,
}));
},
removeMessage(msg) {
if (!window.confirm(
"Do you want to remove this message from your view? This will delete the message only for you, but others in this chat thread may still see it."
)) return;
this.onTakeback({
msgID: msg.msgID,
});
},
/* message reaction emojis */
hasReactions(msg) {
return this.messageReactions[msg.msgID] != undefined;
},
getReactions(msg) {
if (!this.hasReactions(msg)) return [];
return this.messageReactions[msg.msgID];
},
iReacted(msg, emoji) {
// test whether the current user has reacted
if (this.messageReactions[msg.msgID] != undefined && this.messageReactions[msg.msgID][emoji] != undefined) {
for (let reactor of this.messageReactions[msg.msgID][emoji]) {
if (reactor === this.username) {
return true;
}
}
}
return false;
},
activeChannels() {
// List of current channels, unread indicators etc.
let result = [];
for (let channel of this.config.channels) {
// VIP room we can't see?
if (channel.VIP && !this.isVIP) continue;
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;
},
// Start broadcasting my webcam.
// - force=true to skip the NSFW modal prompt (this param is passed by the button in that modal)
// - changeCamera=true to re-negotiate WebRTC connections with a new camera device (invoked by the Settings modal)
startVideo({ force = false, changeCamera = false }) {
if (this.webcam.busy) return;
// Before they go on cam the first time, ATTEMPT to get their device names.
// - If they had never granted permission, we won't get the names of
// the devices and no big deal.
// - If they had given permission before, we can present a nicer experience
// for them and enumerate their devices before they go on originally.
if (!changeCamera && !force) {
// Initial broadcast: did they select device IDs?
this.getDevices();
}
// If we are running in PermitNSFW mode, show the user the modal.
if (this.config.permitNSFW && !force) {
this.nsfwModalCast.visible = true;
return;
}
let mediaParams = {
audio: true,
video: {
width: { max: 640 },
height: { max: 480 },
},
};
// Name the specific devices chosen by the user.
if (this.webcam.videoDeviceID) {
mediaParams.video.deviceId = { exact: this.webcam.videoDeviceID };
}
if (this.webcam.audioDeviceID) {
mediaParams.audio = {
deviceId: { exact: this.webcam.audioDeviceID },
};
}
this.webcam.busy = true;
navigator.mediaDevices.getUserMedia(mediaParams).then(stream => {
this.webcam.active = true;
this.webcam.elem.srcObject = stream;
this.webcam.stream = stream;
// Save our mutuality prefs.
LocalStorage.set('videoMutual', this.webcam.mutual);
LocalStorage.set('videoMutualOpen', this.webcam.mutualOpen);
LocalStorage.set('videoAutoMute', this.webcam.autoMute);
LocalStorage.set('videoVipOnly', this.webcam.vipOnly);
LocalStorage.set('videoExplicit', this.webcam.nsfw);
// Auto-mute our camera? Two use cases:
// 1. The user marked their cam as muted but then changed video device,
// so we set the mute to match their preference as shown on their UI.
// 2. The user opted to auto-mute their camera from the get to on their
// NSFW broadcast modal popup.
if (this.webcam.muted || this.webcam.autoMute) {
// Mute their audio tracks.
this.webcam.stream.getAudioTracks().forEach(track => {
track.enabled = false;
});
// Set their front-end mute toggle to match (in case of autoMute).
this.webcam.muted = true;
}
// Tell backend the camera is ready.
this.sendMe();
// Record the selected device IDs.
this.webcam.videoDeviceID = stream.getVideoTracks()[0].getSettings().deviceId;
this.webcam.audioDeviceID = stream.getAudioTracks()[0].getSettings().deviceId;
// Collect video and audio devices to let the user change them in their settings.
this.getDevices().then(() => {
// Store their names on localStorage in case we can reselect them by name
// on the user's next visit.
this.storePreferredDeviceNames();
});
// If we have changed devices, reconnect everybody's WebRTC channels for your existing watchers.
if (changeCamera) {
this.updateWebRTCStreams();
}
}).catch(err => {
this.ChatClient(`Webcam error: ${err}`);
}).finally(() => {
this.webcam.busy = false;
})
},
getDevices() {
// Collect video and audio devices.
if (!navigator.mediaDevices?.enumerateDevices) {
console.log("enumerateDevices() not supported.");
return;
}
this.webcam.gettingDevices = true;
return navigator.mediaDevices.enumerateDevices().then(devices => {
this.webcam.videoDevices = [];
this.webcam.audioDevices = [];
devices.forEach(device => {
// If we can't get the device label, disregard it.
// It can happen if the user has not yet granted permission.
if (!device.label) {
return;
};
if (device.kind === 'videoinput') {
// console.log(`Video device ${device.deviceId} ${device.label}`);
this.webcam.videoDevices.push({
id: device.deviceId,
label: device.label,
});
} else if (device.kind === 'audioinput') {
// console.log(`Audio device ${device.deviceId} ${device.label}`);
this.webcam.audioDevices.push({
id: device.deviceId,
label: device.label,
});
}
});
// If we don't have the user's current active device IDs (e.g., they have
// not yet started their video the first time), see if we can pre-select
// by their preferred device names.
if (!this.webcam.videoDeviceID && this.webcam.preferredDeviceNames.video) {
for (let dev of this.webcam.videoDevices) {
if (dev.label === this.webcam.preferredDeviceNames.video) {
this.webcam.videoDeviceID = dev.id;
}
}
}
if (!this.webcam.audioDeviceID && this.webcam.preferredDeviceNames.audio) {
for (let dev of this.webcam.audioDevices) {
if (dev.label === this.webcam.preferredDeviceNames.audio) {
this.webcam.audioDeviceID = dev.id;
}
}
}
}).catch(err => {
this.ChatClient(`Error listing your cameras and microphones: ${err.name}: ${err.message}`);
}).finally(() => {
// In the settings modal, let the spinner spin for a moment.
setTimeout(() => {
this.webcam.gettingDevices = false;
}, 500);
})
},
storePreferredDeviceNames() {
// This function looks up the names of the user's selected video/audio device.
// When they come back later, IF we are able to enumerate devices before they
// go on for the first time, we might pre-select their last device by name.
// NOTE: on iOS apparently device IDs shuffle every single time so only names
// may be reliable!
if (this.webcam.videoDeviceID) {
for (let dev of this.webcam.videoDevices) {
if (dev.id === this.webcam.videoDeviceID && dev.label) {
this.webcam.preferredDeviceNames.video = dev.label;
}
}
}
if (this.webcam.audioDeviceID) {
for (let dev of this.webcam.audioDevices) {
if (dev.id === this.webcam.audioDeviceID && dev.label) {
this.webcam.preferredDeviceNames.audio = dev.label;
}
}
}
// Put them on localStorage.
LocalStorage.set('preferredDeviceNames', this.webcam.preferredDeviceNames);
},
// Replace your video/audio streams for your watchers (on camera changes)
updateWebRTCStreams() {
console.log("Re-negotiating video and audio channels to your watchers.");
for (let username of Object.keys(this.WebRTC.pc)) {
let pc = this.WebRTC.pc[username];
if (pc.answerer != undefined) {
let oldTracks = pc.answerer.getSenders();
let newTracks = this.webcam.stream.getTracks();
// Remove and replace the tracks.
for (let old of oldTracks) {
if (old.track.kind === 'audio') {
for (let replace of newTracks) {
if (replace.kind === 'audio') {
old.replaceTrack(replace);
}
}
}
else if (old.track.kind === 'video') {
for (let replace of newTracks) {
if (replace.kind === 'video') {
old.replaceTrack(replace);
}
}
}
}
}
}
},
// Begin connecting to someone else's webcam.
openVideoByUsername(username, force) {
if (this.whoMap[username] != undefined) {
this.openVideo(this.whoMap[username], force);
return;
}
this.ChatClient("Couldn't open video by username: not found.");
},
setSkipNSFWModal() {
// Set the "don't show again" on NSFW modal.
LocalStorage.set("skip-nsfw-modal", true);
},
openVideo(user, force) {
// Close the NSFW warning modal if it was open.
this.nsfwModalView.visible = false;
if (user.username === this.username) {
this.ChatClient("You can already see your own webcam.");
return;
}
// If we have muted the target, we shouldn't view their video.
if (this.isMutedUser(user.username)) {
this.ChatClient(`You have muted <strong>${user.username}</strong> and so should not see their camera.`);
return;
}
// Is the target user NSFW? Go thru the modal.
let dontShowAgain = LocalStorage.get("skip-nsfw-modal") === true;
if ((user.video & this.VideoFlag.NSFW) && !dontShowAgain && !force) {
this.nsfwModalView.user = user;
this.nsfwModalView.visible = true;
return;
}
// Debounce so we don't spam too much for the same user.
if (this.WebRTC.debounceOpens[user.username]) return;
this.WebRTC.debounceOpens[user.username] = true;
setTimeout(() => {
delete (this.WebRTC.debounceOpens[user.username]);
}, 5000);
// Camera is already open? Then disconnect the connection.
if (this.WebRTC.pc[user.username] != undefined && this.WebRTC.pc[user.username].offerer != undefined) {
this.closeVideo(user.username, "offerer");
}
// If this user requests mutual viewership...
if (this.isVideoNotAllowed(user)) {
this.ChatClient(
`<strong>${user.username}</strong> has requested that you should share your own camera too before opening theirs.`
);
return;
}
// Set a timeout: the video icon becomes a spinner and we wait a while
// to see if the connection went thru. This gives the user feedback and we
// can avoid a spammy 'ChatClient' notification message.
if (this.WebRTC.openTimeouts[user.username] != undefined) {
clearTimeout(this.WebRTC.openTimeouts[user.username]);
delete (this.WebRTC.openTimeouts[user.username]);
}
this.WebRTC.openTimeouts[user.username] = setTimeout(() => {
// It timed out. If they are on an iPad, offer additional hints on
// how to have better luck connecting their cameras.
if (isAppleWebkit()) {
this.ChatClient(
`There was an error opening <strong>${user.username}</strong>'s camera.<br><br>` +
"<strong>Advice:</strong> You appear to be on an iPad-style browser. Webcam sharing " +
"may be limited and only work if:<br>A) You are sharing your own camera first, and<br>B) " +
"The person you view has the setting to auto-open your camera in return.<br>Best of luck!",
);
} else {
this.ChatClient(
`There was an error opening <strong>${user.username}</strong>'s camera.`,
);
}
delete (this.WebRTC.openTimeouts[user.username]);
}, 10000);
// Send the ChatServer 'open' command.
this.sendOpen(user.username);
// Responsive CSS -> go to chat panel to see the camera
this.openChatPanel();
},
closeVideo(username, name) {
// Clean up any lingering camera freeze states.
delete (this.WebRTC.frozenStreamDetected[username]);
if (this.WebRTC.frozenStreamInterval[username]) {
clearInterval(this.WebRTC.frozenStreamInterval[username]);
delete (this.WebRTC.frozenStreamInterval[username]);
}
if (name === "offerer") {
// We are closing another user's video stream.
delete (this.WebRTC.streams[username]);
delete (this.WebRTC.muted[username]);
delete (this.WebRTC.poppedOut[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;
}
// A user has logged off the server. Clean up any WebRTC connections.
delete (this.WebRTC.streams[username]);
delete (this.webcam.watching[username]);
if (this.WebRTC.pc[username] != undefined) {
if (this.WebRTC.pc[username].offerer) {
this.WebRTC.pc[username].offerer.close();
}
if (this.WebRTC.pc[username].answerer) {
this.WebRTC.pc[username].answerer.close();
}
delete (this.WebRTC.pc[username]);
delete (this.WebRTC.muted[username]);
delete (this.WebRTC.poppedOut[username]);
}
// Clean up any lingering camera freeze states.
delete (this.WebRTC.frozenStreamDetected[username]);
if (this.WebRTC.frozenStreamInterval[username]) {
clearInterval(this.WebRTC.frozenStreamInterval[username]);
delete (this.WebRTC.frozenStreamInterval[username]);
}
// Inform backend we have closed it.
this.sendWatch(username, false);
},
closeOpenVideos() {
// Close all videos open of other users.
for (let username of Object.keys(this.WebRTC.streams)) {
this.closeVideo(username, "offerer");
}
},
muteAllVideos() {
// Mute the mic of all open videos.
let count = 0;
for (let username of Object.keys(this.WebRTC.streams)) {
if (this.WebRTC.muted[username]) continue;
// Find the <video> tag to mute it.
this.WebRTC.muted[username] = true;
let $ref = document.getElementById(`videofeed-${username}`);
if ($ref) {
$ref.muted = this.WebRTC.muted[username];
}
count++;
}
if (count > 0) {
this.ChatClient(`You have muted the audio on ${count} video${count === 1 ? '' : 's'}.`);
}
},
unMutualVideo() {
// If we had our camera on to watch a video of someone who wants mutual cameras,
// and then we turn ours off: we should unfollow the ones with mutual video.
if (this.webcam.active) return;
for (let row of this.whoList) {
let username = row.username;
if ((row.video & this.VideoFlag.MutualRequired) && this.WebRTC.pc[username] != undefined) {
this.closeVideo(username);
}
}
},
webcamIconClass(user) {
// Return the icon to show on a video button.
// - Usually a video icon
// - May be a crossed-out video if isVideoNotAllowed
// - Or an eyeball for cameras already opened
// - Or a spinner if we are actively trying to open the video
// Current user sees their own self camera always.
if (user.username === this.username && this.webcam.active) {
return 'fa-eye';
}
// In spinner mode? (Trying to open the video)
if (this.WebRTC.openTimeouts[user.username] != undefined) {
return 'fa-spinner fa-spin';
}
// Already opened?
if (this.WebRTC.pc[user.username] != undefined && this.WebRTC.streams[user.username] != undefined) {
return 'fa-eye';
}
// iPad test: they will have very limited luck opening videos unless
// A) the iPad camera is already on, and
// B) the person they want to watch has mutual auto-open enabled.
if (isAppleWebkit()) {
if (!this.webcam.active) {
return 'fa-video-slash'; // can not open any cam w/o local video on
}
if (!(this.whoMap[user.username].video & this.VideoFlag.MutualOpen)) {
// the user must have mutual auto-open on: the iPad has to offer
// their video which will force open their cam on the other side,
// and this is only if the user expects it.
return 'fa-video-slash';
}
}
if (this.isVideoNotAllowed(user)) return 'fa-video-slash';
return 'fa-video';
},
isVideoNotAllowed(user) {
// Returns whether the video button to open a user's cam will be not allowed (crossed out)
// Mutual video sharing is required on this camera, and ours is not active
if ((user.video & this.VideoFlag.Active) && (user.video & this.VideoFlag.MutualRequired)) {
// A nuance to the mutual video required: if we DO have our cam on, but ours is VIP only, and the
// user we want to watch can't see that our cam is on, then honor their wishes.
if (this.webcam.active && this.isVIP && this.webcam.vipOnly && this.whoMap[user.username] != undefined && !this.whoMap[user.username].vip) {
// Our cam is active, but the non-VIP user won't see it on, so they won't expect
// us to be able to open their camera.
return true;
}
if (!this.webcam.active) {
// Our cam is not broadcasting, but they requested it should be: not allowed.
return true;
}
}
// We have muted them and it wouldn't be appropriate to still watch their video but not get their messages.
if (this.isMutedUser(user.username)) {
return true;
}
return 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(", "));
}
// 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 your video.
bootUser(username) {
// Un-boot?
if (this.isBooted(username)) {
if (!window.confirm(`Allow ${username} to watch your webcam again?`)) {
return;
}
this.sendUnboot(username);
delete(this.WebRTC.booted[username]);
return;
}
// Boot them off our webcam.
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);
this.WebRTC.booted[username] = true;
// Close the WebRTC peer connection.
if (this.WebRTC.pc[username] != undefined) {
this.closeVideo(username, "answerer");
}
// Remove them from our list.
delete(this.webcam.watching[username]);
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.`
);
},
isBooted(username) {
return this.WebRTC.booted[username] === true;
},
isBootedAdmin(username) {
return (this.WebRTC.booted[username] === true || this.muted[username] === true) &&
this.whoMap[username] != undefined &&
this.whoMap[username].op;
},
// Stop broadcasting.
stopVideo() {
// Close all WebRTC sessions.
for (let username of Object.keys(this.WebRTC.pc)) {
this.closeVideo(username, "answerer");
}
// Hang up on mutual cameras.
this.unMutualVideo();
// Close the local camera devices completely.
this.webcam.stream.getTracks().forEach(track => {
track.stop();
});
// Reset all front-end state.
this.webcam.elem.srcObject = null;
this.webcam.stream = null;
this.webcam.active = false;
this.webcam.muted = false;
this.whoTab = "online";
// Tell backend our camera state.
this.sendMe();
},
// Mute my microphone if broadcasting.
muteMe() {
this.webcam.muted = !this.webcam.muted;
this.webcam.stream.getAudioTracks().forEach(track => {
track.enabled = !this.webcam.muted;
});
// Communicate our local mute to others.
this.sendMe();
},
isSourceMuted(username) {
// See if the webcam broadcaster muted their mic at the source
if (this.whoMap[username] != undefined && this.whoMap[username].video & this.VideoFlag.Muted) {
return true;
}
return false;
},
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];
}
},
setVideoVolume(username, volume) {
// Set the volume on their video.
let $ref = document.getElementById(`videofeed-${username}`);
if ($ref) {
$ref.volume = volume / 100;
}
},
// Pop out a user's video.
popoutVideo(username) {
this.WebRTC.poppedOut[username] = !this.WebRTC.poppedOut[username];
// If not popped out, reset CSS positioning.
window.requestAnimationFrame(this.makeDraggableVideos);
},
// Outside of Vue, attach draggable video scripts to DOM.
makeDraggableVideos() {
let $panel = document.querySelector("#video-feeds");
interact('.popped-in').unset();
// Give popped out videos to the root of the DOM so they can
// be dragged anywhere on the page.
window.requestAnimationFrame(() => {
document.querySelectorAll('.popped-out').forEach(node => {
// $panel.removeChild(node);
document.body.appendChild(node);
});
document.querySelectorAll('.popped-in').forEach(node => {
// document.body.removeChild(node);
$panel.appendChild(node);
node.style.top = null;
node.style.left = null;
node.setAttribute('data-x', 0);
node.setAttribute('data-y', 0);
});
});
interact('.popped-out').draggable({
// enable inertial throwing
inertia: true,
// keep the element within the area of it's parent
modifiers: [
interact.modifiers.restrictRect({
restriction: 'parent',
endOnly: true
})
],
listeners: {
// call this function on every dragmove event
move(event) {
let target = event.target;
let x = (parseFloat(target.getAttribute('data-x')) || 0) + event.dx
let y = (parseFloat(target.getAttribute('data-y')) || 0) + event.dy
target.style.top = `${y}px`;
target.style.left = `${x}px`;
target.setAttribute('data-x', x);
target.setAttribute('data-y', y);
},
// call this function on every dragend event
end(event) {
console.log(
'moved a distance of ' +
(Math.sqrt(Math.pow(event.pageX - event.x0, 2) +
Math.pow(event.pageY - event.y0, 2) | 0))
.toFixed(2) + 'px')
}
}
}).resizable({
edges: { left: true, right: true, bottom: true, right: true },
listeners: {
move(event) {
var target = event.target
var x = (parseFloat(target.getAttribute('data-x')) || 0)
var y = (parseFloat(target.getAttribute('data-y')) || 0)
// update the element's style
target.style.width = event.rect.width + 'px'
target.style.height = event.rect.height + 'px'
// translate when resizing from top or left edges
x += event.deltaRect.left
y += event.deltaRect.top
target.style.top = `${y}px`;
target.style.left = `${x}px`;
target.setAttribute('data-x', x)
target.setAttribute('data-y', y)
}
},
modifiers: [
// keep the edges inside the parent
interact.modifiers.restrictEdges({
outer: 'parent'
}),
// minimum size
interact.modifiers.restrictSize({
min: { width: 100, height: 50 }
})
],
inertia: true
})
},
initHistory(channel) {
if (this.channels[channel] == undefined) {
this.channels[channel] = {
history: [],
updated: Date.now(),
unread: 0,
};
}
},
pushHistory({ channel, username, message, action = "message", isChatServer, isChatClient, messageID }) {
// Default channel = your current channel.
if (!channel) {
channel = this.channel;
}
// Are we ignoring DMs?
if (this.prefs.closeDMs && channel.indexOf('@') === 0) {
// Don't allow an (incoming) DM to initialize a new chat room for us.
// Unless the user is an operator.
let isSenderOp = this.whoMap[username] != undefined && this.whoMap[username].op;
if (username !== this.username && this.channels[channel] == undefined && !isSenderOp) return;
}
// Initialize this channel's history?
this.initHistory(channel);
// Image handling per the user's preference.
if (message.indexOf("<img") > -1) {
if (this.imageDisplaySetting === "hide") {
return;
} else if (this.imageDisplaySetting === "collapse") {
// Put a collapser link.
let collapseID = `collapse-${messageID}`;
message = `
<a href="#" id="img-show-${collapseID}"
class="button is-outlined is-small is-info"
onclick="document.querySelector('#img-${collapseID}').style.display = 'block';
document.querySelector('#img-show-${collapseID}').style.display = 'none';
return false">
<i class="fa fa-image mr-1"></i>
Image attachment - click to expand
</a>
<div id="img-${collapseID}" style="display: none">${message}</div>`;
}
}
// Were we at mentioned in this message?
if (message.indexOf("@"+this.username) > -1) {
let re = new RegExp("@"+this.username+"\\b", "ig");
message = message.replace(re, `<strong class="has-background-at-mention">@${this.username}</strong>`);
}
// And same for @here or @all
message = message.replace(/@(here|all)\b/ig, `<strong class="has-background-at-mention">@$1</strong>`);
// Append the message.
this.channels[channel].updated = new Date().getTime();
this.channels[channel].history.push({
action: action,
channel: channel,
username: username,
message: message,
msgID: messageID,
at: new Date(),
isChatServer,
isChatClient,
});
// Trim the history per the scrollback buffer.
if (this.scrollback > 0 && this.channels[channel].history.length > this.scrollback) {
this.channels[channel].history = this.channels[channel].history.slice(
-this.scrollback,
this.channels[channel].history.length + 1,
);
}
this.scrollHistory(channel);
// Mark unread notifiers if this is not our channel.
if (this.channel !== channel) {
// Don't notify about presence broadcasts.
if (action !== "presence" && !isChatServer) {
this.channels[channel].unread++;
}
}
// Edit hyperlinks to open in a new window.
this.makeLinksExternal();
},
scrollHistory(channel, force) {
if (!this.autoscroll && !force) return;
window.requestAnimationFrame(() => {
// Only scroll if it's the current channel.
if (channel !== this.channel) return;
if (this.historyScrollbox === null) return;
this.historyScrollbox.scroll({
top: this.historyScrollbox.scrollHeight,
behavior: 'smooth',
});
});
},
// 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");
},
// 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,
});
},
// Format a datetime nicely for chat timestamp.
prettyDate(date) {
if (date == undefined) return '';
let hours = date.getHours(),
minutes = String(date.getMinutes()).padStart(2, '0'),
ampm = hours >= 11 ? "pm" : "am";
let hour = hours % 12 || 12;
return `${(hour)}:${minutes} ${ampm}`;
},
// CSS classes for the profile button (color coded genders)
profileButtonClass(user) {
// VIP background.
let result = "";
if (user.vip) {
result = "has-background-vip ";
}
let gender = (user.gender || "").toLowerCase();
if (gender.indexOf("m") === 0) {
return result + "has-text-gender-male";
} else if (gender.indexOf("f") === 0) {
return result + "has-text-gender-female";
} else if (gender.length > 0) {
return result + "has-text-gender-other";
}
return "";
},
/**
* 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();
},
// Invoke the Profile Modal
showProfileModal(username) {
if (this.whoMap[username] != undefined) {
this.profileModal.user = this.whoMap[username];
this.profileModal.username = username;
this.profileModal.visible = true;
}
},
/**
* Sound effect concerns.
*/
setupSounds() {
// Note: setupSounds had to be called on a page gesture (mouse click) before browsers
// allow it to set up the AudioContext. If we've successfully set one up before, exit
// this function immediately.
if (this.config.sounds.audioContext) {
if (this.config.sounds.audioContext.state === 'suspended') {
this.config.sounds.audioContext.resume();
}
return;
}
try {
if (AudioContext) {
this.config.sounds.audioContext = new AudioContext();
} else {
this.config.sounds.audioContext = window.AudioContext || window.webkitAudioContext;
}
} catch { }
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) {
// Muting all SFX?
if (this.prefs.muteSounds) return;
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];
},
// Make all links in chat open in new windows
makeLinksExternal() {
window.requestAnimationFrame(() => {
let $history = document.querySelector("#chatHistory");
if ($history === null) return;
// 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";
});
})
},
/*
* Idle Detection methods
*/
setupIdleDetection() {
window.addEventListener("keypress", this.deidle);
window.addEventListener("mousemove", this.deidle);
},
// Common "de-idle" event handler
deidle(e) {
if (this.status === "idle") {
this.status = "online";
}
if (this.idleTimeout !== null) {
clearTimeout(this.idleTimeout);
}
this.idleTimeout = setTimeout(this.goIdle, 1000 * this.idleThreshold);
},
goIdle() {
// only if we aren't already set on away
if (this.status === "online") {
this.status = "idle";
}
},
/*
* Webhook methods
*/
isWebhookEnabled(name) {
for (let webhook of this.config.webhookURLs) {
if (webhook.Name === name && webhook.Enabled) {
return true;
}
}
return false;
},
reportMessage(message) {
// User is reporting a message on chat.
if (message.reported) {
if (!window.confirm("You have already reported this message. Do you want to report it again?")) return;
}
// Clone the user object.
let user = {
avatar: this.avatarForUsername(message.username),
nickname: this.nicknameForUsername(message.username),
}
// Clone the message.
let clone = Object.assign({}, message);
// Sub out attached images.
clone.message = clone.message.replace(/<img .+?>/g, "[inline image]");
this.reportModal.message = clone;
this.reportModal.user = user;
this.reportModal.origMessage = message;
this.reportModal.visible = true;
},
doReport({ classification, comment }) {
// Submit the queued up report.
if (this.reportModal.busy) return;
this.reportModal.busy = true;
let msg = this.reportModal.message;
this.ws.conn.send(JSON.stringify({
action: "report",
channel: msg.channel,
username: msg.username,
timestamp: "" + msg.at,
reason: classification,
message: msg.message,
comment: comment,
}));
this.reportModal.busy = false;
this.reportModal.visible = false;
// Set the "reported" flag.
this.reportModal.origMessage.reported = true;
},
}
};
</script>
<template>
<!-- Sign In modal -->
<LoginModal :visible="loginModal.visible" @sign-in="signIn"></LoginModal>
<!-- Settings modal -->
<div class="modal" :class="{ 'is-active': settingsModal.visible }">
<div class="modal-background"></div>
<div class="modal-content">
<div class="card">
<header class="card-header has-background-info">
<p class="card-header-title has-text-light">Chat Settings</p>
</header>
<div class="card-content">
<!-- Tab bar for the settings -->
<div class="tabs">
<ul>
<li :class="{ 'is-active': settingsModal.tab === 'prefs' }">
<a href="#" @click.prevent="settingsModal.tab = 'prefs'">
Display
</a>
</li>
<li :class="{ 'is-active': settingsModal.tab === 'sounds' }">
<a href="#" @click.prevent="settingsModal.tab = 'sounds'">
Sounds
</a>
</li>
<li :class="{ 'is-active': settingsModal.tab === 'webcam' }">
<a href="#" @click.prevent="settingsModal.tab = 'webcam'">
Camera
</a>
</li>
<li :class="{ 'is-active': settingsModal.tab === 'misc' }">
<a href="#" @click.prevent="settingsModal.tab = 'misc'">
Misc
</a>
</li>
</ul>
</div>
<!-- Display preferences -->
<div v-if="settingsModal.tab === 'prefs'">
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Video size</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<div class="select is-fullwidth">
<select v-model="webcam.videoScale">
<option v-for="s in webcam.videoScaleOptions" v-bind:key="s[0]"
:value="s[0]">
{{ s[1] }}
</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Text size</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<div class="select is-fullwidth">
<select v-model="fontSizeClass">
<option v-for="s in config.fontSizeClasses" v-bind:key="s[0]" :value="s[0]">
{{ s[1] }}
</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Text style</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<div class="select is-fullwidth">
<select v-model="messageStyle">
<option v-for="s in config.messageStyleSettings" v-bind:key="s[0]" :value="s[0]">
{{ s[1] }}
</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Images</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<div class="select is-fullwidth">
<select v-model="imageDisplaySetting">
<option v-for="s in config.imageDisplaySettings" v-bind:key="s[0]"
:value="s[0]">
{{ s[1] }}
</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div class="field">
<label class="label">Scrollback buffer</label>
<div class="control">
<input type="number" class="input" v-model="scrollback" min="0" inputmode="numeric">
</div>
<p class="help">
How many chat history messages to keep at once (per channel/DM thread).
Older messages will be removed so your web browser doesn't run low on memory.
A value of zero (0) will mean "unlimited" and the chat history is never trimmed.
</p>
</div>
</div>
<!-- Sound settings -->
<div v-else-if="settingsModal.tab === 'sounds'">
<div class="mb-4">
<label class="checkbox">
<input type="checkbox" v-model="prefs.muteSounds" :value="true">
Mute all sound effects
</label>
</div>
<div class="columns is-mobile">
<div class="column is-2 pr-1">
<label class="label is-size-7">DM chat</label>
</div>
<div class="column">
<div class="select is-fullwidth">
<select v-model="config.sounds.settings.DM" @change="setSoundPref('DM')">
<option v-for="s in config.sounds.available" v-bind:key="s.name" :value="s.name">
{{ s.name }}
</option>
</select>
</div>
</div>
<div class="column is-2 pr-1">
<label class="label is-size-7">Public chat</label>
</div>
<div class="column">
<div class="select is-fullwidth">
<select v-model="config.sounds.settings.Chat" @change="setSoundPref('Chat')">
<option v-for="s in config.sounds.available" v-bind:key="s.name" :value="s.name">
{{ s.name }}
</option>
</select>
</div>
</div>
</div>
<div class="columns is-mobile">
<div class="column is-2 pr-1">
<label class="label is-size-7">Room enter</label>
</div>
<div class="column">
<div class="select is-fullwidth">
<select v-model="config.sounds.settings.Enter" @change="setSoundPref('Enter')">
<option v-for="s in config.sounds.available" v-bind:key="s.name" :value="s.name">
{{ s.name }}
</option>
</select>
</div>
</div>
<div class="column is-2 pr-1">
<label class="label is-size-7">Room leave</label>
</div>
<div class="column">
<div class="select is-fullwidth">
<select v-model="config.sounds.settings.Leave" @change="setSoundPref('Leave')">
<option v-for="s in config.sounds.available" v-bind:key="s.name" :value="s.name">
{{ s.name }}
</option>
</select>
</div>
</div>
</div>
<div class="columns is-mobile">
<div class="column is-2 pr-1">
<label class="label is-size-7">Watched</label>
</div>
<div class="column">
<div class="select is-fullwidth">
<select v-model="config.sounds.settings.Watch" @change="setSoundPref('Watch')">
<option v-for="s in config.sounds.available" v-bind:key="s.name" :value="s.name">
{{ s.name }}
</option>
</select>
</div>
</div>
<div class="column is-2 pr-1">
<label class="label is-size-7">Unwatched</label>
</div>
<div class="column">
<div class="select is-fullwidth">
<select v-model="config.sounds.settings.Unwatch" @change="setSoundPref('Unwatch')">
<option v-for="s in config.sounds.available" v-bind:key="s.name" :value="s.name">
{{ s.name }}
</option>
</select>
</div>
</div>
</div>
</div>
<!-- Webcam preferences -->
<div v-if="settingsModal.tab === 'webcam'">
<h3 class="subtitle mb-2">
Camera Settings
</h3>
<p class="block mb-1 is-size-7">
The settings on this tab will be relevant only when you are already
broadcasting your camera. They allow you to modify your broadcast settings
while you are already live (for example, to change your mutual camera
preference or select another audio/video device to broadcast from).
</p>
<p class="block mb-1" v-if="config.permitNSFW">
<label class="label">Explicit</label>
</p>
<div class="field" v-if="config.permitNSFW">
<label class="checkbox" :class="{ 'cursor-notallowed': !webcam.active }">
<input type="checkbox" v-model="webcam.nsfw" :disabled="!webcam.active">
Mark my camera as featuring explicit content
</label>
</div>
<p class="block mb-1">
<label class="label">Mutual webcam options</label>
</p>
<div class="field mb-1">
<label class="checkbox" :class="{ 'cursor-notallowed': !webcam.active }">
<input type="checkbox" v-model="webcam.mutual" :disabled="!webcam.active">
People must be sharing their own camera before they can open mine
</label>
</div>
<div class="field mb-1">
<label class="checkbox" :class="{ 'cursor-notallowed': !webcam.active }">
<input type="checkbox" v-model="webcam.mutualOpen" :disabled="!webcam.active">
When someone opens my camera, I also open their camera automatically
</label>
</div>
<div class="field" v-if="isVIP">
<label class="checkbox" :class="{ 'cursor-notallowed': !webcam.active }">
<input type="checkbox" v-model="webcam.vipOnly" :disabled="!webcam.active">
Only <span v-html="config.VIP.Branding"></span> <sup class="is-size-7"
:class="config.VIP.Icon"></sup>
members can see that my camera is broadcasting
</label>
</div>
<h3 class="subtitle mb-2" v-if="webcam.videoDevices.length > 0 || webcam.audioDevices.length > 0">
Webcam Devices
<button type="button" class="button is-primary is-small is-outlined ml-2" @click="getDevices()"
title="Refresh list of devices">
<i class="fa fa-arrows-rotate" :class="{ 'fa-spin': webcam.gettingDevices }">
</i>
</button>
</h3>
<div class="columns is-mobile"
v-if="webcam.videoDevices.length > 0 || webcam.audioDevices.length > 0">
<div class="column">
<label class="label">Video source</label>
<div class="select is-fullwidth">
<select v-model="webcam.videoDeviceID"
@change="startVideo({ changeCamera: true, force: true })">
<option v-for="(d, i) in webcam.videoDevices" :value="d.id"
v-bind:key="i">
{{ d.label || `Camera ${i}` }}
</option>
</select>
</div>
</div>
<div class="column">
<label class="label">Audio source</label>
<div class="select is-fullwidth">
<select v-model="webcam.audioDeviceID"
@change="startVideo({ changeCamera: true, force: true })">
<option v-for="(d, i) in webcam.audioDevices" :value="d.id"
v-bind:key="i">
{{ d.label || `Microphone ${i}` }}
</option>
</select>
</div>
</div>
</div>
</div>
<!-- Misc preferences -->
<div v-if="settingsModal.tab === 'misc'">
<div class="field">
<label class="label">Presence messages <small>('has joined the room')</small> in public
channels</label>
<div class="columns is-mobile mb-0">
<div class="column py-1">
<label class="checkbox" title="Show 'has joined the room' messages in public channels">
<input type="checkbox" v-model="prefs.joinMessages" :value="true">
Join room
</label>
</div>
<div class="column py-1">
<label class="checkbox" title="Show 'has exited the room' messages in public channels">
<input type="checkbox" v-model="prefs.exitMessages" :value="true">
Exit room
</label>
</div>
</div>
</div>
<div class="field">
<label class="label mb-0">Server notification messages</label>
<label class="checkbox" title="Show 'has joined the room' messages in public channels">
<input type="checkbox" v-model="prefs.watchNotif" :value="true">
Notify when somebody opens my camera
</label>
</div>
<div class="field">
<label class="label mb-0">Direct Messages</label>
<label class="checkbox">
<input type="checkbox" v-model="prefs.closeDMs" :value="true">
Ignore unsolicited DMs from others
</label>
<p class="help">
If you check this box, other chatters may not initiate DMs with you: their messages
will be (silently) ignored. You may still initiate DM chats with others, unless they
also have closed their DMs with this setting.
</p>
</div>
</div>
</div>
<footer class="card-footer">
<div class="card-footer-item">
<button type="button" class="button is-primary" @click="hideSettings()">
Close
</button>
</div>
</footer>
</div>
</div>
</div>
<!-- NSFW Modal: before user activates their webcam -->
<div class="modal" :class="{ 'is-active': nsfwModalCast.visible }">
<div class="modal-background"></div>
<div class="modal-content">
<div class="card">
<header class="card-header has-background-info">
<p class="card-header-title has-text-light">Broadcast my webcam</p>
</header>
<div class="card-content">
<p class="block mb-1">
You can turn on your webcam and enable others in the room to connect to yours.
The controls to <i class="fa fa-stop has-text-danger"></i> stop and <i
class="fa fa-microphone-slash has-text-danger"></i> mute audio
will be at the top of the page.
</p>
<div class="field">
<label class="checkbox">
<input type="checkbox" v-model="webcam.autoMute">
Start with my microphone on mute by default
</label>
</div>
<p class="block mb-1">
If your camera will be featuring "<abbr title="Not Safe For Work">Explicit</abbr>" or sexual
content, please
mark it as such by clicking on the "<small><i class="fa fa-fire mr-1 has-text-danger"></i>
Explicit</small>"
button at the top of the page, or check the box below to start with it enabled.
</p>
<div class="field">
<label class="checkbox">
<input type="checkbox" v-model="webcam.nsfw">
Check this box if your webcam will <em>definitely</em> be Explicit. 😈
</label>
</div>
<p class="block mb-1">
<label class="label">Mutual webcam options:</label>
</p>
<div class="field mb-1">
<label class="checkbox">
<input type="checkbox" v-model="webcam.mutual">
People must be sharing their own camera before they can open mine
</label>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" v-model="webcam.mutualOpen">
When someone opens my camera, I also open their camera automatically
</label>
</div>
<div class="field" v-if="isVIP">
<label class="checkbox" :class="{ 'cursor-notallowed': !webcam.active }">
<input type="checkbox" v-model="webcam.vipOnly">
Only <span v-html="config.VIP.Branding"></span> <sup class="is-size-7"
:class="config.VIP.Icon"></sup>
members can see that my camera is broadcasting
</label>
</div>
<!-- Device Pickers: just in case the user had granted video permission in the past,
and we are able to enumerate their device names, we can show them here before they
go on this time.-->
<div class="columns is-mobile" v-if="webcam.videoDevices.length > 0 || webcam.audioDevices.length > 0">
<div class="column">
<label class="label">Video source</label>
<div class="select is-fullwidth">
<select v-model="webcam.videoDeviceID">
<option :value="null" disabled selected>Select default camera</option>
<option v-for="(d, i) in webcam.videoDevices" :value="d.id">
{{ d.label || `Camera ${i}` }}
</option>
</select>
</div>
</div>
<div class="column">
<label class="label">Audio source</label>
<div class="select is-fullwidth">
<select v-model="webcam.audioDeviceID">
<option :value="null" disabled selected>Select default microphone</option>
<option v-for="(d, i) in webcam.audioDevices" :value="d.id">
{{ d.label || `Microphone ${i}` }}
</option>
</select>
</div>
</div>
</div>
<div class="field">
<div class="control has-text-centered">
<button type="button" class="button is-link mr-4"
@click="startVideo({ force: true }); nsfwModalCast.visible = false">Start webcam</button>
<button type="button" class="button" @click="nsfwModalCast.visible = false">Cancel</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- NSFW Modal: before user views a NSFW camera the first time -->
<ExplicitOpenModal :visible="nsfwModalView.visible"
:user="nsfwModalView.user"
@accept="openVideo(nsfwModalView.user, true)"
@cancel="nsfwModalView.visible=false"
@dont-show-again="setSkipNSFWModal()"></ExplicitOpenModal>
<!-- Report Modal -->
<ReportModal :visible="reportModal.visible"
:busy="reportModal.busy"
:user="reportModal.user"
:message="reportModal.message"
@accept="doReport"
@cancel="reportModal.visible=false"></ReportModal>
<!-- Profile Modal (profile cards popup) -->
<ProfileModal :visible="profileModal.visible"
:user="profileModal.user"
:username="username"
:jwt="jwt.token"
:website-url="config.website"
:is-dnd="isUsernameDND(profileModal.username)"
:is-muted="isMutedUser(profileModal.username)"
:is-booted="isBooted(profileModal.username)"
:profile-webhook-enabled="isWebhookEnabled('profile')"
:vip-config="config.VIP"
@send-dm="openDMs"
@mute-user="muteUser"
@boot-user="bootUser"
@cancel="profileModal.visible=false"></ProfileModal>
<div class="chat-container">
<!-- Top header panel -->
<header class="chat-header">
<div class="columns is-mobile">
<div class="column is-narrow pr-1">
<strong class="is-6"><span v-html="config.branding"></span></strong>
</div>
<div class="column px-1">
<!-- Stop/Start video buttons -->
<button type="button" v-if="webcam.active" class="button is-small is-danger px-1" @click="stopVideo()">
<i class="fa fa-stop mr-2"></i>
Stop
</button>
<button type="button" v-else class="button is-small is-success px-1" @click="startVideo({})"
:disabled="webcam.busy">
<i class="fa fa-video mr-2"></i>
Share webcam
</button>
<!-- Mute/Unmute my mic buttons (if streaming)-->
<button type="button" v-if="webcam.active && !webcam.muted" class="button is-small is-success ml-1 px-1"
@click="muteMe()">
<i class="fa fa-microphone mr-2"></i>
Mute
</button>
<button type="button" v-if="webcam.active && webcam.muted" class="button is-small is-danger ml-1 px-1"
@click="muteMe()">
<i class="fa fa-microphone-slash mr-2"></i>
Unmute
</button>
<!-- Watchers button -->
<button type="button" v-if="webcam.active" class="button is-small is-info is-outlined ml-1 px-1"
@click="showViewers()">
<i class="fa fa-eye mr-2"></i>
{{ Object.keys(webcam.watching).length }}
</button>
<!-- NSFW toggle button -->
<button type="button" v-if="webcam.active && config.permitNSFW" class="button is-small px-1 ml-1"
:class="{
'is-outlined is-dark': !webcam.nsfw,
'is-danger': webcam.nsfw
}" @click.prevent="webcam.nsfw = !webcam.nsfw; sendMe()"
title="Toggle the NSFW setting for your camera broadcast">
<i class="fa fa-fire mr-1" :class="{ 'has-text-danger': !webcam.nsfw }"></i> Explicit
</button>
</div>
<div class="column dropdown is-right is-narrow pl-1" id="chat-settings-hamburger-menu">
<!-- Note: the onclick for the previous div is handled in index.html -->
<div class="dropdown-trigger">
<button type="button" class="button is-small is-link px-2"
aria-haspopup="true"
aria-controls="chat-settings-menu">
<span>
<i class="fa fa-bars"></i>
</span>
</button>
</div>
<div class="dropdown-menu mr-3" id="chat-settings-menu" role="menu">
<div class="dropdown-content">
<a href="#" class="dropdown-item" @click.prevent="showSettings()">
<i class="fa fa-gear mr-1"></i> Chat Settings
</a>
<a href="#" class="dropdown-item" v-if="numVideosOpen > 0"
@click.prevent="closeOpenVideos()">
<i class="fa fa-video-slash mr-1"></i> Close all cameras
</a>
<a href="#" class="dropdown-item" v-if="numVideosOpen > 0"
@click.prevent="muteAllVideos()">
<i class="fa fa-microphone-slash mr-1"></i> Mute all cameras
</a>
<hr class="dropdown-divider">
<a href="/about" target="_blank" class="dropdown-item">
<i class="fa fa-info-circle mr-1"></i> About
</a>
<a href="/logout" class="dropdown-item">
<i class="fa fa-arrow-right-from-bracket mr-1"></i> Log out
</a>
</div>
</div>
</div>
</div>
</header>
<!-- Left Column: Channels & DMs -->
<div class="left-column">
<div class="card grid-card">
<header class="card-header has-background-success-dark">
<div class="columns is-mobile card-header-title has-text-light">
<div class="column is-narrow mobile-only">
<button type="button" class="button is-success px-2" @click="openChatPanel">
<i class="fa fa-arrow-left"></i>
</button>
</div>
<div class="column">Channels</div>
</div>
</header>
<div class="card-content">
<aside class="menu">
<p class="menu-label">
Chat Rooms
</p>
<ul class="menu-list">
<li v-for="c in activeChannels()" v-bind:key="c.ID">
<a :href="'#' + c.ID" @click.prevent="setChannel(c)" :class="{ 'is-active': c.ID == channel }">
{{ c.Name }}
<span v-if="hasUnread(c.ID)" class="tag is-success">
{{ hasUnread(c.ID) }}
</span>
</a>
</li>
</ul>
<p class="menu-label">
Direct Messages
</p>
<ul class="menu-list">
<li v-for="c in activeDMs" v-bind:key="c.channel">
<a :href="'#' + c.channel" @click.prevent="setChannel(c.channel)"
:class="{ 'is-active': c.channel == channel }">
<div class="columns is-mobile">
<!-- Avatar URL if available (copied from Who List) -->
<div class="column is-narrow pr-0" style="position: relative">
<img v-if="avatarForUsername(normalizeUsername(c.channel))"
:src="avatarForUsername(normalizeUsername(c.channel))" width="24"
height="24" alt="">
<img v-else src="/static/img/shy.png" width="24" height="24">
</div>
<div class="column">
<del v-if="isUserOffline(c.name)">
{{ c.name }}
</del>
<span v-else>{{ c.name }}</span>
<span v-if="hasUnread(c.channel)" class="tag is-danger ml-1">
{{ hasUnread(c.channel) }}
</span>
</div>
</div>
</a>
</li>
</ul>
</aside>
<!-- Close new DMs toggle -->
<div class="tag mt-2">
<label class="checkbox">
<input type="checkbox"
v-model="prefs.closeDMs"
:value="true">
Ignore unsolicited DMs
<a href="#"
onclick="alert('When checked, your DMs will be closed to new conversations. You may still initiate new DMs with others.'); return false"
class="fa fa-info-circle ml-2">
</a>
</label>
</div>
</div>
</div>
</div>
<!-- Middle Column: Chat Room/History -->
<div class="chat-column">
<div class="card grid-card">
<header class="card-header" :class="{ 'has-background-private': isDM, 'has-background-link': !isDM }">
<div class="columns is-mobile card-header-title has-text-light">
<div class="column is-narrow mobile-only pr-0">
<!-- Responsive mobile button to pan to Left Column -->
<button type="button" class="button is-success px-2" @click="openChannelsPanel">
<i v-if="isDM" class="fa fa-arrow-left"></i>
<i v-else class="fa fa-comments"></i>
<!-- Indicator badge for unread messages -->
<span v-if="hasAnyUnread() > 0" class="tag ml-1" :class="{ 'is-danger': anyUnreadDMs() }">
{{ hasAnyUnread() }}
</span>
</button>
</div>
<div class="column">
{{ channelName }}
</div>
<div class="column is-narrow">
<!-- If a DM thread and the user has a profile URL -->
<button type="button" v-if="channel.indexOf('@') === 0 && profileURLForUsername(channel)"
class="button is-small is-outlined is-light mr-1" @click="openProfile({ username: channel })">
<i class="fa fa-user"></i>
</button>
<!-- DMs: Leave convo button -->
<button type="button" v-if="channel.indexOf('@') === 0"
class="float-right button is-small is-warning is-outlined" @click="leaveDM()">
<i class="fa fa-trash"></i>
</button>
</div>
<!-- Who List button, only shown on public channel view -->
<div v-if="!isDM" class="column is-narrow mobile-only">
<!-- Responsive mobile button to pan to Right Column -->
<button type="button" class="button is-success px-2" @click="openWhoPanel">
<i class="fa fa-user-group"></i>
</button>
</div>
</div>
</header>
<div id="video-feeds" class="video-feeds" :class="webcam.videoScale"
v-show="webcam.active || Object.keys(WebRTC.streams).length > 0">
<!-- Video Feeds-->
<!-- My video -->
<VideoFeed
v-show="webcam.active"
:local-video="true"
:username="username"
:popped-out="WebRTC.poppedOut[username]"
:is-explicit="webcam.nsfw"
:is-muted="webcam.muted"
:is-source-muted="webcam.muted"
@mute-video="muteMe()"
@popout="popoutVideo"
@set-volume="setVideoVolume">
</VideoFeed>
<!-- Others' videos -->
<VideoFeed
v-for="(stream, username) in WebRTC.streams"
v-bind:key="username"
:username="username"
:popped-out="WebRTC.poppedOut[username]"
:is-explicit="isUsernameCamNSFW(username)"
:is-source-muted="isSourceMuted(username)"
:is-muted="isMuted(username)"
:is-watching-me="isWatchingMe(username)"
:is-frozen="WebRTC.frozenStreamDetected[username]"
@reopen-video="openVideoByUsername"
@mute-video="muteVideo"
@popout="popoutVideo"
@close-video="closeVideo"
@set-volume="setVideoVolume">
</VideoFeed>
<!-- Debugging - copy a lot of these to simulate more videos -->
<!-- <div class="feed">
hi
</div>
<div class="feed">
hi
</div>
<div class="feed">
hi
</div>
<div class="feed">
hi
</div> -->
</div>
<div class="card-content" id="chatHistory"
:class="{ 'has-background-dm': isDM,
'p-1 pb-5': messageStyle.indexOf('compact') === 0 }">
<div class="autoscroll-field tag">
<label class="checkbox is-size-6" title="Automatically scroll when new chat messages come in.">
<input type="checkbox" v-model="autoscroll" :value="true">
Auto-scroll
</label>
</div>
<div :class="fontSizeClass">
<!-- Disclaimer at the top of DMs -->
<!-- TODO: make this disclaimer configurable for other sites to modify -->
<div class="notification is-warning is-light" v-if="isDM">
<i class="fa fa-info-circle mr-1"></i>
<strong>Reminder:</strong> please conduct yourself honorably in Direct Messages.
Please refer to <span v-html="config.branding"></span>'s Privacy Policy or Terms of Service with
regard to DMs.
</div>
<!-- No history? -->
<div v-if="chatHistory.length === 0">
<em v-if="isDM">
Starting a direct message chat with {{ channel }}. Type a message and say hello!
</em>
<em v-else>
There are no messages in this channel yet.
</em>
</div>
<div v-for="(msg, i) in chatHistory" v-bind:key="i">
<!-- Enter chat presence messages draw as a short banner -->
<div v-if="msg.action === 'presence'" class="notification is-success is-light py-1 px-3 mb-2">
<!-- Tiny avatar next to name and action buttons -->
<div class="columns is-mobile">
<div class="column is-narrow pr-0 pt-4">
<figure class="image is-16x16">
<img v-if="avatarForUsername(msg.username)"
:src="avatarForUsername(msg.username)">
<img v-else src="/static/img/shy.png">
</figure>
</div>
<div class="column">
<!-- Timestamp on the right -->
<span class="float-right is-size-7" :title="msg.at">
{{ prettyDate(msg.at) }}
</span>
<strong>{{ nicknameForUsername(msg.username) }}</strong>
<span v-if="isUserOffline(msg.username)" class="ml-1">(offline)</span>
<small v-else class="ml-1">(@{{ msg.username }})</small>
{{ msg.message }}
</div>
</div>
</div>
<!-- Normal chat message: full size card w/ avatar -->
<MessageBox
v-else
:message="msg"
:appearance="messageStyle"
:position="i"
:user="getUser(msg.username)"
:is-offline="isUserOffline(msg.username)"
:username="username"
:website-url="config.website"
:is-dnd="isUsernameDND(msg.username)"
:is-muted="isMutedUser(msg.username)"
:reactions="getReactions(msg)"
:report-enabled="isWebhookEnabled('report')"
:is-dm="isDM"
:is-op="isOp"
@open-profile="showProfileModal"
@send-dm="openDMs"
@mute-user="muteUser"
@takeback="takeback"
@remove="removeMessage"
@report="reportMessage"
@react="sendReact"
></MessageBox>
</div>
</div>
<!-- If this is a DM with a muted user, offer to unmute. -->
<div v-if="isDM && isMutedUser(channel)" class="has-text-danger">
<i class="fa fa-comment-slash"></i>
<strong>{{ channel }}</strong> is currently <strong>muted</strong> so you have not been seeing their
recent
chat messages or DMs.
<a href="#" v-on:click.prevent="muteUser(channel)">Unmute them?</a>
</div>
</div>
</div>
</div>
<!-- Chat Footer Frame -->
<div class="chat-footer">
<div class="card">
<div class="card-content p-2">
<div class="columns is-mobile">
<div class="column pr-1 is-narrow" v-if="canUploadFile">
<button type="button" class="button" @click="uploadFile()"
title="Upload a picture to share in chat">
<i class="fa fa-image"></i>
</button>
</div>
<div class="column pr-1" :class="{ 'pl-1': canUploadFile }">
<form @submit.prevent="sendMessage()">
<!-- At Mentions -->
<Mentionable
:keys="['@']"
:items="atMentionItems"
offset="12"
insert-space>
<!-- My text box -->
<input type="text" class="input" id="messageBox" v-model="message"
placeholder="Write a message" @keydown="sendTypingNotification()" autocomplete="off"
:disabled="!ws.connected">
<!-- At Mention templates-->
<template #no-result>
<div class="has-text-grey m-2">
No result
</div>
</template>
<template #item-@="{ item }">
<div class="has-text-link m-2">
@{{ item.value }}
<span class="has-text-grey">
{{ item.label }}
</span>
</div>
</template>
</Mentionable>
</form>
</div>
<div class="column px-1 is-narrow dropdown is-right is-up" :class="{ 'is-active': showEmojiPicker }"
@click="showEmojiPicker=true">
<!-- Emoji picker for messages -->
<div class="dropdown-trigger">
<button type="button" class="button"
aria-haspopup="true"
aria-controls="input-emoji-picker"
@click="hideEmojiPicker()">
<span>
<i class="fa-regular fa-smile"></i>
</span>
</button>
</div>
<div class="dropdown-menu" id="input-emoji-picker" role="menu"
style="z-index: 9000">
<!-- Note: z-index so the popup isn't covered by the "Auto-scroll"
label on the chat history panel -->
<div class="dropdown-content p-0">
<EmojiPicker
:native="true"
:display-recent="true"
:disable-skin-tones="true"
theme="auto"
@select="onSelectEmoji">
</EmojiPicker>
</div>
</div>
</div>
<div class="column pl-1 is-narrow">
<button type="button" class="button" :disabled="message.length === 0"
title="Click to send your message" @click="sendMessage()">
<i class="fa fa-paper-plane"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Right Column: Who Is Online -->
<div class="right-column">
<div class="card grid-card">
<header class="card-header has-background-success-dark">
<div class="columns is-mobile card-header-title has-text-light">
<div class="column">Who Is Online</div>
<div class="column is-narrow mobile-only">
<button type="button" class="button is-success px-2" @click="openChatPanel">
<i class="fa fa-arrow-left"></i>
</button>
</div>
</div>
</header>
<div class="card-content p-2">
<div class="columns is-mobile mb-0">
<div class="column is-one-quarter">
Status:
</div>
<div class="column">
<div class="select is-small is-fullwidth">
<select v-model="status">
<optgroup label="Status">
<option value="online">☀️ Active</option>
<option value="away">🕒 Away</option>
<option value="brb">⏰ Be right back</option>
<option value="lunch">🍴 Out to lunch</option>
<option value="call">📞 On the phone</option>
<option value="busy">💼 Working</option>
<option value="book">📖 Studying</option>
<option value="gaming">🎮 Gaming</option>
<option value="idle" v-show="status === 'idle'">🕒 Idle</option>
<option value="hidden" v-if="jwt.claims != undefined && jwt.claims.op">🕵️ Hidden
</option>
</optgroup>
<optgroup label="Mood">
<option value="chatty">🗨️ Chatty and sociable</option>
<option value="introverted">🥄 Introverted and quiet</option>
<option value="horny" v-if="config.permitNSFW">🔥 Horny</option>
<option value="exhibitionist" v-if="config.permitNSFW">👀 Watch me</option>
</optgroup>
</select>
</div>
</div>
</div>
<div class="columns is-mobile mb-0">
<div class="column is-one-quarter">
Sort:
</div>
<div class="column">
<div class="select is-small is-fullwidth">
<select v-model="whoSort">
<option value="a-z">Username (a-z)</option>
<option value="z-a">Username (z-a)</option>
<option value="login">Login Time</option>
<option value="broadcasting">Broadcasting</option>
<option value="nsfw" v-show="config.permitNSFW">Red cameras</option>
<option value="status">Status</option>
<option value="emoji">Emoji/country flag</option>
<option value="gender">Gender</option>
<option value="op">User level (operators)</option>
</select>
</div>
</div>
</div>
<div class="tabs has-text-small">
<ul>
<li :class="{ 'is-active': whoTab === 'online' }">
<a class="is-size-7" @click.prevent="whoTab = 'online'">
Online ({{ whoList.length }})
</a>
</li>
<li v-if="webcam.active" :class="{ 'is-active': whoTab === 'watching' }">
<a class="is-size-7" @click.prevent="whoTab = 'watching'">
<i class="fa fa-eye mr-2"></i>
Watching ({{ Object.keys(webcam.watching).length }})
</a>
</li>
</ul>
</div>
<!-- Who Is Online -->
<ul class="menu-list" v-if="whoTab === 'online'">
<li v-for="(u, i) in sortedWhoList" v-bind:key="i">
<WhoListRow
:user="u"
:username="username"
:website-url="config.website"
:is-dnd="isUsernameDND(u.username)"
:is-muted="isMutedUser(u.username)"
:is-op="isOp"
:is-video-not-allowed="isVideoNotAllowed(u)"
:video-icon-class="webcamIconClass(u)"
:vip-config="config.VIP"
@send-dm="openDMs"
@mute-user="muteUser"
@open-video="openVideo"
@open-profile="showProfileModal"></WhoListRow>
</li>
</ul>
<!-- Watching My Webcam -->
<ul class="menu-list" v-if="whoTab === 'watching'">
<li v-for="(u, i) in sortedWatchingList" v-bind:key="username">
<WhoListRow
:is-watching-tab="true"
:user="u"
:username="username"
:website-url="config.website"
:is-dnd="isUsernameDND(username)"
:is-muted="isMutedUser(username)"
:is-op="isOp"
:is-video-not-allowed="isVideoNotAllowed(u)"
:video-icon-class="webcamIconClass(u)"
:vip-config="config.VIP"
@send-dm="openDMs"
@mute-user="muteUser"
@open-video="openVideo"
@boot-user="bootUser"
@open-profile="showProfileModal"></WhoListRow>
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<style>
/* At-mention styles */
.mention-item {
padding: 4px 10px;
border-radius: 4px;
}
.mention-selected {
background: rgb(192, 250, 153);
}
</style>