Start transitioning to MVC

This commit is contained in:
Midgard 2020-03-31 12:09:33 +02:00
parent 77b1d465a3
commit 4db66bb8c0
Signed by: midgard
GPG key ID: 511C112F1331BBB4
8 changed files with 457 additions and 434 deletions

View file

@ -14,15 +14,6 @@ class InvalidJsonError extends AjaxError {}
const MIME_JSON = "application/json"; const MIME_JSON = "application/json";
/**
* Wrap a function so that it receives `this` as first argument.
*/
function thisToArg(f) {
return function(...rest) {
return f(this, ...rest);
}
}
function xhrInitForPromise(resolve, reject, url, method, headers) { function xhrInitForPromise(resolve, reject, url, method, headers) {
const t_resolve = thisToArg(resolve), const t_resolve = thisToArg(resolve),
t_reject = thisToArg(reject); t_reject = thisToArg(reject);

142
controller/controller.js Normal file
View file

@ -0,0 +1,142 @@
"use strict";
function createClient(endpoint) {
const api = new mm_client.MattermostApi(normalizedEndpoint(endpoint));
return new mm_client.MattermostClient(api);
}
function buttonDisable(element, text) {
if (!element.dataset.originalText) {
element.dataset.originalText = element.innerText;
}
element.innerText = text;
element.disabled = true;
}
function buttonEnable(element) {
element.innerText = element.dataset.originalText;
element.disabled = false;
}
function logIn() {
const client = createClient(byId("login_server").value);
buttonDisable(byId("login_button"), "Logging in...");
client.logIn(byId("login_login_id").value, byId("login_password").value)
.then(json => {
buttonEnable(byId("login_button"));
byId("login_message").innerText = "";
byId("channel_list").innerText = `Logged in as ${json.username}`;
window.location = "#";
populateServerSelectionList();
populateChannelList();
})
.catch(error => {
console.error(error);
buttonEnable(byId("login_button"));
byId("login_message").innerText = `${error}`;
});
}
function logOut(endpoint, button) {
const client = createClient(endpoint);
buttonDisable(button, "Logging out...");
client.logOut()
.then(response => {
console.info("Succesfully logged out");
populateServerSelectionList();
populateChannelList();
})
.catch(error => {
console.error(error);
const span = document.createElement("span");
span.innerText = `Failed to log out: ${error.message}`;
button.parentElement.appendChild(span);
buttonEnable(button);
});
}
function channelNameElements(team, channel) {
let icon = "";
let teamName = team.name;
let channelName = channel.name;
let title = "";
switch (channel.type) {
case "O": // Public channel
title = `${channel.name} in team ${team.name} (public)`;
break;
case "P": // Private channel
icon = "🔒";
title = `${channel.name} in team ${team.name} (private)`;
break;
case "D": // Direct message
return undefined;
teamName = "";
channelName = `👤 ...`;
title = `Direct message`;
break;
case "G": // Group chat
teamName = "";
channelName = `👥 ${channel.display_name}`;
title = `Group chat with ${channel.display_name}`;
break;
default: // Unsupported
icon = channel.type;
title = `${channel.name} in team ${team.name} (type ${channel.type})`;
break;
}
let elements = [];
if (icon) {
elements.push(`${icon} `);
}
if (teamName) {
const teamElement = document.createElement("span");
teamElement.className = "team-name";
teamElement.innerText = teamName;
elements.push(teamElement);
const separatorElement = document.createElement("span");
separatorElement.className = "separator";
separatorElement.innerText = "/";
elements.push(separatorElement);
}
const channelElement = document.createElement("span");
channelElement.className = "channel-name";
channelElement.innerText = channelName;
elements.push(channelElement);
return [title, elements];
}
function switchToChannel(client, team, channel) {
for (let el of byId("channel_list").childNodes) {
if (el.dataset["id"] == channel.id) {
el.className = "active";
} else {
el.className = "";
}
}
byId("channel_contents").innerText = "Loading…";
client.channelPosts(channel.id)
.then(response => {
console.info(`Got channel contents of ${channel.id} (${channel.name})`);
populateChannelContents(response);
})
.catch(error => {
console.error(error);
byId("channel_contents").innerText = `Failed to get channel contents:\n${error.message}`;
});
const [title, elements] = channelNameElements(team, channel);
byId("channel_header").innerHTML = "";
byId("channel_header").append(...elements);
byId("compose").setAttribute("placeholder", `Write to ${byId("channel_header").textContent}`);
}

View file

@ -63,6 +63,11 @@
</div> </div>
<script type="text/javascript" src="/ajax.js"></script> <script type="text/javascript" src="/ajax.js"></script>
<script type="text/javascript" src="/util.js"></script>
<script type="text/javascript" src="/model/credentials.js"></script>
<script type="text/javascript" src="/model/mm_client.js"></script>
<script type="text/javascript" src="/view/view.js"></script>
<script type="text/javascript" src="/controller/controller.js"></script>
<script type="text/javascript" src="/main.js"></script> <script type="text/javascript" src="/main.js"></script>
</body> </body>
</html> </html>

430
main.js
View file

@ -1,428 +1,8 @@
"use strict"; "use strict";
function byId(id, nullOk=false) {
const el = document.getElementById(id);
if (!el && !nullOk) {
console.error(`No element #${id}`);
}
return el;
}
const LOCALSTORAGE_KEY_SERVER = "mattermostServer";
const RE_SERVER_ITEM = new RegExp(`^${LOCALSTORAGE_KEY_SERVER}_(.*)\$`, "");
const Storage = {
getServers() {
let servers = [];
for (var i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i);
const matches = key.match(RE_SERVER_ITEM);
if (matches) {
const endpoint = matches[1];
console.debug(`Found logged in endpoint ${endpoint}`);
let stored = JSON.parse(window.localStorage.getItem(Storage._key_for(endpoint)));
servers.push({...stored, endpoint});
}
}
return servers;
},
_key_for(endpoint) {
return `${LOCALSTORAGE_KEY_SERVER}_${endpoint}`;
},
clear(endpoint) {
window.localStorage.removeItem(Storage._key_for(endpoint));
},
store(endpoint, login_id, token) {
window.localStorage.setItem(Storage._key_for(endpoint), JSON.stringify({login_id, token}));
},
get(endpoint) {
return JSON.parse(window.localStorage.getItem(Storage._key_for(endpoint)) || "null");
}
}
class MattermostApi {
constructor(endpoint) {
this._endpoint = endpoint;
}
get id() {
return this._endpoint;
}
async get(path, token, queryParams) {
const headers = token ? {"Authorization": `Bearer ${token}`} : {};
const response = await ajax.getJson(`${this._endpoint}${path}`, {headers, queryParams});
if (!response.ok) {
throw response;
}
return response;
}
async post(path, token, data) {
const headers = token ? {"Authorization": `Bearer ${token}`} : {};
const response = await ajax.postJson(`${this._endpoint}${path}`, data, {headers});
if (!response.ok) {
throw response;
}
return response;
}
}
class MattermostClient {
constructor (api, storage) {
this.api = api;
this.storage = storage;
}
async logIn(login_id, password) {
const response = await this.api.post("/users/login", undefined, {login_id, password});
const token = response.getResponseHeader("Token");
if (!token) {
throw Error("No Token header in response to log in request");
}
this.storage.store(this.api.id, login_id, token);
return response.responseJson;
}
async logOut() {
const stored = this.storage.get(this.api.id);
if (!stored || !stored.token) {
throw Error("No token stored");
}
const response = await this.api.post("/users/logout", stored.token);
// Verify that the token is now invalidated
let tokenWorks;
try {
const meResponse = await this.usersMe();
tokenWorks = true;
} catch (e) {
if (e instanceof ajax.NotOkError && e.xhr.status == 401) {
tokenWorks = false;
} else {
throw e;
}
}
if (tokenWorks) {
throw new Error("Failed to log out: token still works after trying to log out");
}
this.storage.clear(this.api.id);
return response.responseJson;
}
async usersMe() {
const stored = this.storage.get(this.api.id);
if (!stored || !stored.token) {
throw Error("No token stored");
}
const response = await this.api.get("/users/me", stored.token);
return response.responseJson;
}
async myTeams() {
const stored = this.storage.get(this.api.id);
const response = await this.api.get("/users/me/teams", stored.token);
return response.responseJson;
}
async myChannels(team_id) {
const stored = this.storage.get(this.api.id);
const response = await this.api.get(`/users/me/teams/${team_id}/channels`, stored.token);
return response.responseJson;
}
async channelPosts(channel_id, beforePost=null, afterPost=null, since=null) {
const stored = this.storage.get(this.api.id);
const response = await this.api.get(`/channels/${channel_id}/posts`, stored.token, {
before: beforePost, after: afterPost, since
});
return response.responseJson;
}
}
/**
* Return an endpoint URL that has a protocol, domain and path
*/
function normalizedEndpoint(endpoint) {
let matches = endpoint.match(/^(https?:\/\/)?([^\/]+)(\/.*)?$/i);
if (!matches) throw Error("Invalid endpoint URL");
let protocol = matches[1] || "https://";
let domain = matches[2];
let path = matches[3] || "/api/v4";
return `${protocol}${domain}${path}`;
}
function humanReadableEndpoint(endpoint) {
let matches = endpoint.match(/^(https?:\/\/.+)\/api\/v4$/i);
if (!matches) throw Error("Invalid endpoint URL");
return matches[1];
}
function createClient(endpoint) {
const api = new MattermostApi(normalizedEndpoint(endpoint));
return new MattermostClient(api, Storage);
}
function buttonDisable(element, text) {
if (!element.dataset.originalText) {
element.dataset.originalText = element.innerText;
}
element.innerText = text;
element.disabled = true;
}
function buttonEnable(element) {
element.innerText = element.dataset.originalText;
element.disabled = false;
}
function populateServerSelectionList() {
const servers = Storage.getServers();
let nodes = [];
for (let server of servers) {
const li = document.createElement("li");
const endpoint = humanReadableEndpoint(server.endpoint);
li.innerText = `${server.login_id}@${endpoint} `;
const logoutButton = document.createElement("button");
logoutButton.className = "logout";
logoutButton.innerText = "Log out";
logoutButton.addEventListener("click", e => logOut(endpoint, e.currentTarget));
li.appendChild(logoutButton);
nodes.push(li);
}
byId("server_selection_list").innerHTML = "";
byId("server_selection_list").append(...nodes);
}
populateServerSelectionList();
function populateChannelList() {
async function addChannelItems(endpoint) {
const client = createClient(endpoint);
const teams = await client.myTeams();
console.log(teams);
for (let team of teams) {
let nodes = [];
const channels = await client.myChannels(team.id);
console.log(channels);
for (let channel of channels) {
const li = document.createElement("li");
const a = document.createElement("a");
a.href = "javascript:void(0)";
const titleAndElements = channelNameElements(team, channel);
if (!titleAndElements) continue;
a.title = titleAndElements[0];
a.append(...titleAndElements[1]);
a.addEventListener("click", () => switchToChannel(client, team, channel));
li.appendChild(a);
li.dataset["id"] = channel.id;
nodes.push(li);
}
byId("channel_list").append(...nodes);
}
}
const servers = Storage.getServers();
byId("channel_list").innerHTML = "";
for (let server of servers) {
addChannelItems(server.endpoint);
}
}
populateChannelList();
function populateChannelContents(contents) {
byId("channel_contents").innerHTML = "";
let nodes = [];
for (let id of contents.order) {
const post = contents.posts[id];
const isThreadReply = !!post.parent_id;
const messageDiv = document.createElement("div");
messageDiv.className = "message";
messageDiv.innerText = post.message;
const createAt = new Date(post.create_at);
const createAtDiv = document.createElement("time");
createAtDiv.className = "create_at";
createAtDiv.innerText = createAt.toLocaleString("nl-BE");
createAtDiv.dateTime = createAt.toISOString();
const authorDiv = document.createElement("div");
authorDiv.className = "author";
authorDiv.innerText = `Auteur: ${post.user_id}`;
const postDiv = document.createElement("div");
postDiv.className = "post";
postDiv.dataset["id"] = id;
postDiv.appendChild(authorDiv);
postDiv.appendChild(createAtDiv);
postDiv.appendChild(messageDiv);
nodes.unshift(postDiv);
}
byId("channel_contents").append(...nodes);
checkScrolledToBottom();
}
function logIn() {
const client = createClient(byId("login_server").value);
buttonDisable(byId("login_button"), "Logging in...");
client.logIn(byId("login_login_id").value, byId("login_password").value)
.then(json => {
buttonEnable(byId("login_button"));
byId("login_message").innerText = "";
byId("channel_list").innerText = `Logged in as ${json.username}`;
window.location = "#";
populateServerSelectionList();
populateChannelList();
})
.catch(error => {
console.error(error);
buttonEnable(byId("login_button"));
byId("login_message").innerText = `${error}`;
});
}
function logOut(endpoint, button) {
const client = createClient(endpoint);
buttonDisable(button, "Logging out...");
client.logOut()
.then(response => {
console.info("Succesfully logged out");
populateServerSelectionList();
populateChannelList();
})
.catch(error => {
console.error(error);
const span = document.createElement("span");
span.innerText = `Failed to log out: ${error.message}`;
button.parentElement.appendChild(span);
buttonEnable(button);
});
}
function channelNameElements(team, channel) {
let icon = "";
let teamName = team.name;
let channelName = channel.name;
let title = "";
switch (channel.type) {
case "O": // Public channel
title = `${channel.name} in team ${team.name} (public)`;
break;
case "P": // Private channel
icon = "🔒";
title = `${channel.name} in team ${team.name} (private)`;
break;
case "D": // Direct message
return undefined;
teamName = "";
channelName = `👤 ...`;
title = `Direct message`;
break;
case "G": // Group chat
teamName = "";
channelName = `👥 ${channel.display_name}`;
title = `Group chat with ${channel.display_name}`;
break;
default: // Unsupported
icon = channel.type;
title = `${channel.name} in team ${team.name} (type ${channel.type})`;
break;
}
let elements = [];
if (icon) {
elements.push(`${icon} `);
}
if (teamName) {
const teamElement = document.createElement("span");
teamElement.className = "team-name";
teamElement.innerText = teamName;
elements.push(teamElement);
const separatorElement = document.createElement("span");
separatorElement.className = "separator";
separatorElement.innerText = "/";
elements.push(separatorElement);
}
const channelElement = document.createElement("span");
channelElement.className = "channel-name";
channelElement.innerText = channelName;
elements.push(channelElement);
return [title, elements];
}
function switchToChannel(client, team, channel) {
for (let el of byId("channel_list").childNodes) {
if (el.dataset["id"] == channel.id) {
el.className = "active";
} else {
el.className = "";
}
}
byId("channel_contents").innerText = "Loading…";
client.channelPosts(channel.id)
.then(response => {
console.info(`Got channel contents of ${channel.id} (${channel.name})`);
populateChannelContents(response);
})
.catch(error => {
console.error(error);
byId("channel_contents").innerText = `Failed to get channel contents:\n${error.message}`;
});
const [title, elements] = channelNameElements(team, channel);
byId("channel_header").innerHTML = "";
byId("channel_header").append(...elements);
byId("compose").setAttribute("placeholder", `Write to ${byId("channel_header").textContent}`);
}
function updateComposeHeight() {
byId("compose").style.height = "";
byId("compose").style.height = (byId("compose").scrollHeight + 1) + "px";
}
byId("compose").addEventListener("input", updateComposeHeight);
updateComposeHeight();
function checkScrolledToBottom() {
const el = byId("channel_contents_wrapper");
const scrolledTo = el.clientHeight + el.scrollTop;
const scrollHeight = el.scrollHeight
const atBottom = scrolledTo >= scrollHeight;
el.className = atBottom ? "" : "not-at-bottom";
}
byId("channel_contents_wrapper").addEventListener("scroll", checkScrolledToBottom);
checkScrolledToBottom();
byId("login_button").addEventListener("click", logIn); byId("login_button").addEventListener("click", logIn);
updateComposeHeight();
checkScrolledToBottom();
populateServerSelectionList();
populateChannelList();

39
model/credentials.js Normal file
View file

@ -0,0 +1,39 @@
const credentials = (function() { "use strict";
const LOCALSTORAGE_KEY_SERVER = "mattermostServer";
const RE_SERVER_ITEM = new RegExp(`^${LOCALSTORAGE_KEY_SERVER}_(.*)\$`, "");
function key_for(endpoint) {
return `${LOCALSTORAGE_KEY_SERVER}_${endpoint}`;
};
return {
getServers() {
let servers = [];
for (var i = 0; i < window.localStorage.length; i++) {
const key = window.localStorage.key(i);
const matches = key.match(RE_SERVER_ITEM);
if (matches) {
const endpoint = matches[1];
console.debug(`Found logged in endpoint ${endpoint}`);
let stored = JSON.parse(window.localStorage.getItem(key_for(endpoint)));
servers.push({...stored, endpoint});
}
}
return servers;
},
clear(endpoint) {
window.localStorage.removeItem(key_for(endpoint));
},
store(endpoint, login_id, token) {
window.localStorage.setItem(key_for(endpoint), JSON.stringify({login_id, token}));
},
get(endpoint) {
return JSON.parse(window.localStorage.getItem(key_for(endpoint)) || "null");
}
};
})();

107
model/mm_client.js Normal file
View file

@ -0,0 +1,107 @@
const mm_client = (function() { "use strict";
class MattermostApi {
constructor(endpoint) {
this._endpoint = endpoint;
}
get id() {
return this._endpoint;
}
async get(path, token, queryParams) {
const headers = token ? {"Authorization": `Bearer ${token}`} : {};
const response = await ajax.getJson(`${this._endpoint}${path}`, {headers, queryParams});
if (!response.ok) {
throw response;
}
return response;
}
async post(path, token, data) {
const headers = token ? {"Authorization": `Bearer ${token}`} : {};
const response = await ajax.postJson(`${this._endpoint}${path}`, data, {headers});
if (!response.ok) {
throw response;
}
return response;
}
}
class MattermostClient {
constructor (api) {
this.api = api;
}
async logIn(login_id, password) {
const response = await this.api.post("/users/login", undefined, {login_id, password});
const token = response.getResponseHeader("Token");
if (!token) {
throw Error("No Token header in response to log in request");
}
credentials.store(this.api.id, login_id, token);
return response.responseJson;
}
async logOut() {
const stored = credentials.get(this.api.id);
if (!stored || !stored.token) {
throw Error("No token stored");
}
const response = await this.api.post("/users/logout", stored.token);
// Verify that the token is now invalidated
let tokenWorks;
try {
const meResponse = await this.usersMe();
tokenWorks = true;
} catch (e) {
if (e instanceof ajax.NotOkError && e.xhr.status == 401) {
tokenWorks = false;
} else {
throw e;
}
}
if (tokenWorks) {
throw new Error("Failed to log out: token still works after trying to log out");
}
credentials.clear(this.api.id);
return response.responseJson;
}
async usersMe() {
const stored = credentials.get(this.api.id);
if (!stored || !stored.token) {
throw Error("No token stored");
}
const response = await this.api.get("/users/me", stored.token);
return response.responseJson;
}
async myTeams() {
const stored = credentials.get(this.api.id);
const response = await this.api.get("/users/me/teams", stored.token);
return response.responseJson;
}
async myChannels(team_id) {
const stored = credentials.get(this.api.id);
const response = await this.api.get(`/users/me/teams/${team_id}/channels`, stored.token);
return response.responseJson;
}
async channelPosts(channel_id, beforePost=null, afterPost=null, since=null) {
const stored = credentials.get(this.api.id);
const response = await this.api.get(`/channels/${channel_id}/posts`, stored.token, {
before: beforePost, after: afterPost, since
});
return response.responseJson;
}
}
return {MattermostApi, MattermostClient};
})();

46
util.js Normal file
View file

@ -0,0 +1,46 @@
/**
* Return an endpoint URL that has a protocol, domain and path
*/
function normalizedEndpoint(endpoint) {
let matches = endpoint.match(/^(https?:\/\/)?([^\/]+)(\/.*)?$/i);
if (!matches) throw Error("Invalid endpoint URL");
let protocol = matches[1] || "https://";
let domain = matches[2];
let path = matches[3] || "/api/v4";
return `${protocol}${domain}${path}`;
}
/**
* Return the endpoint as it should be shown to the user
*/
function humanReadableEndpoint(endpoint) {
let matches = endpoint.match(/^(https?:\/\/.+)\/api\/v4$/i);
if (!matches) throw Error("Invalid endpoint URL");
return matches[1];
}
/**
* Shorthand for document.getElementById
*/
function byId(id, nullOk=false) {
const el = document.getElementById(id);
if (!el && !nullOk) {
console.error(`No element #${id}`);
}
return el;
}
/**
* Wrap a function so that it receives `this` as first argument
*/
function thisToArg(f) {
return function(...rest) {
return f(this, ...rest);
}
}

113
view/view.js Normal file
View file

@ -0,0 +1,113 @@
function populateServerSelectionList() {
const servers = credentials.getServers();
let nodes = [];
for (let server of servers) {
const li = document.createElement("li");
const endpoint = humanReadableEndpoint(server.endpoint);
li.innerText = `${server.login_id}@${endpoint} `;
const logoutButton = document.createElement("button");
logoutButton.className = "logout";
logoutButton.innerText = "Log out";
logoutButton.addEventListener("click", e => logOut(endpoint, e.currentTarget));
li.appendChild(logoutButton);
nodes.push(li);
}
byId("server_selection_list").innerHTML = "";
byId("server_selection_list").append(...nodes);
}
function populateChannelList() {
async function addChannelItems(endpoint) {
const client = createClient(endpoint);
const teams = await client.myTeams();
console.log(teams);
for (let team of teams) {
let nodes = [];
const channels = await client.myChannels(team.id);
console.log(channels);
for (let channel of channels) {
const li = document.createElement("li");
const a = document.createElement("a");
a.href = "javascript:void(0)";
const titleAndElements = channelNameElements(team, channel);
if (!titleAndElements) continue;
a.title = titleAndElements[0];
a.append(...titleAndElements[1]);
a.addEventListener("click", () => switchToChannel(client, team, channel));
li.appendChild(a);
li.dataset["id"] = channel.id;
nodes.push(li);
}
byId("channel_list").append(...nodes);
}
}
const servers = credentials.getServers();
byId("channel_list").innerHTML = "";
for (let server of servers) {
addChannelItems(server.endpoint);
}
}
function populateChannelContents(contents) {
byId("channel_contents").innerHTML = "";
let nodes = [];
for (let id of contents.order) {
const post = contents.posts[id];
const isThreadReply = !!post.parent_id;
const messageDiv = document.createElement("div");
messageDiv.className = "message";
messageDiv.innerText = post.message;
const createAt = new Date(post.create_at);
const createAtDiv = document.createElement("time");
createAtDiv.className = "create_at";
createAtDiv.innerText = createAt.toLocaleString("nl-BE");
createAtDiv.dateTime = createAt.toISOString();
const authorDiv = document.createElement("div");
authorDiv.className = "author";
authorDiv.innerText = `Auteur: ${post.user_id}`;
const postDiv = document.createElement("div");
postDiv.className = "post";
postDiv.dataset["id"] = id;
postDiv.appendChild(authorDiv);
postDiv.appendChild(createAtDiv);
postDiv.appendChild(messageDiv);
nodes.unshift(postDiv);
}
byId("channel_contents").append(...nodes);
checkScrolledToBottom();
}
function updateComposeHeight() {
byId("compose").style.height = "";
byId("compose").style.height = (byId("compose").scrollHeight + 1) + "px";
}
byId("compose").addEventListener("input", updateComposeHeight);
function checkScrolledToBottom() {
const el = byId("channel_contents_wrapper");
const scrolledTo = el.clientHeight + el.scrollTop;
const scrollHeight = el.scrollHeight
const atBottom = scrolledTo >= scrollHeight;
el.className = atBottom ? "" : "not-at-bottom";
}
byId("channel_contents_wrapper").addEventListener("scroll", checkScrolledToBottom);