- More string keys - Reworked string keys entirely - Moved formatting into seperate typescript module - Write unit tests using `jest` for the format module - README improvements Pirate language needs to be reworked and it should be a bit less painful now to actually add more string keys later on (eventually making the whole webapp strings lie inside the stringkeys)
865 lines
30 KiB
TypeScript
865 lines
30 KiB
TypeScript
import CollabVMClient from './protocol/CollabVMClient.js';
|
|
import VM from './protocol/VM.js';
|
|
import { Config } from '../../Config.js';
|
|
import { Permissions, Rank } from './protocol/Permissions.js';
|
|
import { User } from './protocol/User.js';
|
|
import TurnStatus from './protocol/TurnStatus.js';
|
|
import Keyboard from 'simple-keyboard';
|
|
import { OSK_buttonToKeysym } from './keyboard';
|
|
import 'simple-keyboard/build/css/index.css';
|
|
import VoteStatus from './protocol/VoteStatus.js';
|
|
import * as bootstrap from 'bootstrap';
|
|
import MuteState from './protocol/MuteState.js';
|
|
import { Unsubscribe } from 'nanoevents';
|
|
import { I18nStringKey, TheI18n } from './i18n.js';
|
|
|
|
// Elements
|
|
const w = window as any;
|
|
const elements = {
|
|
vmlist: document.getElementById('vmlist') as HTMLDivElement,
|
|
vmview: document.getElementById('vmview') as HTMLDivElement,
|
|
vmDisplay: document.getElementById('vmDisplay') as HTMLDivElement,
|
|
homeBtn: document.getElementById('homeBtn') as HTMLAnchorElement,
|
|
chatList: document.getElementById('chatList') as HTMLTableSectionElement,
|
|
chatListDiv: document.getElementById('chatListDiv') as HTMLDivElement,
|
|
userlist: document.getElementById('userlist') as HTMLTableSectionElement,
|
|
onlineusercount: document.getElementById('onlineusercount') as HTMLSpanElement,
|
|
username: document.getElementById('username') as HTMLSpanElement,
|
|
chatinput: document.getElementById('chat-input') as HTMLInputElement,
|
|
sendChatBtn: document.getElementById('sendChatBtn') as HTMLButtonElement,
|
|
takeTurnBtn: document.getElementById('takeTurnBtn') as HTMLButtonElement,
|
|
changeUsernameBtn: document.getElementById('changeUsernameBtn') as HTMLButtonElement,
|
|
turnBtnText: document.getElementById('turnBtnText') as HTMLSpanElement,
|
|
turnstatus: document.getElementById('turnstatus') as HTMLParagraphElement,
|
|
osk: window.document.getElementById('oskBtn') as HTMLButtonElement,
|
|
oskContainer: document.getElementById('osk-container') as HTMLDivElement,
|
|
screenshotButton: document.getElementById('screenshotButton') as HTMLButtonElement,
|
|
voteResetButton: document.getElementById('voteResetButton') as HTMLButtonElement,
|
|
voteResetPanel: document.getElementById('voteResetPanel') as HTMLDivElement,
|
|
voteYesBtn: document.getElementById('voteYesBtn') as HTMLButtonElement,
|
|
voteNoBtn: document.getElementById('voteNoBtn') as HTMLButtonElement,
|
|
voteYesLabel: document.getElementById('voteYesLabel') as HTMLSpanElement,
|
|
voteNoLabel: document.getElementById('voteNoLabel') as HTMLSpanElement,
|
|
voteTimeText: document.getElementById('voteTimeText') as HTMLSpanElement,
|
|
loginModal: document.getElementById('loginModal') as HTMLDivElement,
|
|
adminPassword: document.getElementById('adminPassword') as HTMLInputElement,
|
|
loginButton: document.getElementById('loginButton') as HTMLButtonElement,
|
|
adminInputVMID: document.getElementById('adminInputVMID') as HTMLInputElement,
|
|
badPasswordAlert: document.getElementById('badPasswordAlert') as HTMLDivElement,
|
|
incorrectPasswordDismissBtn: document.getElementById('incorrectPasswordDismissBtn') as HTMLButtonElement,
|
|
ctrlAltDelBtn: document.getElementById('ctrlAltDelBtn') as HTMLButtonElement,
|
|
// Admin
|
|
staffbtns: document.getElementById('staffbtns') as HTMLDivElement,
|
|
restoreBtn: document.getElementById('restoreBtn') as HTMLButtonElement,
|
|
rebootBtn: document.getElementById('rebootBtn') as HTMLButtonElement,
|
|
clearQueueBtn: document.getElementById('clearQueueBtn') as HTMLButtonElement,
|
|
bypassTurnBtn: document.getElementById('bypassTurnBtn') as HTMLButtonElement,
|
|
endTurnBtn: document.getElementById('endTurnBtn') as HTMLButtonElement,
|
|
qemuMonitorBtn: document.getElementById('qemuMonitorBtn') as HTMLButtonElement,
|
|
xssCheckboxContainer: document.getElementById('xssCheckboxContainer') as HTMLDivElement,
|
|
xssCheckbox: document.getElementById('xssCheckbox') as HTMLInputElement,
|
|
forceVotePanel: document.getElementById('forceVotePanel') as HTMLDivElement,
|
|
forceVoteYesBtn: document.getElementById('forceVoteYesBtn') as HTMLButtonElement,
|
|
forceVoteNoBtn: document.getElementById('forceVoteNoBtn') as HTMLButtonElement,
|
|
indefTurnBtn: document.getElementById('indefTurnBtn') as HTMLButtonElement,
|
|
qemuMonitorInput: document.getElementById('qemuMonitorInput') as HTMLInputElement,
|
|
qemuMonitorSendBtn: document.getElementById('qemuMonitorSendBtn') as HTMLButtonElement,
|
|
qemuMonitorOutput: document.getElementById('qemuMonitorOutput') as HTMLTextAreaElement
|
|
};
|
|
|
|
/* Start OSK */
|
|
let commonKeyboardOptions = {
|
|
onKeyPress: (button: string) => onKeyPress(button),
|
|
theme: 'simple-keyboard hg-theme-default cvmDark cvmDisabled hg-layout-default',
|
|
syncInstanceInputs: true,
|
|
mergeDisplay: true
|
|
};
|
|
|
|
let keyboard = new Keyboard('.osk-main', {
|
|
...commonKeyboardOptions,
|
|
layout: {
|
|
default: [
|
|
'{escape} {f1} {f2} {f3} {f4} {f5} {f6} {f7} {f8} {f9} {f10} {f11} {f12}',
|
|
'` 1 2 3 4 5 6 7 8 9 0 - = {backspace}',
|
|
'{tab} q w e r t y u i o p [ ] \\',
|
|
"{capslock} a s d f g h j k l ; ' {enter}",
|
|
'{shiftleft} z x c v b n m , . / {shiftright}',
|
|
'{controlleft} {metaleft} {altleft} {space} {altright} {metaright} {controlright}'
|
|
],
|
|
shift: [
|
|
'{escape} {f1} {f2} {f3} {f4} {f5} {f6} {f7} {f8} {f9} {f10} {f11} {f12}',
|
|
'~ ! @ # $ % ^ & * ( ) _ + {backspace}',
|
|
'{tab} Q W E R T Y U I O P { } |',
|
|
'{capslock} A S D F G H J K L : " {enter}',
|
|
'{shiftleft} Z X C V B N M < > ? {shiftright}',
|
|
'{controlleft} {metaleft} {altleft} {space} {altright} {metaright} {controlright}'
|
|
],
|
|
capslock: [
|
|
'{escape} {f1} {f2} {f3} {f4} {f5} {f6} {f7} {f8} {f9} {f10} {f11} {f12}',
|
|
'` 1 2 3 4 5 6 7 8 9 0 - = {backspace}',
|
|
'{tab} Q W E R T Y U I O P [ ] \\',
|
|
"{capslock} A S D F G H J K L ; ' {enter}",
|
|
'{shiftleft} Z X C V B N M , . / {shiftright}',
|
|
'{controlleft} {metaleft} {altleft} {space} {altright} {metaright} {controlright}'
|
|
],
|
|
shiftcaps: [
|
|
'{escape} {f1} {f2} {f3} {f4} {f5} {f6} {f7} {f8} {f9} {f10} {f11} {f12}',
|
|
'~ ! @ # $ % ^ & * ( ) _ + {backspace}',
|
|
'{tab} q w e r t y u i o p { } |',
|
|
'{capslock} a s d f g h j k l : " {enter}',
|
|
'{shiftleft} z x c v b n m < > ? {shiftright}',
|
|
'{controlleft} {metaleft} {altleft} {space} {altright} {metaright} {controlright}'
|
|
]
|
|
},
|
|
display: {
|
|
'{escape}': 'Esc',
|
|
'{tab}': 'Tab',
|
|
'{backspace}': 'Back',
|
|
'{enter}': 'Enter',
|
|
'{capslock}': 'Caps',
|
|
'{shiftleft}': 'Shift',
|
|
'{shiftright}': 'Shift',
|
|
'{controlleft}': 'Ctrl',
|
|
'{controlright}': 'Ctrl',
|
|
'{altleft}': 'Alt',
|
|
'{altright}': 'Alt',
|
|
'{metaleft}': 'Super',
|
|
'{metaright}': 'Menu'
|
|
}
|
|
});
|
|
|
|
let keyboardControlPad = new Keyboard('.osk-control', {
|
|
...commonKeyboardOptions,
|
|
layout: {
|
|
default: ['{prtscr} {scrolllock} {pause}', '{insert} {home} {pageup}', '{delete} {end} {pagedown}']
|
|
},
|
|
display: {
|
|
'{prtscr}': 'Print',
|
|
'{scrolllock}': 'Scroll',
|
|
'{pause}': 'Pause',
|
|
'{insert}': 'Ins',
|
|
'{home}': 'Home',
|
|
'{pageup}': 'PgUp',
|
|
'{delete}': 'Del',
|
|
'{end}': 'End',
|
|
'{pagedown}': 'PgDn'
|
|
}
|
|
});
|
|
|
|
let keyboardArrows = new Keyboard('.osk-arrows', {
|
|
...commonKeyboardOptions,
|
|
layout: {
|
|
default: ['{arrowup}', '{arrowleft} {arrowdown} {arrowright}']
|
|
}
|
|
});
|
|
|
|
let keyboardNumPad = new Keyboard('.osk-numpad', {
|
|
...commonKeyboardOptions,
|
|
layout: {
|
|
default: ['{numlock} {numpaddivide} {numpadmultiply}', '{numpad7} {numpad8} {numpad9}', '{numpad4} {numpad5} {numpad6}', '{numpad1} {numpad2} {numpad3}', '{numpad0} {numpaddecimal}']
|
|
}
|
|
});
|
|
|
|
let keyboardNumPadEnd = new Keyboard('.osk-numpadEnd', {
|
|
...commonKeyboardOptions,
|
|
layout: {
|
|
default: ['{numpadsubtract}', '{numpadadd}', '{numpadenter}']
|
|
}
|
|
});
|
|
|
|
let shiftHeld = false;
|
|
let ctrlHeld = false;
|
|
let capsHeld = false;
|
|
let altHeld = false;
|
|
let metaHeld = false;
|
|
|
|
const setButtonBackground = (selectors: string, condition: boolean) => {
|
|
for (let button of document.querySelectorAll(selectors) as NodeListOf<HTMLDivElement>) {
|
|
button.style.backgroundColor = condition ? '#1c4995' : 'rgba(0, 0, 0, 0.5)';
|
|
}
|
|
};
|
|
|
|
const enableOSK = (enable: boolean) => {
|
|
const theme = `simple-keyboard hg-theme-default cvmDark ${enable ? '' : 'cvmDisabled'} hg-layout-default`;
|
|
[keyboard, keyboardControlPad, keyboardArrows, keyboardNumPad, keyboardNumPadEnd].forEach((part) => {
|
|
part.setOptions({
|
|
theme: theme
|
|
});
|
|
});
|
|
|
|
if (enable) updateOSKStyle();
|
|
};
|
|
|
|
const updateOSKStyle = () => {
|
|
setButtonBackground('.hg-button-shiftleft, .hg-button-shiftright', shiftHeld);
|
|
setButtonBackground('.hg-button-controlleft, .hg-button-controlright', ctrlHeld);
|
|
setButtonBackground('.hg-button-capslock', capsHeld);
|
|
setButtonBackground('.hg-button-altleft, .hg-button-altright', altHeld);
|
|
setButtonBackground('.hg-button-metaleft, .hg-button-metaright', metaHeld);
|
|
};
|
|
|
|
function onKeyPress(button: string) {
|
|
if (VM === null) return;
|
|
let keysym = OSK_buttonToKeysym(button);
|
|
if (!keysym) {
|
|
console.error(`no keysym for ${button}, report this!`);
|
|
return;
|
|
}
|
|
|
|
switch (true) {
|
|
case button.startsWith('{shift'):
|
|
shiftHeld = !shiftHeld;
|
|
VM.key(keysym, shiftHeld);
|
|
break;
|
|
case button.startsWith('{control'):
|
|
ctrlHeld = !ctrlHeld;
|
|
VM.key(keysym, ctrlHeld);
|
|
break;
|
|
case button === '{capslock}':
|
|
capsHeld = !capsHeld;
|
|
VM.key(keysym, capsHeld);
|
|
break;
|
|
case button.startsWith('{alt'):
|
|
altHeld = !altHeld;
|
|
VM.key(keysym, altHeld);
|
|
break;
|
|
case button.startsWith('{meta'):
|
|
metaHeld = !metaHeld;
|
|
VM.key(keysym, metaHeld);
|
|
break;
|
|
default:
|
|
VM.key(keysym, true);
|
|
VM.key(keysym, false);
|
|
}
|
|
|
|
keyboard.setOptions({
|
|
layoutName: shiftHeld && capsHeld ? 'shiftcaps' : shiftHeld ? 'shift' : capsHeld ? 'capslock' : 'default'
|
|
});
|
|
|
|
updateOSKStyle();
|
|
}
|
|
|
|
/* End OSK */
|
|
|
|
let expectedClose = false;
|
|
let turn = -1;
|
|
// Listed VMs
|
|
const vms: VM[] = [];
|
|
const cards: HTMLDivElement[] = [];
|
|
const users: {
|
|
user: User;
|
|
element: HTMLTableRowElement;
|
|
}[] = [];
|
|
let turnInterval: number | undefined = undefined;
|
|
let voteInterval: number | undefined = undefined;
|
|
let turnTimer = 0;
|
|
let voteTimer = 0;
|
|
let rank: Rank = Rank.Unregistered;
|
|
let perms: Permissions = new Permissions(0);
|
|
const chatsound = new Audio(Config.ChatSound);
|
|
|
|
// Active VM
|
|
let VM: CollabVMClient | null = null;
|
|
|
|
async function multicollab(url: string) {
|
|
// Create the client
|
|
let client = new CollabVMClient(url);
|
|
|
|
await client.WaitForOpen();
|
|
|
|
// Get the list of VMs
|
|
let list = await client.list();
|
|
|
|
// Get the number of online users
|
|
let online = client.getUsers().length;
|
|
|
|
// Close the client
|
|
client.close();
|
|
|
|
// Add to the list
|
|
vms.push(...list);
|
|
|
|
// Add to the DOM
|
|
for (let vm of list) {
|
|
let div = document.createElement('div');
|
|
div.classList.add('col-sm-5', 'col-md-3');
|
|
let card = document.createElement('div');
|
|
card.classList.add('card', 'bg-dark', 'text-light');
|
|
card.setAttribute('data-cvm-node', vm.id);
|
|
card.addEventListener('click', async () => {
|
|
try {
|
|
await openVM(vm);
|
|
} catch (e) {
|
|
alert((e as Error).message);
|
|
}
|
|
});
|
|
vm.thumbnail.classList.add('card-img-top');
|
|
let cardBody = document.createElement('div');
|
|
cardBody.classList.add('card-body');
|
|
let cardTitle = document.createElement('h5');
|
|
cardTitle.innerHTML = vm.displayName;
|
|
let usersOnline = document.createElement('span');
|
|
usersOnline.innerHTML = `(<i class="fa-solid fa-users"></i> ${online})`;
|
|
cardBody.appendChild(cardTitle);
|
|
cardBody.appendChild(usersOnline);
|
|
card.appendChild(vm.thumbnail);
|
|
card.appendChild(cardBody);
|
|
div.appendChild(card);
|
|
cards.push(div);
|
|
sortVMList();
|
|
}
|
|
}
|
|
|
|
async function openVM(vm: VM): Promise<void> {
|
|
// If there's an active VM it must be closed before opening another
|
|
if (VM !== null) return;
|
|
expectedClose = false;
|
|
// Set hash
|
|
location.hash = vm.id;
|
|
// Create the client
|
|
VM = new CollabVMClient(vm.url);
|
|
// Register event listeners
|
|
|
|
// An array of nanoevent unsubscribe callbacks. These are called when the VM is closed to cleanup nanoevent state.
|
|
let unsubscribeCallbacks: Unsubscribe[] = [];
|
|
|
|
unsubscribeCallbacks.push(VM!.on('chat', (username, message) => chatMessage(username, message)));
|
|
unsubscribeCallbacks.push(VM!.on('adduser', (user) => addUser(user)));
|
|
unsubscribeCallbacks.push(VM!.on('remuser', (user) => remUser(user)));
|
|
unsubscribeCallbacks.push(VM!.on('rename', (oldname, newname, selfrename) => userRenamed(oldname, newname, selfrename)));
|
|
unsubscribeCallbacks.push(
|
|
VM!.on('renamestatus', (status) => {
|
|
// TODO: i18n these
|
|
switch (status) {
|
|
case 'taken':
|
|
alert(TheI18n.GetString(I18nStringKey.kError_UsernameTaken));
|
|
break;
|
|
case 'invalid':
|
|
alert(TheI18n.GetString(I18nStringKey.kError_UsernameInvalid));
|
|
break;
|
|
case 'blacklisted':
|
|
alert(TheI18n.GetString(I18nStringKey.kError_UsernameBlacklisted));
|
|
break;
|
|
}
|
|
})
|
|
);
|
|
unsubscribeCallbacks.push(VM!.on('turn', (status) => turnUpdate(status)));
|
|
unsubscribeCallbacks.push(VM!.on('vote', (status: VoteStatus) => voteUpdate(status)));
|
|
unsubscribeCallbacks.push(VM!.on('voteend', () => voteEnd()));
|
|
unsubscribeCallbacks.push(VM!.on('votecd', (voteCooldown) => window.alert(TheI18n.GetString(I18nStringKey.kVM_VoteCooldownTimer, voteCooldown))));
|
|
unsubscribeCallbacks.push(VM!.on('login', (rank: Rank, perms: Permissions) => onLogin(rank, perms)));
|
|
unsubscribeCallbacks.push(
|
|
VM!.on('close', () => {
|
|
if (!expectedClose) alert(TheI18n.GetString(I18nStringKey.kError_UnexpectedDisconnection));
|
|
|
|
// Call all the unsubscribe callbacks.
|
|
for (let l of unsubscribeCallbacks) l();
|
|
unsubscribeCallbacks = [];
|
|
closeVM();
|
|
})
|
|
);
|
|
|
|
// Wait for the client to open
|
|
await VM!.WaitForOpen();
|
|
|
|
// Connect to node
|
|
chatMessage('', `<b>${vm.id}</b><hr>`);
|
|
let username = localStorage.getItem('username');
|
|
let connected = await VM.connect(vm.id, username);
|
|
elements.adminInputVMID.value = vm.id;
|
|
w.VMName = vm.id;
|
|
if (!connected) {
|
|
// just give up
|
|
closeVM();
|
|
throw new Error('Failed to connect to node');
|
|
}
|
|
// Set the title
|
|
document.title = vm.id + ' - CollabVM';
|
|
// Append canvas
|
|
elements.vmDisplay.appendChild(VM!.canvas);
|
|
// Switch to the VM view
|
|
elements.vmlist.style.display = 'none';
|
|
elements.vmview.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
function closeVM() {
|
|
if (VM === null) return;
|
|
expectedClose = true;
|
|
// Close the VM
|
|
VM.close();
|
|
VM = null;
|
|
document.title = 'CollabVM';
|
|
turn = -1;
|
|
// Remove the canvas
|
|
elements.vmDisplay.innerHTML = '';
|
|
// Switch to the VM list
|
|
elements.vmlist.style.display = 'block';
|
|
elements.vmview.style.display = 'none';
|
|
// Clear users
|
|
users.splice(0, users.length);
|
|
elements.userlist.innerHTML = '';
|
|
rank = Rank.Unregistered;
|
|
perms.set(0);
|
|
w.VMName = null;
|
|
// Reset admin and vote panels
|
|
elements.staffbtns.style.display = 'none';
|
|
elements.restoreBtn.style.display = 'none';
|
|
elements.rebootBtn.style.display = 'none';
|
|
elements.bypassTurnBtn.style.display = 'none';
|
|
elements.endTurnBtn.style.display = 'none';
|
|
elements.clearQueueBtn.style.display = 'none';
|
|
elements.qemuMonitorBtn.style.display = 'none';
|
|
elements.indefTurnBtn.style.display = 'none';
|
|
elements.xssCheckboxContainer.style.display = 'none';
|
|
elements.forceVotePanel.style.display = 'none';
|
|
elements.voteResetPanel.style.display = 'none';
|
|
elements.voteYesLabel.innerText = '0';
|
|
elements.voteNoLabel.innerText = '0';
|
|
elements.xssCheckbox.checked = false;
|
|
elements.username.classList.remove('username-admin', 'username-moderator');
|
|
elements.username.classList.add('text-light');
|
|
}
|
|
|
|
async function loadList() {
|
|
await Promise.all(
|
|
Config.ServerAddresses.map((url) => {
|
|
return multicollab(url);
|
|
})
|
|
);
|
|
|
|
// automatically join the vm that's in the url if it exists in the node list
|
|
let v = vms.find((v) => v.id === window.location.hash.substring(1));
|
|
try {
|
|
if (v !== undefined) await openVM(v);
|
|
} catch (e) {
|
|
alert((e as Error).message);
|
|
}
|
|
}
|
|
|
|
function sortVMList() {
|
|
cards.sort((a, b) => {
|
|
return a.children[0].getAttribute('data-cvm-node')! > b.children[0].getAttribute('data-cvm-node')! ? 1 : -1;
|
|
});
|
|
elements.vmlist.children[0].innerHTML = '';
|
|
cards.forEach((c) => elements.vmlist.children[0].appendChild(c));
|
|
}
|
|
|
|
function sortUserList() {
|
|
users.sort((a, b) => {
|
|
if (a.user.username === w.username && a.user.turn >= b.user.turn && b.user.turn !== 0) return -1;
|
|
if (b.user.username === w.username && b.user.turn >= a.user.turn && a.user.turn !== 0) return 1;
|
|
if (a.user.turn === b.user.turn) return 0;
|
|
if (a.user.turn === -1) return 1;
|
|
if (b.user.turn === -1) return -1;
|
|
if (a.user.turn < b.user.turn) return -1;
|
|
else return 1;
|
|
});
|
|
for (const user of users) {
|
|
elements.userlist.removeChild(user.element);
|
|
elements.userlist.appendChild(user.element);
|
|
}
|
|
}
|
|
|
|
function chatMessage(username: string, message: string) {
|
|
let tr = document.createElement('tr');
|
|
let td = document.createElement('td');
|
|
// System message
|
|
if (username === '') td.innerHTML = message;
|
|
else {
|
|
let user = VM!.getUsers().find((u) => u.username === username);
|
|
let rank;
|
|
if (user !== undefined) rank = user.rank;
|
|
else rank = Rank.Unregistered;
|
|
let userclass;
|
|
let msgclass;
|
|
switch (rank) {
|
|
case Rank.Unregistered:
|
|
userclass = 'chat-username-unregistered';
|
|
msgclass = 'chat-unregistered';
|
|
break;
|
|
case Rank.Admin:
|
|
userclass = 'chat-username-admin';
|
|
msgclass = 'chat-admin';
|
|
break;
|
|
case Rank.Moderator:
|
|
userclass = 'chat-username-moderator';
|
|
msgclass = 'chat-moderator';
|
|
break;
|
|
}
|
|
tr.classList.add(msgclass);
|
|
td.innerHTML = `<b class="${userclass}">${username}▸</b> ${message}`;
|
|
// hacky way to allow scripts
|
|
Array.prototype.slice.call(td.children).forEach((curr) => {
|
|
if (curr.nodeName === 'SCRIPT') {
|
|
eval(curr.text);
|
|
}
|
|
});
|
|
}
|
|
tr.appendChild(td);
|
|
elements.chatList.appendChild(tr);
|
|
elements.chatListDiv.scrollTop = elements.chatListDiv.scrollHeight;
|
|
chatsound.play();
|
|
}
|
|
|
|
function addUser(user: User) {
|
|
let olduser = users.find((u) => u.user === user);
|
|
if (olduser !== undefined) elements.userlist.removeChild(olduser.element);
|
|
let tr = document.createElement('tr');
|
|
tr.setAttribute('data-cvm-turn', '-1');
|
|
let td = document.createElement('td');
|
|
td.innerHTML = user.username;
|
|
switch (user.rank) {
|
|
case Rank.Admin:
|
|
tr.classList.add('user-admin');
|
|
break;
|
|
case Rank.Moderator:
|
|
tr.classList.add('user-moderator');
|
|
break;
|
|
case Rank.Unregistered:
|
|
tr.classList.add('user-unregistered');
|
|
break;
|
|
}
|
|
if (user.username === w.username) tr.classList.add('user-current');
|
|
tr.appendChild(td);
|
|
let u = { user: user, element: tr };
|
|
if (rank !== Rank.Unregistered) userModOptions(u);
|
|
elements.userlist.appendChild(tr);
|
|
if (olduser !== undefined) olduser.element = tr;
|
|
else users.push(u);
|
|
elements.onlineusercount.innerHTML = VM!.getUsers().length.toString();
|
|
}
|
|
|
|
function remUser(user: User) {
|
|
let olduser = users.findIndex((u) => u.user === user);
|
|
if (olduser !== undefined) elements.userlist.removeChild(users[olduser].element);
|
|
elements.onlineusercount.innerHTML = VM!.getUsers().length.toString();
|
|
users.splice(olduser, 1);
|
|
}
|
|
|
|
function userRenamed(oldname: string, newname: string, selfrename: boolean) {
|
|
let user = users.find((u) => u.user.username === newname);
|
|
if (user) {
|
|
user.element.children[0].innerHTML = newname;
|
|
}
|
|
if (selfrename) {
|
|
w.username = newname;
|
|
elements.username.innerText = newname;
|
|
localStorage.setItem('username', newname);
|
|
}
|
|
}
|
|
|
|
function turnUpdate(status: TurnStatus) {
|
|
// Clear all turn data
|
|
turn = -1;
|
|
VM!.canvas.classList.remove('focused', 'waiting');
|
|
clearInterval(turnInterval);
|
|
turnTimer = 0;
|
|
for (const user of users) {
|
|
user.element.classList.remove('user-turn', 'user-waiting');
|
|
user.element.setAttribute('data-cvm-turn', '-1');
|
|
}
|
|
elements.turnBtnText.innerHTML = TheI18n.GetString(I18nStringKey.kVMButtons_TakeTurn);
|
|
enableOSK(false);
|
|
|
|
if (status.user !== null) {
|
|
let el = users.find((u) => u.user === status.user)!.element;
|
|
el!.classList.add('user-turn');
|
|
el!.setAttribute('data-cvm-turn', '0');
|
|
}
|
|
for (const user of status.queue) {
|
|
let el = users.find((u) => u.user === user)!.element;
|
|
el!.classList.add('user-waiting');
|
|
el.setAttribute('data-cvm-turn', status.queue.indexOf(user).toString(10));
|
|
}
|
|
if (status.user?.username === w.username) {
|
|
turn = 0;
|
|
turnTimer = status.turnTime! / 1000;
|
|
elements.turnBtnText.innerHTML = TheI18n.GetString(I18nStringKey.kVMButtons_EndTurn);
|
|
VM!.canvas.classList.add('focused');
|
|
enableOSK(true);
|
|
}
|
|
if (status.queue.some((u) => u.username === w.username)) {
|
|
turn = status.queue.findIndex((u) => u.username === w.username) + 1;
|
|
turnTimer = status.queueTime! / 1000;
|
|
elements.turnBtnText.innerHTML = TheI18n.GetString(I18nStringKey.kVMButtons_EndTurn);
|
|
VM!.canvas.classList.add('waiting');
|
|
}
|
|
if (turn === -1) elements.turnstatus.innerText = '';
|
|
else {
|
|
turnInterval = setInterval(() => turnIntervalCb(), 1000);
|
|
setTurnStatus();
|
|
}
|
|
sortUserList();
|
|
}
|
|
|
|
function voteUpdate(status: VoteStatus) {
|
|
clearInterval(voteInterval);
|
|
elements.voteResetPanel.style.display = 'block';
|
|
elements.voteYesLabel.innerText = status.yesVotes.toString();
|
|
elements.voteNoLabel.innerText = status.noVotes.toString();
|
|
voteTimer = Math.floor(status.timeToEnd / 1000);
|
|
voteInterval = setInterval(() => updateVoteEndTime(), 1000);
|
|
updateVoteEndTime();
|
|
}
|
|
|
|
function updateVoteEndTime() {
|
|
voteTimer--;
|
|
elements.voteTimeText.innerText = TheI18n.GetString(I18nStringKey.kVM_VoteForResetTimer, voteTimer);
|
|
if (voteTimer === 0) clearInterval(voteInterval);
|
|
}
|
|
|
|
function voteEnd() {
|
|
clearInterval(voteInterval);
|
|
elements.voteResetPanel.style.display = 'none';
|
|
}
|
|
|
|
function turnIntervalCb() {
|
|
turnTimer--;
|
|
setTurnStatus();
|
|
}
|
|
|
|
function setTurnStatus() {
|
|
if (turn === 0) elements.turnstatus.innerText = TheI18n.GetString(I18nStringKey.kVM_TurnTimeTimer, turnTimer);
|
|
else elements.turnstatus.innerText = TheI18n.GetString(I18nStringKey.kVM_WaitingTurnTimer, turnTimer);
|
|
}
|
|
|
|
function sendChat() {
|
|
if (VM === null) return;
|
|
if (elements.xssCheckbox.checked) VM.xss(elements.chatinput.value);
|
|
else VM.chat(elements.chatinput.value);
|
|
elements.chatinput.value = '';
|
|
}
|
|
|
|
// Bind list buttons
|
|
elements.homeBtn.addEventListener('click', () => closeVM());
|
|
|
|
// Bind VM view buttons
|
|
elements.sendChatBtn.addEventListener('click', sendChat);
|
|
elements.chatinput.addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') sendChat();
|
|
});
|
|
elements.changeUsernameBtn.addEventListener('click', () => {
|
|
let newname = prompt(TheI18n.GetString(I18nStringKey.kVMPrompts_EnterNewUsernamePrompt), w.username);
|
|
if (newname === w.username) return;
|
|
VM?.rename(newname);
|
|
});
|
|
elements.takeTurnBtn.addEventListener('click', () => {
|
|
VM?.turn(turn === -1);
|
|
});
|
|
elements.screenshotButton.addEventListener('click', () => {
|
|
if (!VM) return;
|
|
VM.canvas.toBlob((blob) => {
|
|
open(URL.createObjectURL(blob!), '_blank');
|
|
});
|
|
});
|
|
elements.ctrlAltDelBtn.addEventListener('click', () => {
|
|
if (!VM) return;
|
|
// Ctrl
|
|
VM?.key(0xffe3, true);
|
|
// Alt
|
|
VM?.key(0xffe9, true);
|
|
// Del
|
|
VM?.key(0xffff, true);
|
|
// Ctrl
|
|
VM?.key(0xffe3, false);
|
|
// Alt
|
|
VM?.key(0xffe9, false);
|
|
// Del
|
|
VM?.key(0xffff, false);
|
|
});
|
|
elements.voteResetButton.addEventListener('click', () => VM?.vote(true));
|
|
elements.voteYesBtn.addEventListener('click', () => VM?.vote(true));
|
|
elements.voteNoBtn.addEventListener('click', () => VM?.vote(false));
|
|
// Login
|
|
let usernameClick = false;
|
|
const loginModal = new bootstrap.Modal(elements.loginModal);
|
|
elements.loginModal.addEventListener('shown.bs.modal', () => elements.adminPassword.focus());
|
|
elements.username.addEventListener('click', () => {
|
|
if (!usernameClick) {
|
|
usernameClick = true;
|
|
setInterval(() => (usernameClick = false), 1000);
|
|
return;
|
|
}
|
|
loginModal.show();
|
|
});
|
|
elements.loginButton.addEventListener('click', () => doLogin());
|
|
elements.adminPassword.addEventListener('keypress', (e) => e.key === 'Enter' && doLogin());
|
|
elements.incorrectPasswordDismissBtn.addEventListener('click', () => (elements.badPasswordAlert.style.display = 'none'));
|
|
function doLogin() {
|
|
let adminPass = elements.adminPassword.value;
|
|
if (adminPass === '') return;
|
|
VM?.login(adminPass);
|
|
elements.adminPassword.value = '';
|
|
let u = VM?.on('login', () => {
|
|
u!();
|
|
loginModal.hide();
|
|
elements.badPasswordAlert.style.display = 'none';
|
|
});
|
|
let _u = VM?.on('badpw', () => {
|
|
_u!();
|
|
elements.badPasswordAlert.style.display = 'block';
|
|
});
|
|
}
|
|
|
|
function onLogin(_rank: Rank, _perms: Permissions) {
|
|
rank = _rank;
|
|
perms = _perms;
|
|
elements.username.classList.remove('text-dark', 'text-light');
|
|
if (rank === Rank.Admin) elements.username.classList.add('username-admin');
|
|
if (rank === Rank.Moderator) elements.username.classList.add('username-moderator');
|
|
elements.staffbtns.style.display = 'block';
|
|
if (_perms.restore) elements.restoreBtn.style.display = 'inline-block';
|
|
if (_perms.reboot) elements.rebootBtn.style.display = 'inline-block';
|
|
if (_perms.bypassturn) {
|
|
elements.bypassTurnBtn.style.display = 'inline-block';
|
|
elements.endTurnBtn.style.display = 'inline-block';
|
|
elements.clearQueueBtn.style.display = 'inline-block';
|
|
}
|
|
if (_rank === Rank.Admin) {
|
|
elements.qemuMonitorBtn.style.display = 'inline-block';
|
|
elements.indefTurnBtn.style.display = 'inline-block';
|
|
}
|
|
if (_perms.xss) elements.xssCheckboxContainer.style.display = 'inline-block';
|
|
if (_perms.forcevote) elements.forceVotePanel.style.display = 'block';
|
|
for (const user of users) userModOptions(user);
|
|
}
|
|
|
|
function userModOptions(user: { user: User; element: HTMLTableRowElement }) {
|
|
let tr = user.element;
|
|
let td = tr.children[0] as HTMLTableCellElement;
|
|
tr.classList.add('dropdown');
|
|
td.classList.add('dropdown-toggle');
|
|
td.setAttribute('data-bs-toggle', 'dropdown');
|
|
td.setAttribute('role', 'button');
|
|
td.setAttribute('aria-expanded', 'false');
|
|
let ul = document.createElement('ul');
|
|
ul.classList.add('dropdown-menu', 'dropdown-menu-dark', 'table-dark', 'text-light');
|
|
if (perms.bypassturn) addUserDropdownItem(ul, TheI18n.GetString(I18nStringKey.kVMButtons_EndTurn), () => VM!.endTurn(user.user.username));
|
|
if (perms.ban) addUserDropdownItem(ul, 'Ban', () => VM!.ban(user.user.username));
|
|
if (perms.kick) addUserDropdownItem(ul, 'Kick', () => VM!.kick(user.user.username));
|
|
if (perms.rename)
|
|
addUserDropdownItem(ul, 'Rename', () => {
|
|
let newname = prompt(`Enter new username for ${user.user.username}`);
|
|
if (!newname) return;
|
|
VM!.renameUser(user.user.username, newname);
|
|
});
|
|
if (perms.mute) {
|
|
addUserDropdownItem(ul, 'Temporary Mute', () => VM!.mute(user.user.username, MuteState.Temp));
|
|
addUserDropdownItem(ul, 'Indefinite Mute', () => VM!.mute(user.user.username, MuteState.Perma));
|
|
addUserDropdownItem(ul, 'Unmute', () => VM!.mute(user.user.username, MuteState.Unmuted));
|
|
}
|
|
if (perms.grabip)
|
|
addUserDropdownItem(ul, 'Get IP', async () => {
|
|
let ip = await VM!.getip(user.user.username);
|
|
alert(ip);
|
|
});
|
|
tr.appendChild(ul);
|
|
}
|
|
|
|
function addUserDropdownItem(ul: HTMLUListElement, text: string, func: () => void) {
|
|
let li = document.createElement('li');
|
|
let a = document.createElement('a');
|
|
a.href = '#';
|
|
a.classList.add('dropdown-item');
|
|
a.innerHTML = text;
|
|
a.addEventListener('click', () => func());
|
|
li.appendChild(a);
|
|
ul.appendChild(li);
|
|
}
|
|
|
|
// Admin buttons
|
|
elements.restoreBtn.addEventListener('click', () => window.confirm('Are you sure you want to restore the VM?') && VM?.restore());
|
|
elements.rebootBtn.addEventListener('click', () => VM?.reboot());
|
|
elements.clearQueueBtn.addEventListener('click', () => VM?.clearQueue());
|
|
elements.bypassTurnBtn.addEventListener('click', () => VM?.bypassTurn());
|
|
elements.endTurnBtn.addEventListener('click', () => {
|
|
let user = VM?.getUsers().find((u) => u.turn === 0);
|
|
if (user) VM?.endTurn(user.username);
|
|
});
|
|
elements.forceVoteNoBtn.addEventListener('click', () => VM?.forceVote(false));
|
|
elements.forceVoteYesBtn.addEventListener('click', () => VM?.forceVote(true));
|
|
elements.indefTurnBtn.addEventListener('click', () => VM?.indefiniteTurn());
|
|
|
|
async function sendQEMUCommand() {
|
|
if (!elements.qemuMonitorInput.value) return;
|
|
let cmd = elements.qemuMonitorInput.value;
|
|
elements.qemuMonitorOutput.innerHTML += `> ${cmd}\n`;
|
|
elements.qemuMonitorInput.value = '';
|
|
let response = await VM?.qemuMonitor(cmd);
|
|
elements.qemuMonitorOutput.innerHTML += `${response}\n`;
|
|
elements.qemuMonitorOutput.scrollTop = elements.qemuMonitorOutput.scrollHeight;
|
|
}
|
|
elements.qemuMonitorSendBtn.addEventListener('click', () => sendQEMUCommand());
|
|
elements.qemuMonitorInput.addEventListener('keypress', (e) => e.key === 'Enter' && sendQEMUCommand());
|
|
|
|
elements.osk.addEventListener('click', () => elements.oskContainer.classList.toggle('d-none'));
|
|
|
|
// Public API
|
|
w.collabvm = {
|
|
openVM: openVM,
|
|
closeVM: closeVM,
|
|
loadList: loadList,
|
|
multicollab: multicollab,
|
|
getVM: () => VM
|
|
};
|
|
// Multicollab will stay in the global scope for backwards compatibility
|
|
w.multicollab = multicollab;
|
|
// Same goes for GetAdmin
|
|
w.GetAdmin = () => {
|
|
if (VM === null) return;
|
|
return {
|
|
adminInstruction: (...args: string[]) => {
|
|
args.unshift('admin');
|
|
VM?.send(...args);
|
|
},
|
|
restore: () => VM!.restore(),
|
|
reboot: () => VM!.reboot(),
|
|
clearQueue: () => VM!.clearQueue(),
|
|
bypassTurn: () => VM!.bypassTurn(),
|
|
endTurn: (username: string) => VM!.endTurn(username),
|
|
ban: (username: string) => VM!.ban(username),
|
|
kick: (username: string) => VM!.kick(username),
|
|
renameUser: (oldname: string, newname: string) => VM!.renameUser(oldname, newname),
|
|
mute: (username: string, state: number) => VM!.mute(username, state),
|
|
getip: (username: string) => VM!.getip(username),
|
|
qemuMonitor: (cmd: string) => {
|
|
VM?.qemuMonitor(cmd);
|
|
return;
|
|
},
|
|
globalXss: (msg: string) => VM!.xss(msg),
|
|
forceVote: (result: boolean) => VM!.forceVote(result)
|
|
};
|
|
};
|
|
// more backwards compatibility
|
|
w.cvmEvents = {
|
|
on: (event: string | number, cb: (...args: any) => void) => {
|
|
if (VM === null) return;
|
|
VM.on('message', (...args: any) => cb(...args));
|
|
}
|
|
};
|
|
w.VMName = null;
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
// Initalize the i18n system
|
|
await TheI18n.Init();
|
|
|
|
// Load all VMs
|
|
await loadList();
|
|
|
|
// Welcome modal
|
|
let noWelcomeModal = window.localStorage.getItem('no-welcome-modal');
|
|
if (noWelcomeModal !== '1') {
|
|
let welcomeModalDismissBtn = document.getElementById('welcomeModalDismiss') as HTMLButtonElement;
|
|
let welcomeModal = new bootstrap.Modal(document.getElementById('welcomeModal') as HTMLDivElement);
|
|
welcomeModalDismissBtn.addEventListener('click', () => {
|
|
window.localStorage.setItem('no-welcome-modal', '1');
|
|
});
|
|
welcomeModalDismissBtn.disabled = true;
|
|
welcomeModal.show();
|
|
setTimeout(() => {
|
|
welcomeModalDismissBtn.disabled = false;
|
|
}, 5000);
|
|
}
|
|
});
|