Add pubsub for new message, no websocket though yet

This commit is contained in:
Midgard 2021-02-17 16:07:15 +01:00
parent 1419b44acf
commit de201dca56
Signed by: midgard
GPG key ID: 511C112F1331BBB4
6 changed files with 214 additions and 131 deletions

View file

@ -63,6 +63,7 @@
<script type="text/javascript" src="/js/ajax.js"></script> <script type="text/javascript" src="/js/ajax.js"></script>
<script type="text/javascript" src="/js/util.js"></script> <script type="text/javascript" src="/js/util.js"></script>
<script type="text/javascript" src="/js/pubsub.js"></script>
<script type="text/javascript" src="/js/localstorage_credentials.js"></script> <script type="text/javascript" src="/js/localstorage_credentials.js"></script>
<script type="text/javascript" src="/js/mm_client.js"></script> <script type="text/javascript" src="/js/mm_client.js"></script>
<script type="text/javascript" src="/js/view/view.js"></script> <script type="text/javascript" src="/js/view/view.js"></script>

View file

@ -1,9 +1,5 @@
"use strict"; "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) { function buttonDisable(element, text) {
if (!element.dataset.originalText) { if (!element.dataset.originalText) {
@ -18,7 +14,7 @@ function buttonEnable(element) {
} }
function logIn() { 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..."); buttonDisable(byId("login_button"), "Logging in...");
@ -39,7 +35,7 @@ function logIn() {
} }
function logOut(endpoint, button) { function logOut(endpoint, button) {
const client = createClient(endpoint); const client = mm_client.get(endpoint);
buttonDisable(button, "Logging out..."); buttonDisable(button, "Logging out...");
@ -56,6 +52,8 @@ function logOut(endpoint, button) {
button.parentElement.appendChild(span); button.parentElement.appendChild(span);
buttonEnable(button); buttonEnable(button);
}); });
mm_client.drop(endpoint);
} }
function channelNameElements(team, channel) { function channelNameElements(team, channel) {
@ -73,7 +71,7 @@ function channelNameElements(team, channel) {
title = `${channel.name} in team ${team.name} (private)`; title = `${channel.name} in team ${team.name} (private)`;
break; break;
case "D": // Direct message case "D": // Direct message
return undefined; return undefined; // XXX Because they clutter the list
teamName = ""; teamName = "";
channelName = `👤 ...`; channelName = `👤 ...`;
title = `Direct message`; title = `Direct message`;
@ -129,7 +127,7 @@ function switchToChannel(client, team, channel) {
.then(response => { .then(response => {
console.info(`Got channel contents of ${channel.id} (${channel.name})`); console.info(`Got channel contents of ${channel.id} (${channel.name})`);
response.order.reverse(); response.order.reverse();
populateChannelContents(client, response); populateChannelContents(client, channel, response);
}) })
.catch(error => { .catch(error => {
console.error(error); console.error(error);

View file

@ -9,19 +9,9 @@ checkScrolledToBottom();
populateServerSelectionList(); populateServerSelectionList();
function fetchUsernames(endpoint) { localstorage_credentials.getServers()
console.debug(endpoint); .map(server => server.endpoint)
const client = createClient(endpoint).users(); .map(mm_client.get)
return client; .forEach(client => client.getUsers());
}
let users = {}; populateChannelList();
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();
});

View file

@ -1,22 +1,19 @@
const mm_client = (function() { "use strict"; const mm_client = (function() { "use strict";
class MattermostApi { class MattermostClient {
constructor(endpoint) { constructor (endpoint, credentials_provider) {
this._endpoint = endpoint; this.endpoint = endpoint;
}
get id() { this.credentials = credentials_provider;
return this._endpoint; 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"}`);
get endpoint() {
return this._endpoint;
} }
async get(path, token, queryParams) { async get(path, token, queryParams) {
const headers = token ? {"Authorization": `Bearer ${token}`} : {}; 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) { if (!response.ok) {
throw response; throw response;
} }
@ -25,27 +22,16 @@ class MattermostApi {
async post(path, token, data) { async post(path, token, data) {
const headers = token ? {"Authorization": `Bearer ${token}`} : {}; 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) { if (!response.ok) {
throw response; throw response;
} }
return 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) { async authedGet(url, queryParams) {
assert(this.token, "logged in"); 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; return response.responseJson;
} }
@ -64,8 +50,7 @@ class MattermostClient {
throw new Error("Requesting more than 100 pages, something looks wrong"); throw new Error("Requesting more than 100 pages, something looks wrong");
} }
const response = await this.api.get(url, this.token, params); const response = await this.get(url, this.token, params);
console.log(response);
data = data.concat(response.responseJson); data = data.concat(response.responseJson);
loadMore = response.responseJson.length > 0; loadMore = response.responseJson.length > 0;
@ -96,26 +81,27 @@ class MattermostClient {
throw Error("Already logged in on this server"); 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"); const token = response.getResponseHeader("Token");
if (!token) { if (!token) {
throw Error("No Token header in response to log in request"); 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; this.token = token;
let _ = this.getUsers();
return response.responseJson; return response.responseJson;
} }
async logOut() { async logOut() {
assert(this.token, "logged in"); 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 // Verify that the token is now invalidated
if (await this.loggedIn()) { if (await this.loggedIn()) {
throw new Error("Failed to log out: token still works after trying to log out"); 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; this.token = null;
return response.responseJson; return response.responseJson;
} }
@ -141,8 +127,18 @@ class MattermostClient {
}); });
} }
users() { getUsers() {
return this.authedPaginatedGet("/users", {}); 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) { 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};
})(); })();

44
js/pubsub.js Normal file
View file

@ -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};
})();

View file

@ -20,9 +20,7 @@ function populateServerSelectionList() {
} }
function populateChannelList() { function populateChannelList() {
async function addChannelItems(endpoint) { async function addChannelItems(client) {
const client = createClient(endpoint);
const teams = await client.myTeams(); const teams = await client.myTeams();
for (let team of teams) { for (let team of teams) {
@ -47,92 +45,118 @@ function populateChannelList() {
} }
} }
const servers = localstorage_credentials.getServers();
byId("channel_list").innerHTML = ""; byId("channel_list").innerHTML = "";
for (let server of servers) { const endpoints = localstorage_credentials.getServers().map(server => server.endpoint);
addChannelItems(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 = ""; byId("channel_contents").innerHTML = "";
let lastAuthor = null; let result = {lastAuthor: null, lastTime: null, postDiv: null};
let lastTime = null;
let nodes = []; let nodes = [];
for (let id of contents.order) { for (let id of contents.order) {
const post = contents.posts[id]; result = await createMessageElement(client, contents.posts[id], result.lastTime, result.lastAuthor);
nodes.push(result.postDiv);
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);
} }
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); byId("channel_contents").append(...nodes);
scrollToBottom(); 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() { function scrollToBottom() {
const el = byId("channel_contents_wrapper"); const el = byId("channel_contents_wrapper");
el.scrollTop = el.scrollHeight; el.scrollTop = el.scrollHeight;
@ -146,11 +170,23 @@ function updateComposeHeight() {
} }
byId("compose").addEventListener("input", updateComposeHeight); byId("compose").addEventListener("input", updateComposeHeight);
function checkScrolledToBottom() { function isScrolledToBottom() {
const el = byId("channel_contents_wrapper"); const el = byId("channel_contents_wrapper");
const scrolledTo = el.clientHeight + el.scrollTop; const scrolledTo = el.clientHeight + el.scrollTop;
const atBottom = scrolledTo >= el.scrollHeight; return scrolledTo >= el.scrollHeight;
el.className = atBottom ? "" : "not-at-bottom"; }
function checkScrolledToBottom() {
byId("channel_contents_wrapper").className = isScrolledToBottom() ? "" : "not-at-bottom";
} }
byId("channel_contents_wrapper").addEventListener("scroll", checkScrolledToBottom); 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);
}
});