type int = number; type float = number; var _conf: any = null; function get_timestamp(): int { return Math.floor(Date.now()/1000); } function hash_string_to_unit(x: string): float { return (x.split("").reduce((x, y) => ((x + y.charCodeAt(0)) % 32), 0) / 32); } function get_usercolor(name: string): string { const hue: float = hash_string_to_unit(name); return `hsl(${(hue*360).toFixed(2)},50%,75%)`; } async function backend_call(connection_id: (null | string), action: string, data: any): Promise { const response = await fetch ( `${_conf["backend"]["scheme"]}://${_conf["backend"]["host"]}:${_conf["backend"]["port"].toFixed(0)}/${_conf["backend"]["path"]}`, { "method": "POST", "body": JSON.stringify({"id": connection_id, "action": action, "data": data}), } ); if (response.ok) { return response.json(); } else { console.error(response.text()); return Promise.reject(new Error("backend call failed")); } } enum enum_state { offline = "offline", connecting = "connecting", online = "online", } type type_event = { timestamp: int; kind: string; data: any; }; type type_spot = { kind: string; name: string; }; type type_entry = { timestamp: int; sender: string; content: string; }; type type_user = { name: string; role: string; }; type type_channel = { users: Array; entries: Array; }; type type_query = { entries: Array; }; type type_model = { state: enum_state; connection_id: (null | string); nickname: (null | string); channels: Record; queries: Record; active: (null | type_spot); }; function model_set_state(model: type_model, state: enum_state): void { model.state = state; view_update_state(model); } function model_set_active(model: type_model, spot: type_spot): void { model.active = spot; view_update_spots(model); view_update_entries(model); view_update_users(model); } function model_process_events(model: type_model, events: Array): void { let shall_update_spots: boolean = false; let shall_update_entries: boolean = false; let shall_update_users: boolean = false; for (const event of events) { switch (event.kind) { default: { console.warn("unhandled event kind: " + event.kind); break; } case "userlist": { model.channels[event.data["channel"]].users = event.data["users"]; shall_update_users = true; break; } case "message_channel": { model.channels[event.data["channel"]].entries.push ({ "timestamp": event.timestamp, "sender": event.data["sender"], "content": event.data["content"], }); shall_update_entries = true; break; } case "message_query": { if (! model.queries.hasOwnProperty(event.data["user_name"])) { model.queries[event.data["user_name"]] = {"entries": []}; shall_update_spots = true; } else { // do nothing } model.queries[event.data["user_name"]].entries.push ({ "timestamp": event.timestamp, "sender": event.data["sender"], "content": event.data["content"], }); shall_update_entries = true; break; } } } // update view { if (shall_update_spots) view_update_spots(model); if (shall_update_entries) view_update_entries(model); if (shall_update_users) view_update_users(model); } } function view_update_state(model: type_model): void { document.querySelector("body").setAttribute("class", model.state); } function view_update_spots(model: type_model): void { let dom_spots: HTMLUListElement = document.querySelector("#spots"); const spots: Array = ( [] .concat(Object.keys(model.channels).map((name) => ({"kind": "channel", "name": name}))) .concat(Object.keys(model.queries).map((name) => ({"kind": "query", "name": name}))) ); dom_spots.textContent = ""; for (const spot of spots) { let dom_spot: HTMLLIElement = document.createElement("li"); dom_spot.classList.add("spot"); { let dom_kind: HTMLSpanElement = document.createElement("span"); dom_kind.classList.add("spot_kind"); dom_kind.textContent = spot.kind; dom_spot.appendChild(dom_kind); } { let dom_name: HTMLSpanElement = document.createElement("span"); dom_name.classList.add("spot_sender"); dom_name.textContent = spot.name; dom_spot.appendChild(dom_name); } dom_spot.classList.toggle("spot_active", ((spot.kind === model.active.kind) && (spot.name === model.active.name))); dom_spot.addEventListener ( "click", (e) => { model_set_active(model, spot); } ); dom_spots.appendChild(dom_spot); } } function view_update_entries(model: type_model): void { let dom_entries: HTMLUListElement = document.querySelector("#entries"); let entries: Array; switch (model.active.kind) { case "channel": { entries = model.channels[model.active.name].entries; break; } case "query": { entries = model.queries[model.active.name].entries; break; } } dom_entries.textContent = ""; for (const entry of entries) { let dom_entry: HTMLLIElement = document.createElement("li"); dom_entry.classList.add("entry"); { let dom_time: HTMLSpanElement = document.createElement("span"); dom_time.classList.add("entry_time"); dom_time.textContent = (new Date(entry.timestamp*1000)).toISOString().slice(11, 19); dom_entry.appendChild(dom_time); } { let dom_sender: HTMLSpanElement = document.createElement("span"); dom_sender.classList.add("entry_sender"); dom_sender.style.color = get_usercolor(entry.sender); dom_sender.textContent = entry.sender; dom_entry.appendChild(dom_sender); } { let dom_content: HTMLSpanElement = document.createElement("span"); dom_content.classList.add("entry_content"); dom_content.textContent = entry.content; dom_entry.appendChild(dom_content); } dom_entries.appendChild(dom_entry); } dom_entries.scrollTo(0, dom_entries["scrollTopMax"]); } function view_update_users(model: type_model): void { let dom_users: HTMLUListElement = document.querySelector("#users"); dom_users.textContent = ""; let users: Array; switch (model.active.kind) { default: { console.warn("unhandled kind: " + model.active.kind); users = []; break; } case "channel": { users = model.channels[model.active.name].users; break; } case "query": { users = [{"name": model.nickname, "role": ""}, {"name": model.active.name, "role": ""}]; break; } } const users_sorted: Array = users.sort ( (x, y) => ( (x.role >= y.role) ? -1 : ( (x.role === y.role) ? ((x.name < y.name) ? -1 : +1) : +1 ) ) ); for (const user of users_sorted) { let dom_user: HTMLLIElement = document.createElement("li"); dom_user.classList.add("user"); { let dom_role: HTMLSpanElement = document.createElement("span"); dom_role.textContent = user.role; dom_user.appendChild(dom_role); } { let dom_name: HTMLSpanElement = document.createElement("span"); dom_name.textContent = user.name; dom_name.style.color = get_usercolor(user.name); dom_user.appendChild(dom_name); } dom_users.appendChild(dom_user); } } function view_setup(model: type_model): void { document.querySelector("#channel").value = _conf["irc"]["predefined_channel"]; document.querySelector("#nickname").value = (_conf["irc"]["predefined_nickname_prefix"] + (Math.random()*100).toFixed(0)); setInterval ( async () => { switch (model.state) { default: { throw (new Error("invalid state: " + model.state)); break; } case enum_state.offline: { // do nothing break; } case enum_state.connecting: { const ready: boolean = await backend_call(model.connection_id, "check", null); if (ready) { model_set_state(model, enum_state.online); } else { // do nothing } break; } case enum_state.online: { const events: Array = await backend_call(model.connection_id, "fetch", null); model_process_events(model, events); break; } } }, _conf["settings"]["poll_interval_in_milliseconds"] ); model_set_state(model, enum_state.offline); } function control_setup(model: type_model): void { document.querySelector("#connect > form").addEventListener ( "submit", async (event) => { event.preventDefault(); model_set_state(model, enum_state.connecting); let dom_nickname: HTMLInputElement = document.querySelector("#nickname"); let dom_channel: HTMLInputElement = document.querySelector("#channel"); const nickname: string = dom_nickname.value; const channel_names: Array = dom_channel.value.split(","); const connection_id: string = await backend_call ( model.connection_id, "connect", { "server": _conf["irc"]["server"], "channels": channel_names, "nickname": nickname, } ); model.connection_id = connection_id; model.nickname = nickname; for (const channel_name of channel_names) { model.channels[channel_name] = { "users": [], "entries": [], }; } // TODO: can crash model_set_active(model, {"kind": "channel", "name": channel_names[0]}); } ); document.querySelector("#disconnect").addEventListener ( "click", async (event) => { await backend_call ( model.connection_id, "disconnect", null ); model_set_state(model, enum_state.offline); model.connection_id = null; } ); document.querySelector("#main > form").addEventListener ( "submit", async (e) => { event.preventDefault(); let dom_content: HTMLInputElement = document.querySelector("#content"); const content: string = dom_content.value; dom_content.value = ""; dom_content.focus(); switch (model.active.kind) { case "channel": { const event: type_event = { "timestamp": get_timestamp(), "kind": "message_channel", "data": { "channel": model.active.name, "sender": model.nickname, "content": content, } }; model_process_events(model, [event]); view_update_entries(model); backend_call ( model.connection_id, "send_channel", { "channel": model.active.name, "content": content, } ); break; } case "query": { const event: type_event = { "timestamp": get_timestamp(), "kind": "message_query", "data": { "user_name": model.active.name, "sender": model.nickname, "content": content, } }; model_process_events(model, [event]); backend_call ( model.connection_id, "send_query", { "receiver": model.active.name, "content": content, } ); break; } } } ); } var _model; async function main(): Promise { _conf = await fetch("conf.json").then(x => x.json()); const model: type_model = { "state": enum_state.offline, "connection_id": null, "nickname": null, "channels": {}, "queries": {}, "active": null, }; _model = model; view_setup(model); control_setup(model); } function init(): void { document.addEventListener ( "DOMContentLoaded", (event) => {main();} ); }