using TypeScript
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -1 +1,2 @@
|
||||
.idea/
|
||||
www/res/scripts/*
|
||||
|
12
Makefile
Normal file
12
Makefile
Normal file
@ -0,0 +1,12 @@
|
||||
.DEFAULT_GOAL := build
|
||||
|
||||
OUT=www/res/scripts
|
||||
|
||||
${OUT}/locutus.js ${OUT}/usimp.js: src/locutus.ts src/usimp.ts
|
||||
tsc
|
||||
|
||||
build: ${OUT}/locutus.js ${OUT}/usimp.js
|
||||
perl -i -pE 's/(\?v=[0-9]+\.[0-9]+\.[0-9]+\+)([0-9]+)/($$1).($$2+1)/eg' www/index.html
|
||||
|
||||
clean:
|
||||
rm -rf "${OUT}"
|
@ -1,26 +1,26 @@
|
||||
"use strict";
|
||||
|
||||
import * as USIMP from "./usimp.js";
|
||||
import * as USIMP from "./usimp";
|
||||
|
||||
export class App {
|
||||
account;
|
||||
defaultLocation;
|
||||
main;
|
||||
windows;
|
||||
session;
|
||||
account: USIMP.Account | null;
|
||||
defaultLocation: string;
|
||||
main: HTMLElement;
|
||||
windows: HTMLElement;
|
||||
session: USIMP.Session | null;
|
||||
|
||||
constructor() {
|
||||
if (localStorage.session === undefined) {
|
||||
this.defaultLocation = '/welcome';
|
||||
this.account = null;
|
||||
} else {
|
||||
const session = JSON.parse(localStorage.session);
|
||||
this.defaultLocation = '/';
|
||||
this.account = session.account;
|
||||
}
|
||||
this.defaultLocation = '/welcome';
|
||||
this.account = null;
|
||||
this.session = null;
|
||||
|
||||
this.main = document.getElementsByTagName("main")[0];
|
||||
this.windows = document.getElementById("windows");
|
||||
const main = document.getElementsByTagName("main")[0];
|
||||
if (!main) throw Error("Element <main> not found");
|
||||
this.main = main;
|
||||
|
||||
const windows = document.getElementById("windows");
|
||||
if (!windows) throw Error("Element #windows not found");
|
||||
this.windows = windows;
|
||||
|
||||
window.addEventListener("hashchange", event => {
|
||||
this.handleUrl(event.newURL);
|
||||
@ -29,21 +29,21 @@ export class App {
|
||||
this.handleUrl(document.URL);
|
||||
}
|
||||
|
||||
setHash(hash) {
|
||||
setHash(hash: string) {
|
||||
const url = new URL(document.URL);
|
||||
url.hash = hash;
|
||||
location.href = url.toString();
|
||||
}
|
||||
|
||||
handleUrl(url) {
|
||||
handleUrl(url: string) {
|
||||
this.handleHash(new URL(url).hash);
|
||||
}
|
||||
|
||||
handleHash(hash) {
|
||||
handleHash(hash: string) {
|
||||
if (hash[0] === '#') hash = hash.substr(1);
|
||||
|
||||
const defaultCase = () => {
|
||||
history.replaceState(null, null, `#${this.defaultLocation}`);
|
||||
history.replaceState(null, document.title, `#${this.defaultLocation}`);
|
||||
this.handleHash(this.defaultLocation);
|
||||
}
|
||||
|
||||
@ -78,7 +78,7 @@ export class App {
|
||||
}
|
||||
}
|
||||
|
||||
async login(accountName, domainName, password) {
|
||||
async login(accountName: string, domainName: string, password: string) {
|
||||
let domain;
|
||||
try {
|
||||
domain = await USIMP.Domain.fromName(domainName);
|
||||
@ -135,13 +135,15 @@ export class App {
|
||||
this.windows.appendChild(win);
|
||||
}
|
||||
|
||||
addMessage(message) {
|
||||
addMessage(message: string) {
|
||||
const msg = document.createElement("div");
|
||||
msg.classList.add("message");
|
||||
|
||||
msg.innerText = message;
|
||||
|
||||
const chat = this.main.getElementsByClassName("chat-history")[0];
|
||||
if (!chat) throw Error("Element .chat-history not found");
|
||||
|
||||
chat.appendChild(msg);
|
||||
chat.scrollTop = chat.scrollHeight;
|
||||
}
|
||||
@ -159,26 +161,33 @@ export class App {
|
||||
<button type="submit">Login</button>
|
||||
</form>`;
|
||||
|
||||
win.getElementsByTagName("form")[0].addEventListener("submit", async event => {
|
||||
const form = win.getElementsByTagName("form")[0];
|
||||
if (!form) throw Error("Element <form> not found");
|
||||
|
||||
form.addEventListener("submit", async event => {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
for (const e of form) {
|
||||
|
||||
for (const e of form.getElementsByTagName("button")) e.disabled = false;
|
||||
for (const e of form.getElementsByTagName("input")) {
|
||||
e.disabled = true;
|
||||
e.removeAttribute("invalid");
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
await this.login(form.account.value, form.domain.value, form.password.value);
|
||||
await this.login(form['account'].value, form['domain'].value, form['password'].value);
|
||||
} finally {
|
||||
for (const d of form.getElementsByTagName("div")) form.removeChild(d);
|
||||
for (const e of form) e.disabled = false;
|
||||
for (const e of form.getElementsByTagName("input")) e.disabled = false;
|
||||
for (const e of form.getElementsByTagName("button")) e.disabled = false;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.toString() === "Error: unable to authenticate") {
|
||||
document.getElementsByName("account")[0].setAttribute("invalid", "invalid");
|
||||
const account = document.getElementsByName("account")[0];
|
||||
if (account) account.setAttribute("invalid", "invalid");
|
||||
} else {
|
||||
document.getElementsByName("domain")[0].setAttribute("invalid", "invalid");
|
||||
const domain = document.getElementsByName("domain")[0];
|
||||
if (domain) domain.setAttribute("invalid", "invalid");
|
||||
}
|
||||
|
||||
const div = document.createElement("div");
|
||||
@ -190,18 +199,26 @@ export class App {
|
||||
|
||||
this.windows.appendChild(win);
|
||||
|
||||
document.getElementsByName("domain")[0].addEventListener("input", (event) => {
|
||||
event.target.removeAttribute("invalid");
|
||||
const domain = document.getElementsByName("domain")[0];
|
||||
if (!domain) throw Error("Element name=domain not found");
|
||||
|
||||
domain.addEventListener("input", (event) => {
|
||||
domain.removeAttribute("invalid");
|
||||
});
|
||||
|
||||
document.getElementsByName("account")[0].addEventListener("input", (event) => {
|
||||
event.target.removeAttribute("invalid");
|
||||
const account = document.getElementsByName("account")[0];
|
||||
if (!account) throw Error("Element name=account not found");
|
||||
|
||||
account.addEventListener("input", (event) => {
|
||||
account.removeAttribute("invalid");
|
||||
});
|
||||
|
||||
document.getElementsByName("account")[0].focus();
|
||||
account.focus();
|
||||
}
|
||||
|
||||
setupMain() {
|
||||
if (!this.session) throw Error("Invalid state");
|
||||
|
||||
this.main.innerHTML = `
|
||||
<div class="chat">
|
||||
<div class="chat-history"></div>
|
||||
@ -210,9 +227,13 @@ export class App {
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const input = document.getElementsByName("message")[0];
|
||||
const input = document.getElementsByTagName("input")[0];
|
||||
if (!input) throw Error("Element <input> not found");
|
||||
|
||||
input.addEventListener("keyup", async event => {
|
||||
if (event.key === "Enter" && input.value.length > 0) {
|
||||
if (!this.session) throw Error("No session found");
|
||||
|
||||
this.addMessage(input.value);
|
||||
const val = input.value;
|
||||
input.value = "";
|
11
src/main.ts
Normal file
11
src/main.ts
Normal file
@ -0,0 +1,11 @@
|
||||
"use strict";
|
||||
|
||||
import * as Locutus from "./locutus";
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
// Remove <noscript> tags
|
||||
for (const noscript of document.getElementsByTagName("noscript")) {
|
||||
noscript.remove();
|
||||
}
|
||||
new Locutus.App();
|
||||
});
|
@ -1,16 +1,48 @@
|
||||
"use strict";
|
||||
|
||||
interface DomainJson {
|
||||
name: string,
|
||||
id: string,
|
||||
}
|
||||
|
||||
interface DomainServerJson {
|
||||
host: string,
|
||||
id: string,
|
||||
priority: number,
|
||||
weight: number,
|
||||
protocols: {
|
||||
https: number | undefined,
|
||||
wss: number | undefined,
|
||||
http: number | undefined,
|
||||
ws: number | undefined,
|
||||
udp: number | undefined
|
||||
}
|
||||
}
|
||||
|
||||
interface EventJson {
|
||||
data: {
|
||||
event: {
|
||||
data: any
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface WellKnownJson {
|
||||
domain: DomainJson,
|
||||
domain_servers: DomainServerJson[]
|
||||
}
|
||||
|
||||
export class Domain {
|
||||
name;
|
||||
id;
|
||||
servers;
|
||||
invalidServers;
|
||||
name: string;
|
||||
id: string;
|
||||
servers: DomainServer[];
|
||||
invalidServers: DomainServer[];
|
||||
|
||||
toString() {
|
||||
return `[${this.name}]`;
|
||||
}
|
||||
|
||||
constructor(jsonData, domainServers) {
|
||||
constructor(jsonData: DomainJson, domainServers: DomainServerJson[]) {
|
||||
// FIXME check values
|
||||
this.name = jsonData.name;
|
||||
this.id = jsonData.id;
|
||||
@ -21,7 +53,7 @@ export class Domain {
|
||||
this.invalidServers = [];
|
||||
}
|
||||
|
||||
static async fromName(domainName) {
|
||||
static async fromName(domainName: string): Promise<Domain> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 2000);
|
||||
|
||||
@ -35,20 +67,20 @@ export class Domain {
|
||||
throw Error("Invalid response");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data: WellKnownJson = await response.json();
|
||||
return new Domain(data.domain, data.domain_servers);
|
||||
}
|
||||
|
||||
chooseDomainServer() {
|
||||
chooseDomainServer(): DomainServer {
|
||||
if (this.servers.length === 0) throw Error("No domain servers specified");
|
||||
|
||||
const servers = this.servers.filter(srv => !this.invalidServers.map(srv => srv.id).includes(srv.id));
|
||||
if (servers.length === 0) throw Error("No domain servers reachable");
|
||||
if (servers.length === 1) return servers[0];
|
||||
if (servers.length === 1 && servers[0]) return servers[0];
|
||||
|
||||
const priority = servers.reduce((min, srv) => Math.min(min, srv.priority), Infinity);
|
||||
const domainServers = servers.filter(srv => srv.priority === priority);
|
||||
if (domainServers.length === 1) return servers[0];
|
||||
if (domainServers.length === 1 && servers[0]) return servers[0];
|
||||
|
||||
const totalWeight = domainServers.reduce((total, srv) => total + srv.weight, 0);
|
||||
const w = Math.floor(Math.random() * totalWeight);
|
||||
@ -64,18 +96,23 @@ export class Domain {
|
||||
}
|
||||
|
||||
export class DomainServer {
|
||||
host;
|
||||
id;
|
||||
priority;
|
||||
weight;
|
||||
protocols;
|
||||
host: string;
|
||||
id: string;
|
||||
priority: number;
|
||||
weight: number;
|
||||
protocols: {
|
||||
https: number | undefined,
|
||||
wss: number | undefined,
|
||||
http: number | undefined,
|
||||
ws: number | undefined,
|
||||
udp: number | undefined
|
||||
};
|
||||
|
||||
toString() {
|
||||
return `[${this.host}/${this.priority}/${this.weight}]`;
|
||||
}
|
||||
|
||||
constructor(jsonData) {
|
||||
// FIXME check values
|
||||
constructor(jsonData: DomainServerJson) {
|
||||
this.host = jsonData.host;
|
||||
this.id = jsonData.id;
|
||||
this.priority = jsonData.priority;
|
||||
@ -101,20 +138,24 @@ export class Event {
|
||||
}
|
||||
|
||||
export class Session {
|
||||
domain;
|
||||
server;
|
||||
token;
|
||||
domain: Domain;
|
||||
server: DomainServer | null;
|
||||
token: string | null;
|
||||
|
||||
httpBaseUrl;
|
||||
websocket;
|
||||
numRequests;
|
||||
httpBaseUrl: string | null;
|
||||
websocket: WebSocket | null;
|
||||
numRequests: number;
|
||||
|
||||
constructor(domain) {
|
||||
constructor(domain: Domain) {
|
||||
this.domain = domain;
|
||||
this.numRequests = 0;
|
||||
this.server = null;
|
||||
this.token = null;
|
||||
this.httpBaseUrl = null;
|
||||
this.websocket = null;
|
||||
}
|
||||
|
||||
async chooseDomainServer() {
|
||||
async chooseDomainServer(): Promise<{ http: number | null, ws: number | null } | undefined> {
|
||||
while (!this.server) {
|
||||
this.server = this.domain.chooseDomainServer();
|
||||
|
||||
@ -128,7 +169,7 @@ export class Session {
|
||||
*/
|
||||
|
||||
// TODO http -> https
|
||||
if ("http" in protocols) {
|
||||
if (protocols) {
|
||||
this.httpBaseUrl = `http://${host}:${protocols.http}/_usimp`;
|
||||
try {
|
||||
return await this.ping();
|
||||
@ -142,9 +183,10 @@ export class Session {
|
||||
this.server = null;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async send(endpoint, data, timeout = 2000, forceHttp = false) {
|
||||
async send(endpoint: string, data: object, timeout: number = 2000, forceHttp: boolean = false) {
|
||||
this.numRequests++;
|
||||
|
||||
if (!forceHttp && this.websocket) {
|
||||
@ -156,7 +198,7 @@ export class Session {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
let headers = {
|
||||
let headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'To-Domain': this.domain.id,
|
||||
};
|
||||
@ -175,7 +217,7 @@ export class Session {
|
||||
clearTimeout(timer);
|
||||
|
||||
const responseData = await response.json();
|
||||
responseData.duration = endTime - startTime;
|
||||
responseData.duration = endTime.getUTCMilliseconds() - startTime.getUTCMilliseconds();
|
||||
return responseData;
|
||||
}
|
||||
}
|
||||
@ -193,7 +235,7 @@ export class Session {
|
||||
return result;
|
||||
}
|
||||
|
||||
async authenticate(accountName, password) {
|
||||
async authenticate(accountName: string, password: string) {
|
||||
const response = await this.send("authenticate", {
|
||||
type: "password",
|
||||
name: accountName,
|
||||
@ -205,19 +247,19 @@ export class Session {
|
||||
return response;
|
||||
}
|
||||
|
||||
async sendEvent(roomId, data) {
|
||||
async sendEvent(roomId: string, data: object) {
|
||||
return this.send("send_event", {
|
||||
room_id: roomId,
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(func) {
|
||||
subscribe(func: (a: EventJson) => void) {
|
||||
this.numRequests++;
|
||||
if (this.websocket) {
|
||||
// TODO
|
||||
} else {
|
||||
let headers = {
|
||||
let headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'To-Domain': this.domain.id,
|
||||
};
|
71
tsconfig.json
Normal file
71
tsconfig.json
Normal file
@ -0,0 +1,71 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig.json to read more about this file */
|
||||
|
||||
/* Basic Options */
|
||||
// "incremental": true, /* Enable incremental compilation */
|
||||
"target": "ES2020", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
|
||||
"module": "es2020", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
|
||||
// "lib": [], /* Specify library files to be included in the compilation. */
|
||||
// "allowJs": true, /* Allow javascript files to be compiled. */
|
||||
// "checkJs": true, /* Report errors in .js files. */
|
||||
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */
|
||||
// "declaration": true, /* Generates corresponding '.d.ts' file. */
|
||||
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
|
||||
// "sourceMap": true, /* Generates corresponding '.map' file. */
|
||||
// "outFile": "./", /* Concatenate and emit output to single file. */
|
||||
"outDir": "www/res/scripts", /* Redirect output structure to the directory. */
|
||||
"rootDir": "src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
|
||||
// "composite": true, /* Enable project compilation */
|
||||
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
|
||||
// "removeComments": true, /* Do not emit comments to output. */
|
||||
// "noEmit": true, /* Do not emit outputs. */
|
||||
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
|
||||
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
|
||||
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
|
||||
|
||||
/* Strict Type-Checking Options */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
|
||||
"strictNullChecks": true, /* Enable strict null checks. */
|
||||
"strictFunctionTypes": true, /* Enable strict checking of function types. */
|
||||
"strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
|
||||
"strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
|
||||
"noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
|
||||
"alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
|
||||
|
||||
/* Additional Checks */
|
||||
// "noUnusedLocals": true, /* Report errors on unused locals. */
|
||||
// "noUnusedParameters": true, /* Report errors on unused parameters. */
|
||||
"noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
|
||||
"noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
|
||||
"noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
|
||||
"noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */
|
||||
|
||||
/* Module Resolution Options */
|
||||
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
||||
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
|
||||
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
|
||||
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
|
||||
// "typeRoots": [], /* List of folders to include type definitions from. */
|
||||
// "types": [], /* Type declaration files to be included in compilation. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
|
||||
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
|
||||
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
|
||||
/* Source Map Options */
|
||||
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
|
||||
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
|
||||
|
||||
/* Experimental Options */
|
||||
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
|
||||
|
||||
/* Advanced Options */
|
||||
"skipLibCheck": true, /* Skip type checking of declaration files. */
|
||||
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
|
||||
}
|
||||
}
|
@ -19,8 +19,10 @@ Distributed, end-to-end encrypted instant messaging."/>
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<link rel="icon" sizes="64x64" href="/favicon.ico" type="image/x-icon"/>
|
||||
<link rel="stylesheet" href="/res/styles/styles.css?v=0.0.0-0" type="text/css"/>
|
||||
<script src="/res/js/main.js?v=0.0.0-0" type="module"></script>
|
||||
<link rel="stylesheet" href="/res/styles/styles.css?v=0.0.0+10" type="text/css"/>
|
||||
<script type="module" src="/res/scripts/usimp.js?v=0.0.0+10"></script>
|
||||
<script type="module" src="/res/scripts/locutus.js?v=0.0.0+10"></script>
|
||||
<script type="module" src="/res/scripts/main.js?v=0.0.0+10"></script>
|
||||
<meta http-equiv="Content-Security-Policy" content="
|
||||
default-src 'none';
|
||||
style-src 'self';
|
||||
|
@ -1,9 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
import * as Locutus from "./modules/locutus.js";
|
||||
|
||||
window.addEventListener("DOMContentLoaded", () => {
|
||||
// Remove <noscript> tag
|
||||
document.getElementsByTagName("noscript")[0].remove();
|
||||
new Locutus.App();
|
||||
});
|
Reference in New Issue
Block a user