diff --git a/src/ts/StringLike.ts b/src/ts/StringLike.ts
new file mode 100644
index 0000000..2ed941b
--- /dev/null
+++ b/src/ts/StringLike.ts
@@ -0,0 +1,9 @@
+
+// TODO: `Object` has a toString(), but we should probably gate that off
+/// Interface for things that can be turned into strings
+export interface ToStringable {
+ toString(): string;
+}
+
+/// A type for strings, or things that can (in a valid manner) be turned into strings
+export type StringLike = string | ToStringable;
\ No newline at end of file
diff --git a/src/ts/i18n.ts b/src/ts/i18n.ts
new file mode 100644
index 0000000..3ab74ab
--- /dev/null
+++ b/src/ts/i18n.ts
@@ -0,0 +1,209 @@
+import { StringLike } from './StringLike';
+
+// Nice little string key helper
+export enum I18nStringKey {
+ kSiteName = 'kSiteName',
+ kHomeButton = 'kHomeButton',
+ kFAQButton = 'kFAQButton',
+ kRulesButton = 'kRulesButton',
+ kVMResetTitle = 'kVMResetTitle',
+ kGenericYes = 'kGenericYes',
+ kGenericNo = 'kGenericNo',
+ kVMVoteTime = 'kVMVoteTime',
+ kPassVoteButton = 'kPassVoteButton',
+ kCancelVoteButton = 'kCancelVoteButton',
+ kTakeTurnButton = 'kTakeTurnButton',
+ kEndTurnButton = 'kEndTurnButton',
+ kChangeUsernameButton = 'kChangeUsernameButton',
+ kVoteButton = 'kVoteButton',
+ kScreenshotButton = 'kScreenshotButton',
+ kUsersOnlineHeading = 'kUsersOnlineHeading',
+ kTurnTime = 'kTurnTime',
+ kWaitingTurnTime = 'kWaitingTurnTime',
+ kVoteCooldown = 'kVoteCooldown',
+ kEnterNewUsername = 'kEnterNewUsername'
+}
+
+// This models the JSON structure.
+export type Language = {
+ languageName: string;
+ translatedLanguageName: string;
+ author: string;
+
+ stringKeys: {
+ // This is fancy typescript speak for
+ // "any string index returns a string",
+ // which is our expectation.
+ // See https://www.typescriptlang.org/docs/handbook/2/objects.html#index-signatures if this is confusing.
+ [key: string]: string;
+ };
+};
+
+// ID for fallback language
+const fallbackId = 'fallback';
+
+// This language is provided in the webapp itself just in case language stuff fails
+const fallbackLanguage: Language = {
+ languageName: 'Fallback',
+ translatedLanguageName: 'Fallback',
+ author: 'Computernewb',
+
+ stringKeys: {
+ kTitle: 'CollabVM',
+ kHomeButton: 'Home',
+ kFAQButton: 'FAQ',
+ kRulesButton: 'Rules',
+ kVMResetTitle: 'Do you want to reset the VM?',
+ kGenericYes: 'Yes',
+ kGenericNo: 'No',
+ kVMVoteTime: 'Vote ends in {0} seconds',
+ kPassVoteButton: 'Pass Vote',
+ kCancelVoteButton: 'Cancel Vote',
+ kTakeTurnButton: 'Take Turn',
+ kEndTurnButton: 'End Turn',
+ kChangeUsernameButton: 'Change Username',
+ kVoteButton: 'Vote For Reset',
+ kScreenshotButton: 'Screenshot',
+ kUsersOnlineHeading: 'Users Online:',
+ kTurnTime: 'Turn expires in {0} seconds.',
+ kWaitingTurnTime: 'Waiting for turn 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'
+ }
+};
+
+interface StringMap {
+ [k: string]: string;
+}
+
+/// our fancy internationalization helper.
+export class I18n {
+ // The language data itself
+ private lang: Language = fallbackLanguage;
+
+ // the ID of the language
+ private langId: string = fallbackId;
+
+ async LoadLanguageFile(id: string) {
+ let languageData = await I18n.LoadLanguageFileImpl(id);
+ this.SetLanguage(languageData, id);
+ }
+
+ async initWithLanguage(id: string) {
+ try {
+ await this.LoadLanguageFile(id);
+ console.log("i18n initalized for", id, "sucessfully!");
+ } catch (e) {
+ alert(`There was an error loading the language file for \"${id}\". Please tell a site admin this happened, and give them the following information: \"${(e as Error).message}\"`);
+ // force set the language to fallback
+ this.SetLanguage(fallbackLanguage, fallbackId);
+ }
+ }
+
+ private static async LoadLanguageFileImpl(id: string): Promise
{
+ let path = `./lang/${id}.json`;
+ let res = await fetch(path);
+
+ if (!res.ok) throw new Error(res.statusText);
+
+ return (await res.json()) as Language;
+ }
+
+ private SetLanguage(lang: Language, id: string) {
+ let lastId = this.langId;
+ this.langId = id;
+ this.lang = lang;
+
+ // Only replace static strings
+ if (this.langId != lastId) this.ReplaceStaticStrings();
+ }
+
+ // Replaces static strings that we don't recompute
+ private ReplaceStaticStrings() {
+ const kDomIdtoStringMap: StringMap = {
+ siteNameText: I18nStringKey.kSiteName,
+ homeBtnText: I18nStringKey.kHomeButton,
+ faqBtnText: I18nStringKey.kFAQButton,
+ rulesBtnText: I18nStringKey.kRulesButton,
+
+ usersOnlineText: I18nStringKey.kUsersOnlineHeading,
+
+ voteResetHeaderText: I18nStringKey.kVMResetTitle,
+ voteYesBtnText: I18nStringKey.kGenericYes,
+ voteNoBtnText: I18nStringKey.kGenericNo,
+
+ changeUsernameBtnText: I18nStringKey.kChangeUsernameButton,
+ voteForResetBtnText: I18nStringKey.kVoteButton,
+ screenshotBtnText: I18nStringKey.kScreenshotButton,
+
+ // admin stuff
+ passVoteBtnText: I18nStringKey.kPassVoteButton,
+ cancelVoteBtnText: I18nStringKey.kCancelVoteButton
+ };
+
+ for (let domId of Object.keys(kDomIdtoStringMap)) {
+ let element = document.getElementById(domId);
+ if (element == null) {
+ alert('Uhh!! THIS SHOULD NOT BE SEEN!! IF YOU DO YELL LOUDLY');
+ return;
+ }
+
+ // Do the magic.
+ element.innerText = this.GetString(kDomIdtoStringMap[domId]);
+ }
+ }
+
+ // Gets a string, which also allows replacing by index with the given replacements.
+ GetString(key: string, ...replacements: StringLike[]): string {
+ let replacementStringArray: Array = [...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];
+
+ if (val == null) {
+ let fallback = fallbackLanguage.stringKeys[key];
+ if (fallback == null) return 'UH OH WORM';
+ else return fallback;
+ }
+
+ for (let i = 0; i < val.length; ++i) {
+ if (val[i] == '{') {
+ let replacementStart = i;
+ let foundReplacementEnd = false;
+
+ if (i + 1 > val.length) {
+ throw new Error('Cutoff/invalid replacement');
+ }
+
+ // Try and find the replacement end
+ for (let j = i + 1; j < val.length; ++j) {
+ if (val[j] == '}') {
+ foundReplacementEnd = true;
+ i = j;
+ break;
+ }
+ }
+
+ if (!foundReplacementEnd) throw new Error('Invalid replacement, has no "}" to terminate it');
+
+ // 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) throw new Error('Invalid replacement');
+
+ // 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;
+ }
+}
+
+export let TheI18n = new I18n();
\ No newline at end of file
diff --git a/src/ts/main.ts b/src/ts/main.ts
index 6d8ae51..05e35ab 100644
--- a/src/ts/main.ts
+++ b/src/ts/main.ts
@@ -11,6 +11,7 @@ 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;
@@ -39,7 +40,7 @@ const elements = {
voteNoBtn: document.getElementById('voteNoBtn') as HTMLButtonElement,
voteYesLabel: document.getElementById('voteYesLabel') as HTMLSpanElement,
voteNoLabel: document.getElementById('voteNoLabel') as HTMLSpanElement,
- votetime: document.getElementById('votetime') 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,
@@ -323,6 +324,7 @@ async function openVM(vm: VM): Promise {
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('That username is already taken');
@@ -339,7 +341,7 @@ async function openVM(vm: VM): Promise {
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', (cd) => window.alert(`Please wait ${cd} seconds before starting another vote.`)));
+ unsubscribeCallbacks.push(VM!.on('votecd', (voteCooldown) => window.alert(TheI18n.GetString(I18nStringKey.kVoteCooldown, voteCooldown))));
unsubscribeCallbacks.push(VM!.on('login', (rank: Rank, perms: Permissions) => onLogin(rank, perms)));
unsubscribeCallbacks.push(
VM!.on('close', () => {
@@ -554,7 +556,7 @@ function turnUpdate(status: TurnStatus) {
user.element.classList.remove('user-turn', 'user-waiting');
user.element.setAttribute('data-cvm-turn', '-1');
}
- elements.turnBtnText.innerHTML = 'Take Turn';
+ elements.turnBtnText.innerHTML = TheI18n.GetString(I18nStringKey.kTakeTurnButton);
enableOSK(false);
if (status.user !== null) {
@@ -570,14 +572,14 @@ function turnUpdate(status: TurnStatus) {
if (status.user?.username === w.username) {
turn = 0;
turnTimer = status.turnTime! / 1000;
- elements.turnBtnText.innerHTML = 'End Turn';
+ elements.turnBtnText.innerHTML = TheI18n.GetString(I18nStringKey.kEndTurnButton);
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 = 'End Turn';
+ elements.turnBtnText.innerHTML = TheI18n.GetString(I18nStringKey.kEndTurnButton);
VM!.canvas.classList.add('waiting');
}
if (turn === -1) elements.turnstatus.innerText = '';
@@ -600,7 +602,7 @@ function voteUpdate(status: VoteStatus) {
function updateVoteEndTime() {
voteTimer--;
- elements.votetime.innerText = voteTimer.toString();
+ elements.voteTimeText.innerText = TheI18n.GetString(I18nStringKey.kVMVoteTime, voteTimer);
if (voteTimer === 0) clearInterval(voteInterval);
}
@@ -615,8 +617,8 @@ function turnIntervalCb() {
}
function setTurnStatus() {
- if (turn === 0) elements.turnstatus.innerText = `Turn expires in ${turnTimer} seconds`;
- else elements.turnstatus.innerText = `Waiting for turn in ${turnTimer} seconds`;
+ if (turn === 0) elements.turnstatus.innerText = TheI18n.GetString(I18nStringKey.kTurnTime, turnTimer);
+ else elements.turnstatus.innerText = TheI18n.GetString(I18nStringKey.kWaitingTurnTime, turnTimer);
}
function sendChat() {
@@ -635,7 +637,7 @@ elements.chatinput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') sendChat();
});
elements.changeUsernameBtn.addEventListener('click', () => {
- let newname = prompt('Enter new username, or leave blank to be assigned a guest username', w.username);
+ let newname = prompt(TheI18n.GetString(I18nStringKey.kEnterNewUsername), w.username);
if (newname === w.username) return;
VM?.rename(newname);
});
@@ -730,7 +732,7 @@ function userModOptions(user: { user: User; element: HTMLTableRowElement }) {
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, 'End Turn', () => VM!.endTurn(user.user.username));
+ if (perms.bypassturn) addUserDropdownItem(ul, TheI18n.GetString(I18nStringKey.kEndTurnButton), () => 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)
@@ -838,6 +840,16 @@ w.VMName = null;
// Load all VMs
loadList();
+// Set a default internationalization language if not specified
+let lang = window.localStorage.getItem('i18n-lang');
+if (lang == null) {
+ lang = 'en-us';
+ window.localStorage.setItem('i18n-lang', lang);
+}
+
+// Initalize the internationalization system
+TheI18n.initWithLanguage(lang);
+
// Welcome modal
let noWelcomeModal = window.localStorage.getItem('no-welcome-modal');
if (noWelcomeModal !== '1') {
diff --git a/src/ts/protocol/CollabVMClient.ts b/src/ts/protocol/CollabVMClient.ts
index bdc63d6..b1376e7 100644
--- a/src/ts/protocol/CollabVMClient.ts
+++ b/src/ts/protocol/CollabVMClient.ts
@@ -1,4 +1,4 @@
-import { createNanoEvents, Emitter, DefaultEvents } from 'nanoevents';
+import { createNanoEvents, Emitter, DefaultEvents, Unsubscribe } from 'nanoevents';
import * as Guacutils from './Guacutils.js';
import VM from './VM.js';
import { User } from './User.js';
@@ -8,15 +8,7 @@ import Mouse from './mouse.js';
import GetKeysym from '../keyboard.js';
import VoteStatus from './VoteStatus.js';
import MuteState from './MuteState.js';
-
-// TODO: `Object` has a toString(), but we should probably gate that off
-/// Interface for things that can be turned into strings
-interface ToStringable {
- toString(): string;
-}
-
-/// A type for strings, or things that can (in a valid manner) be turned into strings
-type StringLike = string | ToStringable;
+import { StringLike } from '../StringLike.js';
export interface CollabVMClientEvents {
open: () => void;
@@ -580,11 +572,11 @@ export default class CollabVMClient {
this.send('admin', AdminOpcode.HideScreen, hidden ? '1' : '0');
}
- private onInternal(event: E, callback: CollabVMClientPrivateEvents[E]) {
+ private onInternal(event: E, callback: CollabVMClientPrivateEvents[E]): Unsubscribe {
return this.internalEmitter.on(event, callback);
}
- on(event: E, callback: CollabVMClientEvents[E]) {
+ on(event: E, callback: CollabVMClientEvents[E]): Unsubscribe {
return this.publicEmitter.on(event, callback);
}
}
diff --git a/src/ts/protocol/Guacutils.ts b/src/ts/protocol/Guacutils.ts
index 0647f70..ee1f310 100644
--- a/src/ts/protocol/Guacutils.ts
+++ b/src/ts/protocol/Guacutils.ts
@@ -28,7 +28,7 @@ export function decode(string: string): string[] {
export function encode(...string: string[]): string {
let command = '';
- for (var i = 0; i < string.length; i++) {
+ for (let i = 0; i < string.length; i++) {
let current = string[i];
command += current.toString().length + '.' + current;
command += i < string.length - 1 ? ',' : ';';
diff --git a/static/.gitkeep b/static/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/static/lang/en-us.json b/static/lang/en-us.json
new file mode 100644
index 0000000..53faf06
--- /dev/null
+++ b/static/lang/en-us.json
@@ -0,0 +1,28 @@
+{
+ "languageName": "English (US)",
+ "translatedLanguageName": "English (US)",
+ "author": "Computernewb",
+
+ "stringKeys": {
+ "kSiteName": "CollabVM",
+ "kHomeButton": "Home",
+ "kFAQButton": "FAQ",
+ "kRulesButton": "Rules",
+ "kVMResetTitle": "Do you want to reset the VM?",
+ "kGenericYes": "Yes",
+ "kGenericNo": "No",
+ "kVMVoteTime": "Vote ends in {0} seconds",
+ "kPassVoteButton": "Pass Vote",
+ "kCancelVoteButton": "Cancel Vote",
+ "kTakeTurnButton": "Take Turn",
+ "kEndTurnButton": "End Turn",
+ "kChangeUsernameButton": "Change Username",
+ "kVoteButton": "Vote For Reset",
+ "kScreenshotButton": "Screenshot",
+ "kUsersOnlineHeading": "Users Online:",
+ "kTurnTime": "Turn expires in {0} seconds.",
+ "kWaitingTurnTime": "Waiting for turn 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"
+ }
+}
diff --git a/static/lang/pirate.json b/static/lang/pirate.json
new file mode 100644
index 0000000..cc7ed17
--- /dev/null
+++ b/static/lang/pirate.json
@@ -0,0 +1,28 @@
+{
+ "languageName": "Pirate",
+ "translatedLanguageName": "Arrrghh, matey!",
+ "author": "Computernewb",
+
+ "stringKeys": {
+ "kSiteName": "Argh, matey, it's CollabVM!",
+ "kHomeButton": "Arrrghh, set sail",
+ "kFAQButton": "Arrrgh, get your Map",
+ "kRulesButton": "Arrrrghh, get the Etiquette Book",
+ "kVMResetTitle": "Argh, matey, this VM is broken. Would you like to particpate in the VM War?",
+ "kGenericYes": "Arrrghh, yay!",
+ "kGenericNo": "Arrrrgh, nay!",
+ "kVMVoteTime": "Argghh, the cannon fight ends in {0} seconds",
+ "kPassVoteButton": "Rig the vote",
+ "kCancelVoteButton": "Ignore the vote",
+ "kTakeTurnButton": "Take the Wheel",
+ "kEndTurnButton": "Give Up the Wheel",
+ "kChangeUsernameButton": "Argh, select a new pirate name",
+ "kVoteButton": "Argh, my matey, it's broken...",
+ "kScreenshotButton": "Take a Polaroid",
+ "kUsersOnlineHeading": "Pirate Friends Online:",
+ "kTurnTime": "Arrrrgh, your wheel rights expire in {0} seconds.",
+ "kWaitingTurnTime": "Waiting for the wheel in {0} seconds.",
+ "kVoteCooldown": "Arrgh matey, you need to wait {0} seconds to vote again.",
+ "kEnterNewUsername": "Arggghh, matey, what would you like to be known as?"
+ }
+}