Add extremely janky unread messages banner
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
parent
bc861125d8
commit
6e209bafd6
12 changed files with 257 additions and 72 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
11
src/js/focus.js
Normal file
11
src/js/focus.js
Normal file
|
@ -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")
|
||||
}
|
||||
})
|
|
@ -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")
|
||||
|
|
149
src/js/read-marker.js
Normal file
149
src/js/read-marker.js
Normal file
|
@ -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
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -6,3 +6,4 @@ $milder: #42454a
|
|||
$divider: #4b4e54
|
||||
$muted: #999
|
||||
$link: #57bffd
|
||||
$notify-highlight: #ffac4b
|
||||
|
|
47
src/sass/components/chat-banner.sass
Normal file
47
src/sass/components/chat-banner.sass
Normal file
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -9,3 +9,4 @@
|
|||
@use "./components/anchor"
|
||||
@use "./components/highlighted-code"
|
||||
@use "./components/read-marker"
|
||||
@use "./components/chat-banner"
|
||||
|
|
Loading…
Reference in a new issue