diff --git a/src/ts/format.ts b/src/ts/format.ts index cf2c848..ae95f9d 100644 --- a/src/ts/format.ts +++ b/src/ts/format.ts @@ -1,5 +1,9 @@ import { StringLike } from './StringLike'; +function isalpha(char: number) { + return RegExp(/^\p{L}/, 'u').test(String.fromCharCode(char)); +} + /// 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. @@ -25,8 +29,8 @@ export function Format(pattern: string, ...args: Array) { 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`); + if (i + 3 > pat.length) { + throw new Error(`Error in format pattern "${pat}": Cutoff/invalid format specifier`); } // Try and find the specifier end ('}'). @@ -40,13 +44,14 @@ export function Format(pattern: string, ...args: Array) { 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; + + case '-': + throw new Error(`Error in format pattern "${pat}": Malformed format specifier`); default: + if (isalpha(pat.charCodeAt(j))) throw new Error(`Error in format pattern "${pat}": Malformed format specifier`); break; } diff --git a/src/ts/main.ts b/src/ts/main.ts index af3f332..e2d549d 100644 --- a/src/ts/main.ts +++ b/src/ts/main.ts @@ -12,6 +12,7 @@ import * as bootstrap from 'bootstrap'; import MuteState from './protocol/MuteState.js'; import { Unsubscribe } from 'nanoevents'; import { I18nStringKey, TheI18n } from './i18n.js'; +import { Format } from './format.js'; // Elements const w = window as any; @@ -318,46 +319,39 @@ async function openVM(vm: VM): Promise { 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[] = []; + VM!.on('chat', (username, message) => chatMessage(username, message)); + VM!.on('adduser', (user) => addUser(user)); + VM!.on('remuser', (user) => remUser(user)); + VM!.on('rename', (oldname, newname, selfrename) => userRenamed(oldname, newname, selfrename)); - 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)); + 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; + } + }); - // Call all the unsubscribe callbacks. - for (let l of unsubscribeCallbacks) l(); - unsubscribeCallbacks = []; - closeVM(); - }) - ); + VM!.on('turn', (status) => turnUpdate(status)); + VM!.on('vote', (status: VoteStatus) => voteUpdate(status)); + VM!.on('voteend', () => voteEnd()); + VM!.on('votecd', (voteCooldown) => window.alert(TheI18n.GetString(I18nStringKey.kVM_VoteCooldownTimer, voteCooldown))); + VM!.on('login', (rank: Rank, perms: Permissions) => onLogin(rank, perms)); + + VM!.on('close', () => { + if (!expectedClose) alert(TheI18n.GetString(I18nStringKey.kError_UnexpectedDisconnection)); + closeVM(); + }); // Wait for the client to open await VM!.WaitForOpen(); @@ -374,7 +368,7 @@ async function openVM(vm: VM): Promise { throw new Error('Failed to connect to node'); } // Set the title - document.title = vm.id + ' - CollabVM'; + document.title = Format("{0} - {1}", vm.id, TheI18n.GetString(I18nStringKey.kGeneric_CollabVM)); // Append canvas elements.vmDisplay.appendChild(VM!.canvas); // Switch to the VM view @@ -389,7 +383,7 @@ function closeVM() { // Close the VM VM.close(); VM = null; - document.title = 'CollabVM'; + document.title = TheI18n.GetString(I18nStringKey.kGeneric_CollabVM); turn = -1; // Remove the canvas elements.vmDisplay.innerHTML = ''; @@ -844,6 +838,8 @@ document.addEventListener('DOMContentLoaded', async () => { // Initalize the i18n system await TheI18n.Init(); + document.title = TheI18n.GetString(I18nStringKey.kGeneric_CollabVM); + // Load all VMs await loadList(); diff --git a/src/ts/protocol/CollabVMClient.ts b/src/ts/protocol/CollabVMClient.ts index 1f6a60f..46c81d2 100644 --- a/src/ts/protocol/CollabVMClient.ts +++ b/src/ts/protocol/CollabVMClient.ts @@ -63,6 +63,8 @@ export default class CollabVMClient { // public events private publicEmitter: Emitter; + private unsubscribeCallbacks: Array = []; + constructor(url: string) { // Save the URL this.url = url; @@ -434,6 +436,13 @@ export default class CollabVMClient { // Close the connection close() { this.connectedToVM = false; + + // call all unsubscribe callbacks explicitly + for(let cb of this.unsubscribeCallbacks) { + cb(); + } + this.unsubscribeCallbacks = []; + if (this.socket.readyState === WebSocket.OPEN) this.socket.close(); } @@ -588,6 +597,8 @@ export default class CollabVMClient { } on(event: E, callback: CollabVMClientEvents[E]): Unsubscribe { - return this.publicEmitter.on(event, callback); + let unsub = this.publicEmitter.on(event, callback); + this.unsubscribeCallbacks.push(unsub); + return unsub; } } diff --git a/src/ts/tests/format.test.ts b/src/ts/tests/format.test.ts index 0511902..165792c 100644 --- a/src/ts/tests/format.test.ts +++ b/src/ts/tests/format.test.ts @@ -13,6 +13,12 @@ test('a cut off format specifier throws', () => { }); test('a malformed format specifier throws', () => { + expect(() => Format('a{-0}', 1)).toThrow('Malformed format specifier'); + expect(() => Format('a{0-}', 1)).toThrow('Malformed format specifier'); + expect(() => Format('a{0ab}', 1)).toThrow('Malformed format specifier'); + expect(() => Format('a{ab0ab}', 1)).toThrow('Malformed format specifier'); + + // Whitespace is not permitted inside a format specifier 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');