diff --git a/index.html b/index.html index 1883871..f32160b 100644 --- a/index.html +++ b/index.html @@ -63,6 +63,7 @@ + diff --git a/js/controller.js b/js/controller.js index 8103d7c..5e7dbb1 100644 --- a/js/controller.js +++ b/js/controller.js @@ -1,9 +1,5 @@ "use strict"; -function createClient(endpoint) { - const api = new mm_client.MattermostApi(normalizedEndpoint(endpoint)); - return new mm_client.MattermostClient(api, localstorage_credentials); -} function buttonDisable(element, text) { if (!element.dataset.originalText) { @@ -18,7 +14,7 @@ function buttonEnable(element) { } function logIn() { - const client = createClient(byId("login_server").value); + const client = mm_client.get(normalizedEndpoint(byId("login_server").value)); buttonDisable(byId("login_button"), "Logging in..."); @@ -39,7 +35,7 @@ function logIn() { } function logOut(endpoint, button) { - const client = createClient(endpoint); + const client = mm_client.get(endpoint); buttonDisable(button, "Logging out..."); @@ -56,6 +52,8 @@ function logOut(endpoint, button) { button.parentElement.appendChild(span); buttonEnable(button); }); + + mm_client.drop(endpoint); } function channelNameElements(team, channel) { @@ -73,7 +71,7 @@ function channelNameElements(team, channel) { title = `${channel.name} in team ${team.name} (private)`; break; case "D": // Direct message - return undefined; + return undefined; // XXX Because they clutter the list teamName = ""; channelName = `👤 ...`; title = `Direct message`; @@ -129,7 +127,7 @@ function switchToChannel(client, team, channel) { .then(response => { console.info(`Got channel contents of ${channel.id} (${channel.name})`); response.order.reverse(); - populateChannelContents(client, response); + populateChannelContents(client, channel, response); }) .catch(error => { console.error(error); diff --git a/js/main.js b/js/main.js index d3973a0..3452cf8 100644 --- a/js/main.js +++ b/js/main.js @@ -9,19 +9,9 @@ checkScrolledToBottom(); populateServerSelectionList(); -function fetchUsernames(endpoint) { - console.debug(endpoint); - const client = createClient(endpoint).users(); - return client; -} +localstorage_credentials.getServers() + .map(server => server.endpoint) + .map(mm_client.get) + .forEach(client => client.getUsers()); -let users = {}; -Promise.all(localstorage_credentials.getServers().map(server => server.endpoint).map(fetchUsernames)).then(server_user_data => { - for (let user_data of server_user_data) { - for (let id of Object.getOwnPropertyNames(user_data)) { - const user = user_data[id]; - users[user.id] = user; - } - } - populateChannelList(); -}); +populateChannelList(); diff --git a/js/mm_client.js b/js/mm_client.js index a85b76c..81fe9c1 100644 --- a/js/mm_client.js +++ b/js/mm_client.js @@ -1,22 +1,19 @@ const mm_client = (function() { "use strict"; -class MattermostApi { - constructor(endpoint) { - this._endpoint = endpoint; - } +class MattermostClient { + constructor (endpoint, credentials_provider) { + this.endpoint = endpoint; - get id() { - return this._endpoint; - } - - get endpoint() { - return this._endpoint; + this.credentials = credentials_provider; + const creds = this.credentials.get(this.endpoint); + this.token = creds ? creds.token : null; + console.info(`Created MattermostClient for ${this.endpoint}, ${this.token ? "found token" : "did not find token"}`); } async get(path, token, queryParams) { const headers = token ? {"Authorization": `Bearer ${token}`} : {}; - const response = await ajax.getJson(`${this._endpoint}${path}`, {headers, queryParams}); + const response = await ajax.getJson(`${this.endpoint}${path}`, {headers, queryParams}); if (!response.ok) { throw response; } @@ -25,27 +22,16 @@ class MattermostApi { async post(path, token, data) { const headers = token ? {"Authorization": `Bearer ${token}`} : {}; - const response = await ajax.postJson(`${this._endpoint}${path}`, data, {headers}); + const response = await ajax.postJson(`${this.endpoint}${path}`, data, {headers}); if (!response.ok) { throw response; } return response; } -} - - -class MattermostClient { - constructor (api, credentials_provider) { - this.api = api; - this.credentials = credentials_provider; - const creds = this.credentials.get(this.api.id); - this.token = creds ? creds.token : null; - console.info(`Created MattermostClient for ${this.api.id}, ${this.token ? "found token" : "did not find token"}`); - } async authedGet(url, queryParams) { assert(this.token, "logged in"); - const response = await this.api.get(url, this.token, queryParams); + const response = await this.get(url, this.token, queryParams); return response.responseJson; } @@ -64,8 +50,7 @@ class MattermostClient { throw new Error("Requesting more than 100 pages, something looks wrong"); } - const response = await this.api.get(url, this.token, params); - console.log(response); + const response = await this.get(url, this.token, params); data = data.concat(response.responseJson); loadMore = response.responseJson.length > 0; @@ -96,26 +81,27 @@ class MattermostClient { throw Error("Already logged in on this server"); } - const response = await this.api.post("/users/login", undefined, {login_id, password}); + const response = await this.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.credentials.store(this.api.id, login_id, token); + this.credentials.store(this.endpoint, login_id, token); this.token = token; + let _ = this.getUsers(); return response.responseJson; } async logOut() { assert(this.token, "logged in"); - const response = await this.api.post("/users/logout", this.token); + const response = await this.post("/users/logout", this.token); // Verify that the token is now invalidated if (await this.loggedIn()) { throw new Error("Failed to log out: token still works after trying to log out"); } - this.credentials.clear(this.api.id); + this.credentials.clear(this.endpoint); this.token = null; return response.responseJson; } @@ -141,8 +127,18 @@ class MattermostClient { }); } - users() { - return this.authedPaginatedGet("/users", {}); + getUsers() { + if (!this.users) { + this.users = this.authedPaginatedGet("/users", {}).then(users => { + const newUsers = Object.create(null); + for (let user_data of users) { + const user = user_data; + newUsers[user.id] = user; + } + return newUsers; + }); + } + return this.users; } async filePublicLink(file_id) { @@ -151,6 +147,24 @@ class MattermostClient { } } -return {MattermostApi, MattermostClient}; + +let clients = Object.create(null); + +function get(endpoint) { + if (!clients[endpoint]) { + clients[endpoint] = new MattermostClient(endpoint, localstorage_credentials); + } + return clients[endpoint]; +} + +function getMultiple(endpoints) { + return endpoints.map(get); +} + +function drop(endpoint) { + delete clients[endpoint]; +} + +return {get, getMultiple, drop}; })(); diff --git a/js/pubsub.js b/js/pubsub.js new file mode 100644 index 0000000..95a7058 --- /dev/null +++ b/js/pubsub.js @@ -0,0 +1,44 @@ +const pubsub = (function() { "use strict"; + +const topics = [ + "MESSAGES_NEW", + "MESSAGES_CHANGED", + "CHANNELS_NEW", + "CHANNELS_CHANGED", + "USERS_NEW", + "USERS_CHANGED", + "CHANNEL_MEMBERS_NEW", + "CHANNEL_MEMBERS_REMOVED", +]; + +let subscribers = Object.create(null); +for (let topic of topics) { + subscribers[topic] = []; +} + + +function subscribe(topic, callback) { + assert(subscribers[topic], `Pubsub topic '${topic}' exists`); + + subscribers[topic].push(callback); +} + + +function publish(topic, payload) { + assert(subscribers[topic], `Pubsub topic '${topic}' exists`); + + const amountSubs = subscribers[topic].length; + if (amountSubs == 0) { + console.warn(`Pubsub event with topic '${topic}' without any subscribers`); + } else { + console.debug(`Pubsub event with topic '${topic}' with ${amountSubs} subscribers`); + } + for (let subscriber of subscribers[topic]) { + subscriber(payload); + } +} + + +return {subscribe, publish}; + +})(); diff --git a/js/view/view.js b/js/view/view.js index 4df7255..6191e59 100644 --- a/js/view/view.js +++ b/js/view/view.js @@ -20,9 +20,7 @@ function populateServerSelectionList() { } function populateChannelList() { - async function addChannelItems(endpoint) { - const client = createClient(endpoint); - + async function addChannelItems(client) { const teams = await client.myTeams(); for (let team of teams) { @@ -47,92 +45,118 @@ function populateChannelList() { } } - const servers = localstorage_credentials.getServers(); - byId("channel_list").innerHTML = ""; - for (let server of servers) { - addChannelItems(server.endpoint); + const endpoints = localstorage_credentials.getServers().map(server => server.endpoint); + for (let client of mm_client.getMultiple(endpoints)) { + addChannelItems(client); } } -function populateChannelContents(client, contents) { +async function createMessageElement(client, post, lastTime, lastAuthor) { + const users = await client.getUsers(); + 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"; + const sim = dateSimilarity(lastTime, createAt); + lastTime = createAt; + let createAtText = ""; + if (sim < DATE_SIMILARITY.date) createAtText += formatDdddMmYy(createAt); + if (sim < DATE_SIMILARITY.minutes) createAtText += " " + formatHhMm(createAt); + createAtDiv.title = createAt.toString(); + createAtDiv.innerText = createAtText; + createAtDiv.dateTime = createAt.toISOString(); + + const authorName = users[post.user_id] ? users[post.user_id].username : post.user_id; + const authorDiv = document.createElement("div"); + authorDiv.className = "author"; + authorDiv.innerText = authorName; + + const postDiv = document.createElement("div"); + postDiv.className = "post"; + if (lastAuthor === post.user_id) { + postDiv.className += " same_author"; + } + lastAuthor = post.user_id; + + postDiv.dataset["id"] = post.id; + postDiv.appendChild(authorDiv); + postDiv.appendChild(createAtDiv); + postDiv.appendChild(messageDiv); + + if ((post.metadata.files || []).length > 0) { + const attachmentsUl = document.createElement("ul"); + attachmentsUl.className = "attachments"; + for (let file of post.metadata.files || []) { + const attachmentLi = document.createElement("li"); + attachmentLi.dataset["id"] = file.id; + + const attachmentA = document.createElement("a"); + client.filePublicLink(file.id).then(link => attachmentA.href = link); + attachmentA.target = "_blank"; + attachmentA.innerText = file.name; + + if (file.mini_preview) { + const attachmentImg = document.createElement("img"); + attachmentImg.src = `data:image/jpeg;base64,${file.mini_preview}`; + attachmentA.appendChild(attachmentImg); + } + + attachmentLi.appendChild(attachmentA); + attachmentsUl.appendChild(attachmentLi); + } + postDiv.appendChild(attachmentsUl); + } + + return {postDiv, lastTime, lastAuthor}; +} + + +async function populateChannelContents(client, channel, contents) { byId("channel_contents").innerHTML = ""; - let lastAuthor = null; - let lastTime = null; + let result = {lastAuthor: null, lastTime: null, postDiv: null}; 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"; - const sim = dateSimilarity(lastTime, createAt); - lastTime = createAt; - let createAtText = ""; - if (sim < DATE_SIMILARITY.date) createAtText += formatDdddMmYy(createAt); - if (sim < DATE_SIMILARITY.minutes) createAtText += " " + formatHhMm(createAt); - createAtDiv.title = createAt.toString(); - createAtDiv.innerText = createAtText; - createAtDiv.dateTime = createAt.toISOString(); - - const authorName = users[post.user_id] ? users[post.user_id].username : post.user_id; - const authorDiv = document.createElement("div"); - authorDiv.className = "author"; - authorDiv.innerText = authorName; - - const postDiv = document.createElement("div"); - postDiv.className = "post"; - if (lastAuthor === post.user_id) { - postDiv.className += " same_author"; - } - lastAuthor = post.user_id; - - postDiv.dataset["id"] = id; - postDiv.appendChild(authorDiv); - postDiv.appendChild(createAtDiv); - postDiv.appendChild(messageDiv); - - if ((post.metadata.files || []).length > 0) { - const attachmentsUl = document.createElement("ul"); - attachmentsUl.className = "attachments"; - for (let file of post.metadata.files || []) { - const attachmentLi = document.createElement("li"); - attachmentLi.dataset["id"] = file.id; - - const attachmentA = document.createElement("a"); - client.filePublicLink(file.id).then(link => attachmentA.href = link); - attachmentA.target = "_blank"; - attachmentA.innerText = file.name; - - if (file.mini_preview) { - const attachmentImg = document.createElement("img"); - attachmentImg.src = `data:image/jpeg;base64,${file.mini_preview}`; - attachmentA.appendChild(attachmentImg); - } - - attachmentLi.appendChild(attachmentA); - attachmentsUl.appendChild(attachmentLi); - } - postDiv.appendChild(attachmentsUl); - } - - nodes.push(postDiv); + result = await createMessageElement(client, contents.posts[id], result.lastTime, result.lastAuthor); + nodes.push(result.postDiv); } + byId("channel_contents").dataset["server"] = client.endpoint; + byId("channel_contents").dataset["id"] = channel.id; + byId("channel_contents").dataset["lastTime"] = result.lastTime.getTime(); + byId("channel_contents").dataset["lastAuthor"] = result.lastAuthor; byId("channel_contents").append(...nodes); scrollToBottom(); } +async function addMessage(client, post) { + const shouldScroll = isScrolledToBottom(); + + const result = await createMessageElement( + client, post, + new Date(1 * (byId("channel_contents").dataset["lastTime"])), + byId("channel_contents").dataset["lastAuthor"] + ); + console.log(result); + byId("channel_contents").dataset["lastTime"] = result.lastTime.getTime(); + byId("channel_contents").dataset["lastAuthor"] = result.lastAuthor; + byId("channel_contents").appendChild(result.postDiv); + + if (shouldScroll) { + scrollToBottom(); + } +} + + function scrollToBottom() { const el = byId("channel_contents_wrapper"); el.scrollTop = el.scrollHeight; @@ -146,11 +170,23 @@ function updateComposeHeight() { } byId("compose").addEventListener("input", updateComposeHeight); -function checkScrolledToBottom() { +function isScrolledToBottom() { const el = byId("channel_contents_wrapper"); const scrolledTo = el.clientHeight + el.scrollTop; - const atBottom = scrolledTo >= el.scrollHeight; - el.className = atBottom ? "" : "not-at-bottom"; + return scrolledTo >= el.scrollHeight; +} +function checkScrolledToBottom() { + byId("channel_contents_wrapper").className = isScrolledToBottom() ? "" : "not-at-bottom"; } byId("channel_contents_wrapper").addEventListener("scroll", checkScrolledToBottom); + + +pubsub.subscribe("MESSAGES_NEW", post => { + if ( + post.endpoint === byId("channel_contents").dataset["server"] && + post.channel_id === byId("channel_contents").dataset["id"] + ) { + addMessage(mm_client.get(post.endpoint), post); + } +});