More i18n related updates

- 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)
This commit is contained in:
modeco80 2024-03-15 04:01:40 -04:00
parent 3a7e590797
commit 0cef7194ce
13 changed files with 3899 additions and 177 deletions

View file

@ -2,17 +2,38 @@
![CollabVM Web App](/webapp.png) ![CollabVM Web App](/webapp.png)
The CollabVM Web App is the viewer for the CollabVM Server, currently in beta The CollabVM Web App is the viewer for the CollabVM Server.
## Building ## Building
Edit Config.ts to your needs, then:
1. `npm i`
2. `npm run build`
The build output directory is `dist/` Edit Config.ts to your needs, then:
## yarn
- `yarn`
- `yarn build`
## npm
- `npm i`
- `npm run build`
The build output directory is `dist/`.
## Unit testing
This is very minimal and only tests a single standalone part at the moment:
- `yarn test`
## Serving ## Serving
Just drop the contents of `dist/` somewhere into our webroot. For testing purposes, you can throw up a quick test webserver with the following command Just drop the contents of `dist/` somewhere into your webroot.
For **testing or development purposes only**, you can throw up a quick test webserver with the following command:
## yarn
`yarn serve`
## npm
`npm run serve` `npm run serve`

5
jest.config.js Normal file
View file

@ -0,0 +1,5 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};

View file

@ -6,6 +6,7 @@
"scripts": { "scripts": {
"build": "parcel build --no-source-maps --dist-dir dist --public-url '.' src/html/index.html", "build": "parcel build --no-source-maps --dist-dir dist --public-url '.' src/html/index.html",
"serve": "parcel src/html/index.html", "serve": "parcel src/html/index.html",
"test": "jest",
"clean": "run-script-os", "clean": "run-script-os",
"clean:darwin:linux": "rm -rf dist .parcel-cache", "clean:darwin:linux": "rm -rf dist .parcel-cache",
"clean:win32": "rd /s /q dist .parcel-cache" "clean:win32": "rd /s /q dist .parcel-cache"
@ -24,10 +25,13 @@
}, },
"devDependencies": { "devDependencies": {
"@types/bootstrap": "^5.2.10", "@types/bootstrap": "^5.2.10",
"@types/jest": "^29.5.12",
"jest": "^29.7.0",
"parcel": "^2.11.0", "parcel": "^2.11.0",
"parcel-reporter-static-files-copy": "^1.5.3", "parcel-reporter-static-files-copy": "^1.5.3",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"run-script-os": "^1.1.6", "run-script-os": "^1.1.6",
"ts-jest": "^29.1.2",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
} }

View file

@ -166,7 +166,7 @@
<button class="btn btn-secondary" id="rebootBtn"><i class="fa-solid fa-power-off"></i> Reboot</button> <button class="btn btn-secondary" id="rebootBtn"><i class="fa-solid fa-power-off"></i> Reboot</button>
<button class="btn btn-secondary" id="clearQueueBtn"><i class="fa-solid fa-eraser"></i> Clear Turn Queue</button> <button class="btn btn-secondary" id="clearQueueBtn"><i class="fa-solid fa-eraser"></i> Clear Turn Queue</button>
<button class="btn btn-secondary" id="bypassTurnBtn"><i class="fa-solid fa-forward"></i> Bypass Turn</button> <button class="btn btn-secondary" id="bypassTurnBtn"><i class="fa-solid fa-forward"></i> Bypass Turn</button>
<button class="btn btn-secondary" id="endTurnBtn"><i class="fa-solid fa-ban"></i> End Turn</button> <button class="btn btn-secondary" id="endTurnBtn"><i class="fa-solid fa-ban"></i> <span id="endTurnBtnText"></span></button>
<button class="btn btn-secondary" id="indefTurnBtn"><i class="fa-solid fa-infinity"></i> Indefinite Turn</button> <button class="btn btn-secondary" id="indefTurnBtn"><i class="fa-solid fa-infinity"></i> Indefinite Turn</button>
<button class="btn btn-secondary" id="qemuMonitorBtn" data-bs-toggle="modal" data-bs-target="#qemuMonitorModal"><i class="fa-solid fa-terminal"></i> QEMU Monitor</button> <button class="btn btn-secondary" id="qemuMonitorBtn" data-bs-toggle="modal" data-bs-target="#qemuMonitorModal"><i class="fa-solid fa-terminal"></i> QEMU Monitor</button>
</div> </div>

72
src/ts/format.ts Normal file
View file

