Start transitioning to MVC
This commit is contained in:
parent
77b1d465a3
commit
4db66bb8c0
8 changed files with 457 additions and 434 deletions
9
ajax.js
9
ajax.js
|
@ -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
142
controller/controller.js
Normal 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}`);
|
||||||
|
}
|
|
@ -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
430
main.js
|
@ -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
39
model/credentials.js
Normal 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
107
model/mm_client.js
Normal 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
46
util.js
Normal 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
113
view/view.js
Normal 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);
|
Loading…
Reference in a new issue