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/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/mm_client.js"></script>
<script type="text/javascript" src="/js/view/view.js"></script>

View file

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

View file

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

View file

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

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