@ -0,0 +1,72 @@
import { StringLike } from './StringLike';
/// A simple function for formatting strings in a more expressive manner.
/// While JavaScript *does* have string interpolation, it's not a total replacement
/// for just formatting strings, and a method like this is better for data independent formatting.
///
/// ## Example usage
///
/// ```typescript
/// let hello = Format("Hello, {0}!", "World");
/// ```
export function Format(pattern: string, ...args: Array<StringLike>) {
let argumentsAsStrings: Array<string> = [...args].map((el) => {
// This catches cases where the thing already is a string
if (typeof el == 'string') return el as string;
return el.toString();
});
let pat = pattern;
// Handle pattern ("{0} {1} {2} {3} {4} {5}") syntax if found
for (let i = 0; i < pat.length; ++i) {
if (pat[i] == '{') {
let replacementStart = i;
let foundSpecifierEnd = false;
// Make sure the specifier is not cut off (the last character of the string)
if (i + 3 >= pat.length) {
throw new Error(`Error in format pattern "${pat}": Cutoff/invalid format specifier`);
}
// Try and find the specifier end ('}').
// Whitespace and a '{' are considered errors.
for (let j = i + 1; j < pat.length; ++j) {
switch (pat[j]) {
case '}':
foundSpecifierEnd = true;
i = j;
break;
case '{':
throw new Error(`Error in format pattern "${pat}": Cannot start a format specifier in an existing replacement`);
break;
case ' ':
throw new Error(`Error in format pattern "${pat}": Whitespace inside format specifier`);
break;
default:
break;
}
if (foundSpecifierEnd) break;
}
if (!foundSpecifierEnd) throw new Error(`Error in format pattern "${pat}": No terminating "}" character found`);
// Get the beginning and trailer
let beginning = pat.substring(0, replacementStart);
let trailer = pat.substring(replacementStart + 3);
let argumentIndex = parseInt(pat.substring(replacementStart + 1, i));
if (Number.isNaN(argumentIndex) || argumentIndex > argumentsAsStrings.length) throw new Error(`Error in format pattern "${pat}": Argument index out of bounds`);
// This is seriously the only decent way to do this in javascript
// thanks brendan eich (replace this thanking with more choice words in your head)
pat = beginning + argumentsAsStrings[argumentIndex] + trailer;
}
}
return pat;
}

View file

