From c9b07efc9c815933760b61eff65ff1c5e268249e Mon Sep 17 00:00:00 2001
From: Simon Ser <contact@emersion.fr>
Date: Mon, 29 Jun 2020 09:06:47 +0200
Subject: [PATCH] Implement chathistory support

---
 components/app.js            | 90 ++++++++++++++++++++++++++++++------
 components/scroll-manager.js |  6 +++
 lib/client.js                | 44 +++++++++++++++++-
 lib/irc.js                   | 12 +++++
 4 files changed, 136 insertions(+), 16 deletions(-)

diff --git a/components/app.js b/components/app.js
index 0087a3e..d615222 100644
--- a/components/app.js
+++ b/components/app.js
@@ -11,6 +11,7 @@ import { html, Component, createRef } from "/lib/index.js";
 import { BufferType, Status, Unread } from "/state.js";
 
 const SERVER_BUFFER = "*";
+const CHATHISTORY_PAGE_SIZE = 100;
 
 var messagesCount = 0;
 
@@ -27,6 +28,29 @@ function parseQueryString() {
 	return params;
 }
 
+/* Insert a message in an immutable list of sorted messages. */
+function insertMessage(list, msg) {
+	if (list.length == 0) {
+		return [msg];
+	} else if (list[list.length - 1].tags.time <= msg.tags.time) {
+		return list.concat(msg);
+	}
+
+	var insertBefore = -1;
+	for (var i = 0; i < list.length; i++) {
+		var other = list[i];
+		if (msg.tags.time < other.tags.time) {
+			insertBefore = i;
+			break;
+		}
+	}
+	console.assert(insertBefore >= 0, "");
+
+	list = [ ...list ];
+	list.splice(insertBefore, 0, msg);
+	return list;
+}
+
 export default class App extends Component {
 	client = null;
 	state = {
@@ -44,6 +68,7 @@ export default class App extends Component {
 		buffers: new Map(),
 		activeBuffer: null,
 	};
+	endOfHistory = new Map();
 	buffer = createRef();
 	composer = createRef();
 
@@ -56,6 +81,7 @@ export default class App extends Component {
 		this.handleNickClick = this.handleNickClick.bind(this);
 		this.handleJoinClick = this.handleJoinClick.bind(this);
 		this.autocomplete = this.autocomplete.bind(this);
+		this.handleBufferScrollTop = this.handleBufferScrollTop.bind(this);
 
 		if (window.localStorage && localStorage.getItem("autoconnect")) {
 			var connectParams = JSON.parse(localStorage.getItem("autoconnect"));
@@ -161,21 +187,18 @@ export default class App extends Component {
 		if (!msg.tags) {
 			msg.tags = {};
 		}
-		if (!msg.tags["time"]) {
-			// Format the current time according to ISO 8601
-			var date = new Date();
-			var YYYY = date.getUTCFullYear().toString().padStart(4, "0");
-			var MM = (date.getUTCMonth() + 1).toString().padStart(2, "0");
-			var DD = date.getUTCDate().toString().padStart(2, "0");
-			var hh = date.getUTCHours().toString().padStart(2, "0");
-			var mm = date.getUTCMinutes().toString().padStart(2, "0");
-			var ss = date.getUTCSeconds().toString().padStart(2, "0");
-			var sss = date.getUTCMilliseconds().toString().padStart(3, "0");
-			msg.tags["time"] = `${YYYY}-${MM}-${DD}T${hh}:${mm}:${ss}.${sss}Z`;
+		if (!msg.tags.time) {
+			msg.tags.time = irc.formatDate(new Date());
+		}
+
+		var isHistory = false;
+		if (msg.tags.batch && this.client.batches.has(msg.tags.batch)) {
+			var batch = this.client.batches.get(msg.tags.batch);
+			isHistory = batch.type == "chathistory";
 		}
 
 		var msgUnread = Unread.NONE;
-		if (msg.command == "PRIVMSG" || msg.command == "NOTICE") {
+		if ((msg.command == "PRIVMSG" || msg.command == "NOTICE") && !isHistory) {
 			var target = msg.params[0];
 			var text = msg.params[1];
 
@@ -215,8 +238,9 @@ export default class App extends Component {
 			if (state.activeBuffer != buf.name) {
 				unread = Unread.union(unread, msgUnread);
 			}
+			var messages = insertMessage(buf.messages, msg);
 			return {
-				messages: buf.messages.concat(msg),
+				messages,
 				unread,
 			};
 		});
@@ -394,6 +418,18 @@ export default class App extends Component {
 				return { who };
 			});
 			break;
+		case "BATCH":
+			var enter = msg.params[0].startsWith("+");
+			var name = msg.params[0].slice(1);
+			if (enter) {
+				break;
+			}
+			var batch = this.client.batches.get(name);
+			if (batch.type == "chathistory" && batch.messages.length < CHATHISTORY_PAGE_SIZE) {
+				var target = batch.params[0];
+				this.endOfHistory.set(target, true);
+			}
+			break;
 		case "CAP":
 		case "AUTHENTICATE":
 		case "PING":
@@ -612,6 +648,32 @@ export default class App extends Component {
 		return repl;
 	}
 
+	handleBufferScrollTop() {
+		var target = this.state.activeBuffer;
+		if (!target || target == SERVER_BUFFER) {
+			return;
+		}
+		if (!this.client.enabledCaps["draft/chathistory"] || !this.client.enabledCaps["server-time"]) {
+			return;
+		}
+		if (this.endOfHistory.has(target)) {
+			return;
+		}
+		var buf = this.state.buffers.get(target);
+
+		var before;
+		if (buf.messages.length > 0) {
+			before = buf.messages[0].tags["time"];
+		} else {
+			before = irc.formatDate(new Date());
+		}
+
+		this.client.send({
+			command: "CHATHISTORY",
+			params: ["BEFORE", target, "timestamp=" + before, CHATHISTORY_PAGE_SIZE],
+		});
+	}
+
 	componentDidMount() {
 		if (this.state.connectParams.autoconnect) {
 			this.connect(this.state.connectParams);
@@ -661,7 +723,7 @@ export default class App extends Component {
 				</div>
 			</section>
 			${bufferHeader}
-			<${ScrollManager} target=${this.buffer} scrollKey=${this.state.activeBuffer}>
+			<${ScrollManager} target=${this.buffer} scrollKey=${this.state.activeBuffer} onScrollTop=${this.handleBufferScrollTop}>
 				<section id="buffer" ref=${this.buffer}>
 					<${Buffer} buffer=${activeBuffer} onNickClick=${this.handleNickClick}/>
 				</section>
diff --git a/components/scroll-manager.js b/components/scroll-manager.js
index 7071819..b3f630a 100644
--- a/components/scroll-manager.js
+++ b/components/scroll-manager.js
@@ -40,10 +40,16 @@ export default class ScrollManager extends Component {
 		}
 		this.scroll(pos);
 		this.stickToBottom = pos.bottom;
+		if (this.props.target.current.scrollTop == 0) {
+			this.props.onScrollTop();
+		}
 	}
 
 	handleScroll() {
 		this.stickToBottom = this.isAtBottom();
+		if (this.props.target.current.scrollTop == 0) {
+			this.props.onScrollTop();
+		}
 	}
 
 	componentDidMount() {
diff --git a/lib/client.js b/lib/client.js
index 825756c..a7d2b4b 100644
--- a/lib/client.js
+++ b/lib/client.js
@@ -2,7 +2,15 @@ import * as irc from "./irc.js";
 
 // Static list of capabilities that are always requested when supported by the
 // server
-const permanentCaps = ["message-tags", "server-time", "multi-prefix", "away-notify", "echo-message"];
+const permanentCaps = [
+	"away-notify",
+	"batch",
+	"draft/chathistory",
+	"echo-message",
+	"message-tags",
+	"multi-prefix",
+	"server-time",
+];
 
 export default class Client extends EventTarget {
 	ws = null;
@@ -18,6 +26,7 @@ export default class Client extends EventTarget {
 	registered = false;
 	availableCaps = {};
 	enabledCaps = {};
+	batches = new Map();
 
 	constructor(params) {
 		super();
@@ -65,6 +74,15 @@ export default class Client extends EventTarget {
 		var msg = irc.parseMessage(event.data);
 		console.log("Received:", msg);
 
+		var msgBatch = null;
+		if (msg.tags["batch"]) {
+			msgBatch = this.batches.get(msg.tags["batch"]);
+			if (msgBatch) {
+				msgBatch.messages.push(msg);
+			}
+		}
+
+		var deleteBatch = null;
 		switch (msg.command) {
 		case irc.RPL_WELCOME:
 			if (this.params.saslPlain && this.availableCaps["sasl"] === undefined) {
@@ -115,11 +133,33 @@ export default class Client extends EventTarget {
 				this.nick = newNick;
 			}
 			break;
+		case "BATCH":
+			var enter = msg.params[0].startsWith("+");
+			var name = msg.params[0].slice(1);
+			if (enter) {
+				var batch = {
+					name,
+					type: msg.params[1],
+					params: msg.params.slice(2),
+					parent: msgBatch,
+					messages: [],
+				};
+				this.batches.set(name, batch);
+			} else {
+				deleteBatch = name;
+			}
+			break;
 		}
 
 		this.dispatchEvent(new CustomEvent("message", {
-			detail: { message: msg },
+			detail: { message: msg, batch: msgBatch },
 		}));
+
+		// Delete after firing the message event so that handlers can access
+		// the batch
+		if (deleteBatch) {
+			this.batches.delete(name);
+		}
 	}
 
 	addAvailableCaps(s) {
diff --git a/lib/irc.js b/lib/irc.js
index 44d500e..ff02c7e 100644
--- a/lib/irc.js
+++ b/lib/irc.js
@@ -256,3 +256,15 @@ export function isError(cmd) {
 		return false;
 	}
 }
+
+export function formatDate(date) {
+	// ISO 8601
+	var YYYY = date.getUTCFullYear().toString().padStart(4, "0");
+	var MM = (date.getUTCMonth() + 1).toString().padStart(2, "0");
+	var DD = date.getUTCDate().toString().padStart(2, "0");
+	var hh = date.getUTCHours().toString().padStart(2, "0");
+	var mm = date.getUTCMinutes().toString().padStart(2, "0");
+	var ss = date.getUTCSeconds().toString().padStart(2, "0");
+	var sss = date.getUTCMilliseconds().toString().padStart(3, "0");
+	return `${YYYY}-${MM}-${DD}T${hh}:${mm}:${ss}.${sss}Z`;
+}