diff --git a/src/home.pug b/src/home.pug index 3277b89..a9450f3 100644 --- a/src/home.pug +++ b/src/home.pug @@ -41,7 +41,7 @@ html | ) link(rel="stylesheet" type="text/css" href=getStatic("/sass/main.sass")) script(type="module" src=getStatic("/js/main.js")) - body + body.show-focus main.main .c-groups .c-groups__display#c-groups-display @@ -49,6 +49,7 @@ html .c-groups__container#c-groups-list .c-rooms#c-rooms .c-chat + .c-chat-banner#c-chat-banner .c-chat__messages#c-chat-messages .c-chat__inner#c-chat .c-chat-input diff --git a/src/js/events/message.js b/src/js/events/message.js index a6a5ee4..9528abe 100644 --- a/src/js/events/message.js +++ b/src/js/events/message.js @@ -12,7 +12,7 @@ purifier.addHook("uponSanitizeAttribute", (node, hookevent, config) => { const allowedElementAttributes = { "FONT": ["data-mx-bg-color", "data-mx-color", "color"], - "SPAN": ["data-mx-bg-color", "data-mx-color"], + "SPAN": ["data-mx-bg-color", "data-mx-color", "data-mx-spoiler"], "A": ["name", "target", "href"], "IMG": ["width", "height", "alt", "title", "src", "data-mx-emoticon"], "OL": ["start"], @@ -55,7 +55,7 @@ function cleanHTML(html) { "color", "name", "target", "href", "width", "height", "alt", "title", "src", "start", "class", "noreferrer", "noopener", // matrix attrs - "data-mx-emoticon", "data-mx-bg-color", "data-mx-color" + "data-mx-emoticon", "data-mx-bg-color", "data-mx-color", "data-mx-spoiler" ], // Return a DOM fragment instead of a string, avoids potential future mutation XSS diff --git a/src/js/focus.js b/src/js/focus.js new file mode 100644 index 0000000..2413484 --- /dev/null +++ b/src/js/focus.js @@ -0,0 +1,11 @@ +document.body.classList.remove("show-focus") + +document.addEventListener("mousedown", () => { + document.body.classList.remove("show-focus") +}) + +document.addEventListener("keydown", event => { + if (event.key === "Tab") { + document.body.classList.add("show-focus") + } +}) diff --git a/src/js/main.js b/src/js/main.js index fa5dc06..1bc0be0 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -1,3 +1,4 @@ +require("./focus.js") const groups = require("./groups.js") const chat_input = require("./chat-input.js") const room_picker = require("./room-picker.js") diff --git a/src/js/read-marker.js b/src/js/read-marker.js new file mode 100644 index 0000000..53b4658 --- /dev/null +++ b/src/js/read-marker.js @@ -0,0 +1,149 @@ +const {ElemJS, ejs, q} = require("./basic.js") +const {store} = require("./store/store.js") +const lsm = require("./lsm.js") + +function markFullyRead(roomID, eventID) { + return fetch(`${lsm.get("domain")}/_matrix/client/r0/rooms/${roomID}/read_markers?access_token=${lsm.get("access_token")}`, { + method: "POST", + body: JSON.stringify({ + "m.fully_read": eventID, + "m.read": eventID + }) + }) +} + +class ReadBanner extends ElemJS { + constructor() { + super(q("#c-chat-banner")) + + this.newMessages = ejs("span") + this.child( + ejs("div").class("c-chat-banner__inner").child( + ejs("button").class("c-chat-banner__part").on("click", this.jumpTo.bind(this)).child( + ejs("div").class("c-chat-banner__part-inner") + .child(this.newMessages) + .addText(" new messages") + ), + ejs("button").class("c-chat-banner__part", "c-chat-banner__last").on("click", this.markRead.bind(this)).child( + ejs("div").class("c-chat-banner__part-inner").text("Mark as read") + ) + ) + ) + + store.activeRoom.subscribe("changeSelf", this.render.bind(this)) + store.notificationsChange.subscribe("changeSelf", this.render.bind(this)) + this.render() + } + + async jumpTo() { + if (!store.activeRoom.exists()) return + const timeline = store.activeRoom.value().timeline + const readMarker = timeline.readMarker + while (true) { + if (readMarker.attached) { + readMarker.element.scrollIntoView({behavior: "smooth", block: "center"}) + return + } else { + q("#c-chat-messages").scrollTo({ + top: 0, + left: 0, + behavior: "smooth" + }) + await new Promise(resolve => { + const unsubscribe = timeline.subscribe("afterScrollbackLoad", () => { + unsubscribe() + resolve() + }) + }) + } + } + } + + markRead() { + if (!store.activeRoom.exists()) return + const timeline = store.activeRoom.value().timeline + markFullyRead(timeline.id, timeline.latestEventID) + } + + render() { + let count = 0 + if (store.activeRoom.exists()) { + count = store.activeRoom.value().number.state.unreads + } + if (count !== 0) { + this.newMessages.text(count) + this.class("c-chat-banner--active") + } else { + this.removeClass("c-chat-banner--active") + } + } +} +const readBanner = new ReadBanner() + +class ReadMarker extends ElemJS { + constructor(timeline) { + super("div") + + this.class("c-read-marker") + this.loadingIcon = ejs("div") + .class("c-read-marker__loading", "loading-icon") + .style("display", "none") + this.child( + ejs("div").class("c-read-marker__inner").child( + ejs("div").class("c-read-marker__text").child(this.loadingIcon).addText("New") + ) + ) + + let processing = false + const observer = new IntersectionObserver(entries => { + const entry = entries[0] + if (!entry.isIntersecting) return + if (processing) return + processing = true + this.loadingIcon.style("display", "") + markFullyRead(this.timeline.id, this.timeline.latestEventID).then(() => { + this.loadingIcon.style("display", "none") + processing = false + }) + }, { + root: document.getElementById("c-chat-messages"), + rootMargin: "-80px 0px 0px 0px", // marker must be this distance inside the top of the screen to be counted as read + threshold: 0.01 + }) + observer.observe(this.element) + + this.attached = false + this.timeline = timeline + this.timeline.userReads.get(lsm.get("mx_user_id")).subscribe("changeSelf", (_, eventID) => { + // read marker updated, attach to it + const event = this.timeline.map.get(eventID) + this.attach(event) + }) + this.timeline.subscribe("afterChange", () => { + // timeline has new events, attach to last read one + const eventID = this.timeline.userReads.get(lsm.get("mx_user_id")).value() + const event = this.timeline.map.get(eventID) + this.attach(event) + }) + } + + attach(event) { + if (event && event.data.origin_server_ts !== this.timeline.latest) { + this.class("c-read-marker--attached") + event.element.insertAdjacentElement("beforeend", this.element) + this.attached = true + } else { + this.removeClass("c-read-marker--attached") + this.attached = false + } + if (store.activeRoom.value() === this.timeline.room) { + readBanner.render() + } + } +} + +module.exports = { + ReadMarker, + readBanner, + markFullyRead +} diff --git a/src/js/timeline.js b/src/js/timeline.js index de17b9c..cd3ef05 100644 --- a/src/js/timeline.js +++ b/src/js/timeline.js @@ -5,6 +5,7 @@ const {SubscribeMap} = require("./store/subscribe_map.js") const {store} = require("./store/store.js") const {Anchor} = require("./anchor.js") const {Sender} = require("./sender.js") +const {ReadMarker, markFullyRead} = require("./read-marker.js") const lsm = require("./lsm.js") const {resolveMxc} = require("./functions.js") const {renderEvent} = require("./events/render-event") @@ -20,16 +21,6 @@ function getTxnId() { return Date.now() + (sentIndex++) } -function markFullyRead(roomID, eventID) { - return fetch(`${lsm.get("domain")}/_matrix/client/r0/rooms/${roomID}/read_markers?access_token=${lsm.get("access_token")}`, { - method: "POST", - body: JSON.stringify({ - "m.fully_read": eventID, - "m.read": eventID - }) - }) -} - function eventSearch(list, event, min = 0, max = NO_MAX) { if (list.length === 0) return {success: false, i: 0} @@ -184,62 +175,6 @@ class ReactiveTimeline extends ElemJS { } } -class ReadMarker extends ElemJS { - constructor(timeline) { - super("div") - - this.class("c-read-marker") - this.loadingIcon = ejs("div") - .class("c-read-marker__loading", "loading-icon") - .style("display", "none") - this.child( - ejs("div").class("c-read-marker__inner").child( - ejs("div").class("c-read-marker__text").child(this.loadingIcon).addText("New") - ) - ) - - let processing = false - const observer = new IntersectionObserver(entries => { - const entry = entries[0] - if (!entry.isIntersecting) return - if (processing) return - processing = true - this.loadingIcon.style("display", "") - markFullyRead(this.timeline.id, this.timeline.latestEventID).then(() => { - this.loadingIcon.style("display", "none") - processing = false - }) - }, { - root: document.getElementById("c-chat-messages"), - rootMargin: "-80px 0px 0px 0px", // marker must be this distance inside the top of the screen to be counted as read - threshold: 0.01 - }) - observer.observe(this.element) - - this.timeline = timeline - this.timeline.userReads.get(lsm.get("mx_user_id")).subscribe("changeSelf", (_, eventID) => { - // read marker updated, attach to it - const event = this.timeline.map.get(eventID) - this.attach(event) - }) - this.timeline.subscribe("afterChange", () => { - // timeline has new events, attach to last read one - const eventID = this.timeline.userReads.get(lsm.get("mx_user_id")).value() - const event = this.timeline.map.get(eventID) - this.attach(event) - }) - } - - attach(event) { - if (event && event.data.origin_server_ts !== this.timeline.latest) { - this.class("c-read-marker--attached") - event.element.insertAdjacentElement("beforeend", this.element) - } else { - this.removeClass("c-read-marker--attached") - } - } -} - class Timeline extends Subscribable { constructor(room) { super() diff --git a/src/sass/base.sass b/src/sass/base.sass index 0232c2e..a649cce 100644 --- a/src/sass/base.sass +++ b/src/sass/base.sass @@ -21,3 +21,39 @@ body .main height: 100vh display: flex + +button + appearance: none + border: none + background: none + color: inherit + font-family: inherit + font-size: inherit + font-style: inherit + font-weight: inherit + padding: 0 + margin: 0 + line-height: inherit + cursor: inherit + +// focus resets + +:focus + outline: none + +:-moz-focusring + outline: none + +::-moz-focus-inner + border: 0 + +select:-moz-focusring + color: transparent + text-shadow: 0 0 0 #ddd + +body.show-focus + a, select, button, input, video + outline-color: #fff + + &:focus + outline: 2px dotted diff --git a/src/sass/colors.sass b/src/sass/colors.sass index e40189c..a603edd 100644 --- a/src/sass/colors.sass +++ b/src/sass/colors.sass @@ -6,3 +6,4 @@ $milder: #42454a $divider: #4b4e54 $muted: #999 $link: #57bffd +$notify-highlight: #ffac4b diff --git a/src/sass/components/chat-banner.sass b/src/sass/components/chat-banner.sass new file mode 100644 index 0000000..ba7a94d --- /dev/null +++ b/src/sass/components/chat-banner.sass @@ -0,0 +1,47 @@ +@use "../colors" as c + +.c-chat-banner + position: sticky + z-index: 1 + top: 0 + left: 0 + right: 0 + margin-right: 12px + outline-color: #000 + opacity: 0 + + &--active + opacity: 1 + + &__inner + display: grid + grid-template-columns: 1fr auto + background: c.$notify-highlight + color: #000 + margin: 0px 12px + padding: 0px 12px + border-radius: 0px 0px 10px 10px + line-height: 1 + box-shadow: 0px 5px 5px -2px rgba(0, 0, 0, 0.1) + cursor: pointer + + &:hover + box-shadow: 0px 5px 5px -2px rgba(0, 0, 0, 0.6) + + &__part + padding: 6px 0px 8px + + &:hover + text-decoration: underline + + &__part-inner + display: block + width: 100% // yes, really. + text-align: left + + &__last + margin-left: 8px + + &__last &__part-inner + border-left: 1px solid #222 + padding-left: 8px diff --git a/src/sass/components/chat.sass b/src/sass/components/chat.sass index 5ca48e0..c8bb391 100644 --- a/src/sass/components/chat.sass +++ b/src/sass/components/chat.sass @@ -2,11 +2,12 @@ .c-chat display: grid - grid-template-rows: 1fr 82px // fixed so that input box height adjustment doesn't mess up scroll + grid-template-rows: 0 1fr 82px // fixed so that input box height adjustment doesn't mess up scroll align-items: end flex: 1 &__messages + position: relative height: 100% overflow-y: scroll scrollbar-color: c.$darkest c.$darker diff --git a/src/sass/components/read-marker.sass b/src/sass/components/read-marker.sass index 84435fb..7e45910 100644 --- a/src/sass/components/read-marker.sass +++ b/src/sass/components/read-marker.sass @@ -1,3 +1,5 @@ +@use "../colors" as c + .c-read-marker display: none position: relative @@ -11,7 +13,7 @@ right: 0px height: 2px top: 0px - background-color: #ffac4b // TODO + background-color: c.$notify-highlight @at-root .c-message:last-child & top: 11px @@ -22,7 +24,7 @@ top: -9px display: flex align-items: center - background-color: #ffac4b // TODO + background-color: c.$notify-highlight color: #000 font-size: 12px font-weight: 600 diff --git a/src/sass/main.sass b/src/sass/main.sass index 11c85ec..7134edf 100644 --- a/src/sass/main.sass +++ b/src/sass/main.sass @@ -9,3 +9,4 @@ @use "./components/anchor" @use "./components/highlighted-code" @use "./components/read-marker" +@use "./components/chat-banner"