@ -1,33 +1,55 @@
import { StringLike } from './StringLike'; import { StringLike } from './StringLike';
import { Format } from './format';
/// All string keys. /// All string keys.
export enum I18nStringKey { export enum I18nStringKey {
kSiteName = 'kSiteName', // Generic things
kHomeButton = 'kHomeButton', kGeneric_CollabVM = 'kGeneric_CollabVM',
kFAQButton = 'kFAQButton', kGeneric_Yes = 'kGeneric_Yes',
kRulesButton = 'kRulesButton', kGeneric_No = 'kGeneric_No',
kVMResetTitle = 'kVMResetTitle', kGeneric_Ok = 'kGeneric_Ok',
kGenericYes = 'kGenericYes', kGeneric_Cancel = 'kGeneric_Cancel',
kGenericNo = 'kGenericNo',
kVMVoteTime = 'kVMVoteTime', kSiteButtons_Home = 'kSiteButtons_Home',
kPassVoteButton = 'kPassVoteButton', kSiteButtons_FAQ = 'kSiteButtons_FAQ',
kCancelVoteButton = 'kCancelVoteButton', kSiteButtons_Rules = 'kSiteButtons_Rules',
kTakeTurnButton = 'kTakeTurnButton',
kEndTurnButton = 'kEndTurnButton', kVM_UsersOnlineText = 'kVM_UsersOnlineText',
kChangeUsernameButton = 'kChangeUsernameButton',
kVoteButton = 'kVoteButton', kVM_TurnTimeTimer = 'kVM_TurnTimeTimer',
kScreenshotButton = 'kScreenshotButton', kVM_WaitingTurnTimer = 'kVM_WaitingTurnTimer',
kUsersOnlineHeading = 'kUsersOnlineHeading', kVM_VoteCooldownTimer = 'kVM_VoteCooldownTimer',
kTurnTime = 'kTurnTime',
kWaitingTurnTime = 'kWaitingTurnTime', kVM_VoteForResetTitle = 'kVM_VoteForResetTitle',
kVoteCooldown = 'kVoteCooldown', kVM_VoteForResetTimer = 'kVM_VoteForResetTimer',
kEnterNewUsername = 'kEnterNewUsername'
kVMButtons_TakeTurn = 'kVMButtons_TakeTurn',
kVMButtons_EndTurn = 'kVMButtons_EndTurn',
kVMButtons_ChangeUsername = 'kVMButtons_ChangeUsername',
kVMButtons_VoteForReset = 'kVMButtons_VoteForReset',
kVMButtons_Screenshot = 'kVMButtons_Screenshot',
// Admin VM buttons
kAdminVMButtons_PassVote = 'kAdminVMButtons_PassVote',
kAdminVMButtons_CancelVote = 'kAdminVMButtons_CancelVote',
// prompts
kVMPrompts_EnterNewUsernamePrompt = 'kVMPrompts_EnterNewUsernamePrompt',
// error messages
kError_UnexpectedDisconnection = 'kError_UnexpectedDisconnection',
kError_UsernameTaken = 'kError_UsernameTaken',
kError_UsernameInvalid = 'kError_UsernameInvalid',
kError_UsernameBlacklisted = 'kError_UsernameBlacklisted',
} }
// This models the JSON structure. // This models the JSON structure.
export type Language = { type Language = {
languageName: string; languageName: string;
translatedLanguageName: string; translatedLanguageName: string;
flag: string; // country flag, can be blank if not applicable. will be displayed in language dropdown
author: string; author: string;
stringKeys: { stringKeys: {
@ -39,36 +61,61 @@ export type Language = {
}; };
}; };
// `languages.json`
type LanguagesJson = {
// Array of language IDs to allow loading
languages: Array<string>;
// The default language (set if a invalid language not in the languages array is set, or no language is set)
defaultLanguage: string;
}
// ID for fallback language // ID for fallback language
const fallbackId = 'fallback'; const fallbackId = '!!fallback';
// This language is provided in the webapp itself just in case language stuff fails // This language is provided in the webapp itself just in case language stuff fails
const fallbackLanguage: Language = { const fallbackLanguage: Language = {
languageName: 'Fallback', languageName: 'Fallback',
translatedLanguageName: 'Fallback', translatedLanguageName: 'Fallback',
flag: "no",
author: 'Computernewb', author: 'Computernewb',
stringKeys: { stringKeys: {
kSiteName: 'CollabVM', kGeneric_CollabVM: 'CollabVM',
kHomeButton: 'Home', kGeneric_Yes: 'Yes',
kFAQButton: 'FAQ', kGeneric_No: 'No',
kRulesButton: 'Rules', kGeneric_Ok: 'OK',
kVMResetTitle: 'Do you want to reset the VM?', kGeneric_Cancel: 'Cancel',
kGenericYes: 'Yes',
kGenericNo: 'No', kSiteButtons_Home: 'Home',
kVMVoteTime: 'Vote ends in {0} seconds', kSiteButtons_FAQ: 'FAQ',
kPassVoteButton: 'Pass Vote', kSiteButtons_Rules: 'Rules',
kCancelVoteButton: 'Cancel Vote',
kTakeTurnButton: 'Take Turn', kVM_UsersOnlineText: 'Users Online:',
kEndTurnButton: 'End Turn',
kChangeUsernameButton: 'Change Username', kVM_TurnTimeTimer: 'Turn expires in {0} seconds.',
kVoteButton: 'Vote For Reset', kVM_WaitingTurnTimer: 'Waiting for turn in {0} seconds.',
kScreenshotButton: 'Screenshot', kVM_VoteCooldownTimer: 'Please wait {0} seconds before starting another vote.',
kUsersOnlineHeading: 'Users Online:',
kTurnTime: 'Turn expires in {0} seconds.', kVM_VoteForResetTitle: 'Do you want to reset the VM?',
kWaitingTurnTime: 'Waiting for turn in {0} seconds.', kVM_VoteForResetTimer: 'Vote ends in {0} seconds',
kVoteCooldown: 'Please wait {0} seconds before starting another vote.',
kEnterNewUsername: 'Enter a new username, or leave the field blank to be assigned a guest username' kVMButtons_TakeTurn: 'Take Turn',
kVMButtons_EndTurn: 'End Turn',
kVMButtons_ChangeUsername: 'Change Username',
kVMButtons_VoteForReset: 'Vote For Reset',
kVMButtons_Screenshot: 'Screenshot',
kAdminVMButtons_PassVoteButton: 'Pass Vote',
kAdminVMButtons_CancelVoteButton: 'Cancel Vote',
kVMPrompts_EnterNewUsernamePrompt: 'Enter a new username, or leave the field blank to be assigned a guest username',
kError_UnexpectedDisconnection: 'You have been disconnected from the server.',
kError_UsernameTaken: 'That username is already taken',
kError_UsernameInvalid: 'Usernames can contain only numbers, letters, spaces, dashes, underscores, and dots, and it must be between 3 and 20 characters.',
kError_UsernameBlacklisted: 'That username has been blacklisted.'
} }
}; };
@ -106,6 +153,8 @@ export class I18n {
} }
async Init() { async Init() {
// TODO: load languages.json, add selections, and if an invalid language (not in the languages array) is specified,
// set it to the defaultLanguage in there.
let lang = window.localStorage.getItem('i18n-lang'); let lang = window.localStorage.getItem('i18n-lang');
// Set a default language if not specified // Set a default language if not specified
@ -146,24 +195,25 @@ export class I18n {
// Replaces static strings that we don't recompute // Replaces static strings that we don't recompute
private ReplaceStaticStrings() { private ReplaceStaticStrings() {
const kDomIdtoStringMap: StringKeyMap = { const kDomIdtoStringMap: StringKeyMap = {
siteNameText: I18nStringKey.kSiteName, siteNameText: I18nStringKey.kGeneric_CollabVM,
homeBtnText: I18nStringKey.kHomeButton, homeBtnText: I18nStringKey.kSiteButtons_Home,
faqBtnText: I18nStringKey.kFAQButton, faqBtnText: I18nStringKey.kSiteButtons_FAQ,
rulesBtnText: I18nStringKey.kRulesButton, rulesBtnText: I18nStringKey.kSiteButtons_Rules,
usersOnlineText: I18nStringKey.kUsersOnlineHeading, usersOnlineText: I18nStringKey.kVM_UsersOnlineText,
voteResetHeaderText: I18nStringKey.kVMResetTitle, voteResetHeaderText: I18nStringKey.kVM_VoteForResetTitle,
voteYesBtnText: I18nStringKey.kGenericYes, voteYesBtnText: I18nStringKey.kGeneric_Yes,
voteNoBtnText: I18nStringKey.kGenericNo, voteNoBtnText: I18nStringKey.kGeneric_No,
changeUsernameBtnText: I18nStringKey.kChangeUsernameButton, changeUsernameBtnText: I18nStringKey.kVMButtons_ChangeUsername,
voteForResetBtnText: I18nStringKey.kVoteButton, voteForResetBtnText: I18nStringKey.kVMButtons_VoteForReset,
screenshotBtnText: I18nStringKey.kScreenshotButton, screenshotBtnText: I18nStringKey.kVMButtons_Screenshot,
// admin stuff // admin stuff
passVoteBtnText: I18nStringKey.kPassVoteButton, passVoteBtnText: I18nStringKey.kAdminVMButtons_PassVote,
cancelVoteBtnText: I18nStringKey.kCancelVoteButton cancelVoteBtnText: I18nStringKey.kAdminVMButtons_CancelVote,
endTurnBtnText: I18nStringKey.kVMButtons_EndTurn
}; };
for (let domId of Object.keys(kDomIdtoStringMap)) { for (let domId of Object.keys(kDomIdtoStringMap)) {
@ -174,88 +224,33 @@ export class I18n {
} }
// Do the magic. // Do the magic.
element.innerText = this.GetString(kDomIdtoStringMap[domId]); // N.B: For now, we assume all strings in this map are not formatted.
// If this assumption changes, then we should just use GetString() again
// and maybe include arguments, but for now this is okay
element.innerText = this.GetStringRaw(kDomIdtoStringMap[domId]);
} }
} }
// Gets a string, which also allows replacing by index with the given replacements. // Returns a (raw, unformatted) string. Currently only used if we don't need formatting.
GetString(key: I18nStringKey, ...replacements: StringLike[]): string { GetStringRaw(key: I18nStringKey): string {
let replacementStringArray: Array<string> = [...replacements].map((el) => {
// This catches cases where the thing already is a string
if (typeof el == 'string') return el as string;
return el.toString();
});
let val = this.lang.stringKeys[key]; let val = this.lang.stringKeys[key];
// Helper to throw a more descriptive error (including the looked-up string in question)
let throwError = (desc: string) => {
throw new Error(`Invalid replacement "${val}": ${desc}`);
};
// Look up the fallback language by default if the language doesn't // Look up the fallback language by default if the language doesn't
// have that string key yet; if the fallback doesn't have it either, // have that string key yet; if the fallback doesn't have it either,
// then just return the string key and a bit of a notice things have gone wrong // then just return the string key and a bit of a notice things have gone wrong
if (val == undefined) { if (val == undefined) {
let fallback = fallbackLanguage.stringKeys[key]; let fallback = fallbackLanguage.stringKeys[key];
if (fallback !== undefined) val = fallback; if (fallback !== undefined) val = fallback;
else return `${key} (ERROR)`; else return `${key} (ERROR LOOKING UP TRANSLATION!!!)`;
}
// Handle replacement ("{0} {1} {2} {3} {4} {5}" syntax) in string keys
// which allows us to just specify arguments we want to format into the final string,
// instead of hacky replacements hardcoded at the source. It's more flexible that way.
for (let i = 0; i < val.length; ++i) {
if (val[i] == '{') {
let replacementStart = i;
let foundReplacementEnd = false;
// Make sure the replacement is not cut off (the last character of the string)
if (i + 1 > val.length) {
throwError('Cutoff/invalid replacement');
}
// Try and find the replacement end ('}').
// Whitespace and a '{' are considered errors.
for (let j = i + 1; j < val.length; ++j) {
switch (val[j]) {
case '}':
foundReplacementEnd = true;
i = j;
break;
case '{':
throwError('Cannot start a replacement in an existing replacement');
break;
case ' ':
throwError('Whitespace inside replacement');
break;
default:
break;
}
if (foundReplacementEnd) break;
}
if (!foundReplacementEnd) throwError('No terminating "}" character found');
// Get the beginning and trailer
let beginning = val.substring(0, replacementStart);
let trailer = val.substring(replacementStart + 3);
let replacementIndex = parseInt(val.substring(replacementStart + 1, i));
if (Number.isNaN(replacementIndex) || replacementIndex > replacementStringArray.length) throwError('Replacement index out of bounds');
// This is seriously the only decent way to do this in javascript
// thanks brendan eich (replace this thanking with more choice words in your head)
val = beginning + replacementStringArray[replacementIndex] + trailer;
}
} }
return val; return val;
} }
// Returns a formatted localized string.
GetString(key: I18nStringKey, ...replacements: StringLike[]): string {
return Format(this.GetStringRaw(key), ...replacements);
}
} }
export let TheI18n = new I18n(); export let TheI18n = new I18n();

