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 { ${bufferHeader} - <${ScrollManager} target=${this.buffer} scrollKey=${this.state.activeBuffer}> + <${ScrollManager} target=${this.buffer} scrollKey=${this.state.activeBuffer} onScrollTop=${this.handleBufferScrollTop}>
<${Buffer} buffer=${activeBuffer} onNickClick=${this.handleNickClick}/>
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`; +}