diff --git a/ajax.js b/ajax.js index a927b43..8f83977 100644 --- a/ajax.js +++ b/ajax.js @@ -14,15 +14,6 @@ class InvalidJsonError extends AjaxError {} 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) { const t_resolve = thisToArg(resolve), t_reject = thisToArg(reject); diff --git a/controller/controller.js b/controller/controller.js new file mode 100644 index 0000000..c41977e --- /dev/null +++ b/controller/controller.js @@ -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}`); +} diff --git a/index.html b/index.html index 68c27f6..035ee19 100644 --- a/index.html +++ b/index.html @@ -63,6 +63,11 @@ + + + + + diff --git a/main.js b/main.js index f15e3a7..6639037 100644 --- a/main.js +++ b/main.js @@ -1,428 +1,8 @@ "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); + +updateComposeHeight(); +checkScrolledToBottom(); +populateServerSelectionList(); +populateChannelList(); diff --git a/model/credentials.js b/model/credentials.js new file mode 100644 index 0000000..4cb1339 --- /dev/null +++ b/model/credentials.js @@ -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"); + } +}; + +})(); diff --git a/model/mm_client.js b/model/mm_client.js new file mode 100644 index 0000000..1006772 --- /dev/null +++ b/model/mm_client.js @@ -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}; + +})(); diff --git a/util.js b/util.js new file mode 100644 index 0000000..6e289eb --- /dev/null +++ b/util.js @@ -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); + } +} + diff --git a/view/view.js b/view/view.js new file mode 100644 index 0000000..d9314b6 --- /dev/null +++ b/view/view.js @@ -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);