View file

@ -264,16 +264,21 @@ let VM: CollabVMClient | null = null;
async function multicollab(url: string) { async function multicollab(url: string) {
// Create the client // Create the client
let client = new CollabVMClient(url); let client = new CollabVMClient(url);
// Wait for the client to open
await new Promise<void>((res) => client.on('open', () => res())); await client.WaitForOpen();
// Get the list of VMs // Get the list of VMs
let list = await client.list(); let list = await client.list();
// Get the number of online users // Get the number of online users
let online = client.getUsers().length; let online = client.getUsers().length;
// Close the client // Close the client
client.close(); client.close();
// Add to the list // Add to the list
vms.push(...list); vms.push(...list);
// Add to the DOM // Add to the DOM
for (let vm of list) { for (let vm of list) {
let div = document.createElement('div'); let div = document.createElement('div');
@ -327,13 +332,13 @@ async function openVM(vm: VM): Promise<void> {
// TODO: i18n these // TODO: i18n these
switch (status) { switch (status) {
case 'taken': case 'taken':
alert('That username is already taken'); alert(TheI18n.GetString(I18nStringKey.kError_UsernameTaken));
break; break;
case 'invalid': case 'invalid':
alert('Usernames can contain only numbers, letters, spaces, dashes, underscores, and dots, and it must be between 3 and 20 characters.'); alert(TheI18n.GetString(I18nStringKey.kError_UsernameInvalid));
break; break;
case 'blacklisted': case 'blacklisted':
alert('That username has been blacklisted.'); alert(TheI18n.GetString(I18nStringKey.kError_UsernameBlacklisted));
break; break;
} }
}) })
@ -341,11 +346,11 @@ async function openVM(vm: VM): Promise<void> {
unsubscribeCallbacks.push(VM!.on('turn', (status) => turnUpdate(status))); unsubscribeCallbacks.push(VM!.on('turn', (status) => turnUpdate(status)));
unsubscribeCallbacks.push(VM!.on('vote', (status: VoteStatus) => voteUpdate(status))); unsubscribeCallbacks.push(VM!.on('vote', (status: VoteStatus) => voteUpdate(status)));
unsubscribeCallbacks.push(VM!.on('voteend', () => voteEnd())); unsubscribeCallbacks.push(VM!.on('voteend', () => voteEnd()));
unsubscribeCallbacks.push(VM!.on('votecd', (voteCooldown) => window.alert(TheI18n.GetString(I18nStringKey.kVoteCooldown, voteCooldown)))); 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('login', (rank: Rank, perms: Permissions) => onLogin(rank, perms)));
unsubscribeCallbacks.push( unsubscribeCallbacks.push(
VM!.on('close', () => { VM!.on('close', () => {
if (!expectedClose) alert('You have been disconnected from the server'); if (!expectedClose) alert(TheI18n.GetString(I18nStringKey.kError_UnexpectedDisconnection));
// Call all the unsubscribe callbacks. // Call all the unsubscribe callbacks.
for (let l of unsubscribeCallbacks) l(); for (let l of unsubscribeCallbacks) l();
@ -355,9 +360,7 @@ async function openVM(vm: VM): Promise<void> {
); );
// Wait for the client to open // Wait for the client to open
await new Promise<void>((res) => { await VM!.WaitForOpen();
unsubscribeCallbacks.push(VM!.on('open', () => res()));
});
// Connect to node // Connect to node
chatMessage('', `<b>${vm.id}</b><hr>`); chatMessage('', `<b>${vm.id}</b><hr>`);
@ -419,11 +422,11 @@ function closeVM() {
} }
async function loadList() { async function loadList() {
let p = []; await Promise.all(
for (let url of Config.ServerAddresses) { Config.ServerAddresses.map((url) => {
p.push(multicollab(url)); return multicollab(url);
} })
await Promise.all(p); );
// automatically join the vm that's in the url if it exists in the node list // 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)); let v = vms.find((v) => v.id === window.location.hash.substring(1));
@ -556,7 +559,7 @@ function turnUpdate(status: TurnStatus) {
user.element.classList.remove('user-turn', 'user-waiting'); user.element.classList.remove('user-turn', 'user-waiting');
user.element.setAttribute('data-cvm-turn', '-1'); user.element.setAttribute('data-cvm-turn', '-1');
} }
elements.turnBtnText.innerHTML = TheI18n.GetString(I18nStringKey.kTakeTurnButton); elements.turnBtnText.innerHTML = TheI18n.GetString(I18nStringKey.kVMButtons_TakeTurn);
enableOSK(false); enableOSK(false);
if (status.user !== null) { if (status.user !== null) {
@ -572,14 +575,14 @@ function turnUpdate(status: TurnStatus) {
if (status.user?.username === w.username) { if (status.user?.username === w.username) {
turn = 0; turn = 0;
turnTimer = status.turnTime! / 1000; turnTimer = status.turnTime! / 1000;
elements.turnBtnText.innerHTML = TheI18n.GetString(I18nStringKey.kEndTurnButton); elements.turnBtnText.innerHTML = TheI18n.GetString(I18nStringKey.kVMButtons_EndTurn);
VM!.canvas.classList.add('focused'); VM!.canvas.classList.add('focused');
enableOSK(true); enableOSK(true);
} }
if (status.queue.some((u) => u.username === w.username)) { if (status.queue.some((u) => u.username === w.username)) {
turn = status.queue.findIndex((u) => u.username === w.username) + 1; turn = status.queue.findIndex((u) => u.username === w.username) + 1;
turnTimer = status.queueTime! / 1000; turnTimer = status.queueTime! / 1000;
elements.turnBtnText.innerHTML = TheI18n.GetString(I18nStringKey.kEndTurnButton); elements.turnBtnText.innerHTML = TheI18n.GetString(I18nStringKey.kVMButtons_EndTurn);
VM!.canvas.classList.add('waiting'); VM!.canvas.classList.add('waiting');
} }
if (turn === -1) elements.turnstatus.innerText = ''; if (turn === -1) elements.turnstatus.innerText = '';
@ -602,7 +605,7 @@ function voteUpdate(status: VoteStatus) {
function updateVoteEndTime() { function updateVoteEndTime() {
voteTimer--; voteTimer--;
elements.voteTimeText.innerText = TheI18n.GetString(I18nStringKey.kVMVoteTime, voteTimer); elements.voteTimeText.innerText = TheI18n.GetString(I18nStringKey.kVM_VoteForResetTimer, voteTimer);
if (voteTimer === 0) clearInterval(voteInterval); if (voteTimer === 0) clearInterval(voteInterval);
} }
@ -617,8 +620,8 @@ function turnIntervalCb() {
} }
function setTurnStatus() { function setTurnStatus() {
if (turn === 0) elements.turnstatus.innerText = TheI18n.GetString(I18nStringKey.kTurnTime, turnTimer); if (turn === 0) elements.turnstatus.innerText = TheI18n.GetString(I18nStringKey.kVM_TurnTimeTimer, turnTimer);
else elements.turnstatus.innerText = TheI18n.GetString(I18nStringKey.kWaitingTurnTime, turnTimer); else elements.turnstatus.innerText = TheI18n.GetString(I18nStringKey.kVM_WaitingTurnTimer, turnTimer);
} }
function sendChat() { function sendChat() {
@ -637,7 +640,7 @@ elements.chatinput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') sendChat(); if (e.key === 'Enter') sendChat();
}); });
elements.changeUsernameBtn.addEventListener('click', () => { elements.changeUsernameBtn.addEventListener('click', () => {
let newname = prompt(TheI18n.GetString(I18nStringKey.kEnterNewUsername), w.username); let newname = prompt(TheI18n.GetString(I18nStringKey.kVMPrompts_EnterNewUsernamePrompt), w.username);
if (newname === w.username) return; if (newname === w.username) return;
VM?.rename(newname); VM?.rename(newname);
}); });
@ -732,7 +735,7 @@ function userModOptions(user: { user: User; element: HTMLTableRowElement }) {
td.setAttribute('aria-expanded', 'false'); td.setAttribute('aria-expanded', 'false');
let ul = document.createElement('ul'); let ul = document.createElement('ul');
ul.classList.add('dropdown-menu', 'dropdown-menu-dark', 'table-dark', 'text-light'); ul.classList.add('dropdown-menu', 'dropdown-menu-dark', 'table-dark', 'text-light');
if (perms.bypassturn) addUserDropdownItem(ul, TheI18n.GetString(I18nStringKey.kEndTurnButton), () => VM!.endTurn(user.user.username)); 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.ban) addUserDropdownItem(ul, 'Ban', () => VM!.ban(user.user.username));
if (perms.kick) addUserDropdownItem(ul, 'Kick', () => VM!.kick(user.user.username)); if (perms.kick) addUserDropdownItem(ul, 'Kick', () => VM!.kick(user.user.username));
if (perms.rename) if (perms.rename)

View file

@ -11,7 +11,7 @@ import MuteState from './MuteState.js';
import { StringLike } from '../StringLike.js'; import { StringLike } from '../StringLike.js';
export interface CollabVMClientEvents { export interface CollabVMClientEvents {
open: () => void; //open: () => void;
close: () => void; close: () => void;
message: (...args: string[]) => void; message: (...args: string[]) => void;
@ -37,6 +37,7 @@ export interface CollabVMClientEvents {
// types for private emitter // types for private emitter
interface CollabVMClientPrivateEvents { interface CollabVMClientPrivateEvents {
open: () => void;
list: (listEntries: string[]) => void; list: (listEntries: string[]) => void;
connect: (connectedToVM: boolean) => void; connect: (connectedToVM: boolean) => void;
ip: (username: string, ip: string) => void; ip: (username: string, ip: string) => void;
@ -175,7 +176,7 @@ export default class CollabVMClient {
// Fires when the WebSocket connection is opened // Fires when the WebSocket connection is opened
private onOpen() { private onOpen() {
this.publicEmitter.emit('open'); this.internalEmitter.emit('open');
} }
// Fires on WebSocket message // Fires on WebSocket message
@ -373,6 +374,16 @@ export default class CollabVMClient {
} }
} }
async WaitForOpen() {
return new Promise<void>((res) => {
// TODO: should probably reject on close
let unsub = this.onInternal('open', () => {
unsub();
res();
});
});
}
// Sends a message to the server // Sends a message to the server
send(...args: StringLike[]) { send(...args: StringLike[]) {
let guacElements = [...args].map((el) => { let guacElements = [...args].map((el) => {

View file

@ -0,0 +1,23 @@
import { Format } from '../format';
test('a string without any format specifiers in it is unaltered', () => {
expect(Format("Hello World")).toBe("Hello World");
});
test('formatting a string works', () => {
expect(Format('Hello, {0}!', 'World')).toBe('Hello, World!');
});
test('a cut off format specifier throws', () => {
expect(() => Format('a{0', 1)).toThrow('Cutoff/invalid format specifier');
});
test('a malformed format specifier throws', () => {
expect(() => Format('a{0 }', 1)).toThrow('Whitespace inside format specifier');
expect(() => Format('a{ 0}', 1)).toThrow('Whitespace inside format specifier');
expect(() => Format('a{ 0 }', 1)).toThrow('Whitespace inside format specifier');
});
test("a OOB format specifier doesn't work", () => {
expect(() => Format('a {37}', 1)).toThrow('Argument index out of bounds');
});

View file

@ -1,28 +1,44 @@
{ {
"languageName": "English (US)", "languageName": "English (US)",
"translatedLanguageName": "English (US)", "translatedLanguageName": "English (US)",
"flag": "🇺🇸",
"author": "Computernewb", "author": "Computernewb",
"stringKeys": { "stringKeys": {
"kSiteName": "CollabVM", "kGeneric_CollabVM": "CollabVM",
"kHomeButton": "Home", "kGeneric_Yes": "Yes",
"kFAQButton": "FAQ", "kGeneric_No": "No",
"kRulesButton": "Rules", "kGeneric_Ok": "OK",
"kVMResetTitle": "Do you want to reset the VM?", "kGeneric_Cancel": "Cancel",
"kGenericYes": "Yes",
"kGenericNo": "No", "kSiteButtons_Home": "Home",
"kVMVoteTime": "Vote ends in {0} seconds", "kSiteButtons_FAQ": "FAQ",
"kPassVoteButton": "Pass Vote", "kSiteButtons_Rules": "Rules",
"kCancelVoteButton": "Cancel Vote",
"kTakeTurnButton": "Take Turn", "kVM_UsersOnlineText": "Users Online:",
"kEndTurnButton": "End Turn",
"kChangeUsernameButton": "Change Username", "kVM_TurnTimeTimer": "Turn expires in {0} seconds.",
"kVoteButton": "Vote For Reset", "kVM_WaitingTurnTimer": "Waiting for turn in {0} seconds.",
"kScreenshotButton": "Screenshot", "kVM_VoteCooldownTimer": "Please wait {0} seconds before starting another vote.",
"kUsersOnlineHeading": "Users Online:",
"kTurnTime": "Turn expires in {0} seconds.", "kVM_VoteForResetTitle": "Do you want to reset the VM?",
"kWaitingTurnTime": "Waiting for turn in {0} seconds.", "kVM_VoteForResetTimer": "Vote ends in {0} seconds",
"kVoteCooldown": "Please wait {0} seconds before starting another vote.",
"kEnterNewUsername": "Enter a new username, or leave the field blank to be assigned a guest username" "kVMButtons_TakeTurn": "Take Turn",
"kVMButtons_EndTurn": "End Turn",
"kVMButtons_ChangeUsername": "Change Username",
"kVMButtons_VoteForReset": "Vote For Reset",
"kVMButtons_Screenshot": "Screenshot",
"kAdminVMButtons_PassVoteButton": "Pass Vote",
"kAdminVMButtons_CancelVoteButton": "Cancel Vote",
"kVMPrompts_EnterNewUsernamePrompt": "Enter a new username, or leave the field blank to be assigned a guest username",
"kError_UnexpectedDisconnection": "You have been disconnected from the server.",
"kError_UsernameTaken": "That username is already taken",
"kError_UsernameInvalid": "Usernames can contain only numbers, letters, spaces, dashes, underscores, and dots, and it must be between 3 and 20 characters.",
"kError_UsernameBlacklisted": "That username has been blacklisted."
} }
} }

View file

@ -0,0 +1,4 @@
{
"languages": ["en-us", "pirate"],
"defaultLanguage": "en-us"
}

View file

@ -1,6 +1,7 @@
{ {
"languageName": "Pirate", "languageName": "Pirate",
"translatedLanguageName": "Arrrghh, matey!", "translatedLanguageName": "Arrrghh, matey!",
"flag": "🏴‍☠️",
"author": "Computernewb", "author": "Computernewb",
"stringKeys": { "stringKeys": {
@ -9,8 +10,8 @@
"kFAQButton": "Arrrgh, get your Map", "kFAQButton": "Arrrgh, get your Map",
"kRulesButton": "Arrrrghh, get the Etiquette Book", "kRulesButton": "Arrrrghh, get the Etiquette Book",
"kVMResetTitle": "Argh, matey, this VM is broken. Would you like to particpate in the VM War?", "kVMResetTitle": "Argh, matey, this VM is broken. Would you like to particpate in the VM War?",
"kGenericYes": "Arrrghh, yay!", "kGeneric_Yes": "Arrrghh, yay!",
"kGenericNo": "Arrrrgh, nay!", "kGeneric_No": "Arrrrgh, nay!",
"kVMVoteTime": "Argghh, the cannon fight ends in {0} seconds", "kVMVoteTime": "Argghh, the cannon fight ends in {0} seconds",
"kPassVoteButton": "Rig the vote", "kPassVoteButton": "Rig the vote",
"kCancelVoteButton": "Ignore the vote", "kCancelVoteButton": "Ignore the vote",
@ -23,6 +24,9 @@
"kTurnTime": "Arrrrgh, your wheel rights expire in {0} seconds.", "kTurnTime": "Arrrrgh, your wheel rights expire in {0} seconds.",
"kWaitingTurnTime": "Waiting for the wheel in {0} seconds.", "kWaitingTurnTime": "Waiting for the wheel in {0} seconds.",
"kVoteCooldown": "Arrgh matey, you need to wait {0} seconds to vote again.", "kVoteCooldown": "Arrgh matey, you need to wait {0} seconds to vote again.",
"kEnterNewUsername": "Arggghh, matey, what would you like to be known as?" "kEnterNewUsername": "Arggghh, matey, what would you like to be known as?",
"kError_UsernameTaken": "Sorry, matey, but another pirate is using that name.",
"kError_UsernameInvalid": "Sorry matey, but that name is invalid. Usernames can contain only numbers, letters, spaces, dashes, underscores, and dots, and it must be between 3 and 20 characters.",
"kError_UsernameBlacklisted": "Sorry matey, but you cannot use that name."
} }
} }

3564
yarn.lock Normal file

File diff suppressed because it is too large Load diff