Read marker lines in chat, badges on groups
Some checks failed
continuous-integration/drone/push Build is failing

Also fixed stopping typing after sending a message.
This commit is contained in:
Cadence Ember 2020-11-25 19:54:09 +13:00
parent babd098d18
commit b7905bc3be
Signed by: cadence
GPG key ID: BC1C2C61CF521B17
9 changed files with 248 additions and 23 deletions

View file

@ -73,6 +73,7 @@ input.addEventListener("keydown", event => {
event.preventDefault() event.preventDefault()
const body = input.value const body = input.value
send(input.value) send(input.value)
typingManager.update(null) // stop typing
input.value = "" input.value = ""
fixHeight() fixHeight()
return return

View file

@ -1,5 +1,6 @@
const {ElemJS, ejs} = require("../basic") const {ElemJS, ejs} = require("../basic")
const {dateFormatter} = require("../date-formatter") const {dateFormatter} = require("../date-formatter")
const {SubscribeSet} = require("../store/subscribe_set.js")
class MatrixEvent extends ElemJS { class MatrixEvent extends ElemJS {
constructor(data) { constructor(data) {
@ -8,6 +9,7 @@ class MatrixEvent extends ElemJS {
this.data = null this.data = null
this.group = null this.group = null
this.editedAt = null this.editedAt = null
this.readBy = new SubscribeSet()
this.update(data) this.update(data)
} }

View file

@ -25,12 +25,43 @@ class ActiveGroupMarker extends ElemJS {
const activeGroupMarker = new ActiveGroupMarker() const activeGroupMarker = new ActiveGroupMarker()
class GroupNotifier extends ElemJS {
constructor() {
super("div")
this.class("c-group__number")
this.state = {}
this.render()
}
update(state) {
Object.assign(this.state, state)
this.render()
}
clear() {
this.state = {}
this.render()
}
render() {
let total = Object.values(this.state).reduce((a, c) => a + c, 0)
if (total > 0) {
this.text(total)
this.class("c-group__number--active")
} else {
this.removeClass("c-group__number--active")
}
}
}
class Group extends ElemJS { class Group extends ElemJS {
constructor(key, data) { constructor(key, data) {
super("div") super("div")
this.data = data this.data = data
this.order = this.data.order this.order = this.data.order
this.number = new GroupNotifier()
this.class("c-group") this.class("c-group")
this.child( this.child(
@ -38,6 +69,7 @@ class Group extends ElemJS {
? ejs("img").class("c-group__icon").attribute("src", this.data.icon) ? ejs("img").class("c-group__icon").attribute("src", this.data.icon)
: ejs("div").class("c-group__icon") : ejs("div").class("c-group__icon")
), ),
this.number,
ejs("div").class("c-group__name").text(this.data.name) ejs("div").class("c-group__name").text(this.data.name)
) )
@ -57,16 +89,17 @@ class Group extends ElemJS {
} }
class RoomNotifier extends ElemJS { class RoomNotifier extends ElemJS {
constructor() { constructor(room) {
super("div") super("div")
this.class("c-room__number")
this.room = room
this.classes = [ this.classes = [
"notifications", "notifications",
"unreads", "unreads",
"none" "none"
] ]
this.class("c-room__number")
this.state = { this.state = {
notifications: 0, notifications: 0,
unreads: 0 unreads: 0
@ -81,9 +114,16 @@ class RoomNotifier extends ElemJS {
*/ */
update(state) { update(state) {
Object.assign(this.state, state) Object.assign(this.state, state)
this.informGroup()
this.render() this.render()
} }
informGroup() {
this.room.getGroup().number.update({[this.room.id]: (
this.state.notifications || (this.state.unreads ? 1 : 0)
)})
}
render() { render() {
const display = { const display = {
number: this.state.notifications || this.state.unreads, number: this.state.notifications || this.state.unreads,
@ -114,7 +154,7 @@ class Room extends ElemJS {
this.id = id this.id = id
this.data = data this.data = data
this.number = new RoomNotifier() this.number = new RoomNotifier(this)
this.timeline = new Timeline(this) this.timeline = new Timeline(this)
this.group = null this.group = null
this.members = new SubscribeMapList(SubscribeValue) this.members = new SubscribeMapList(SubscribeValue)
@ -306,8 +346,12 @@ class Groups extends ElemJS {
render() { render() {
this.clearChildren() this.clearChildren()
store.groups.forEach((key, item) => { store.groups.forEach((key, item) => {
item.value().number.clear()
this.child(item.value()) this.child(item.value())
}) })
store.rooms.forEach((id, room) => {
room.value().number.informGroup() // update group notification number
})
} }
} }
const groups = new Groups() const groups = new Groups()

View file

@ -1,8 +1,9 @@
const {Subscribable} = require("./subscribable.js") const {Subscribable} = require("./subscribable.js")
class SubscribeMap extends Subscribable { class SubscribeMap extends Subscribable {
constructor() { constructor(inner) {
super() super()
this.inner = inner
Object.assign(this.events, { Object.assign(this.events, {
addItem: [], addItem: [],
editItem: [], editItem: [],
@ -17,31 +18,49 @@ class SubscribeMap extends Subscribable {
changeItem: [], changeItem: [],
askSet: [] askSet: []
}) })
this.backing = new Map() this.map = new Map()
} }
has(key) { has(key) {
return this.backing.has(key) return this.map.has(key) && this.map.get(key).exists()
}
get(key) {
if (this.map.has(key)) {
return this.map.get(key)
} else {
const item = new this.inner()
this.map.set(key, item)
return item
}
} }
forEach(f) { forEach(f) {
for (const key of this.backing.keys()) { for (const entry of this.map.entries()) {
f(key, this.backing.get(key)) f(entry[0], entry[1])
} }
} }
askSet(key, value) { askSet(key, value) {
this.broadcast("askSet", key, value) this.broadcast("askSet", {key, value})
} }
set(key, value) { set(key, value) {
const existed = this.backing.has(key) let s
this.backing.set(key, value) if (this.map.has(key)) {
if (existed) { const exists = this.map.get(key).exists()
this.broadcast("addItem", key) s = this.map.get(key).set(value)
} else { if (exists) {
this.broadcast("editItem", key) this.broadcast("editItem", key)
} else {
this.broadcast("addItem", key)
} }
} else {
s = new this.inner().set(value)
this.map.set(key, s)
this.broadcast("addItem", key)
}
return s
} }
delete(key) { delete(key) {

View file

@ -1,4 +1,4 @@
const {ElemJS, ejs} = require("./basic.js") const {ElemJS, ejs, q} = require("./basic.js")
const {Subscribable} = require("./store/subscribable.js") const {Subscribable} = require("./store/subscribable.js")
const {SubscribeValue} = require("./store/subscribe_value.js") const {SubscribeValue} = require("./store/subscribe_value.js")
const {SubscribeMap} = require("./store/subscribe_map.js") const {SubscribeMap} = require("./store/subscribe_map.js")
@ -20,6 +20,16 @@ function getTxnId() {
return Date.now() + (sentIndex++) 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) { function eventSearch(list, event, min = 0, max = NO_MAX) {
if (list.length === 0) return {success: false, i: 0} if (list.length === 0) return {success: false, i: 0}
@ -174,6 +184,62 @@ 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 { class Timeline extends Subscribable {
constructor(room) { constructor(room) {
super() super()
@ -195,10 +261,12 @@ class Timeline extends Subscribable {
this.map = new Map() this.map = new Map()
this.reactiveTimeline = new ReactiveTimeline(this.id, []) this.reactiveTimeline = new ReactiveTimeline(this.id, [])
this.latest = 0 this.latest = 0
this.latestEventID = null
this.pending = new Set() this.pending = new Set()
this.pendingEdits = [] this.pendingEdits = []
this.typing = new SubscribeValue().set([]) this.typing = new SubscribeValue().set([])
this.userReads = new SubscribeMap() this.userReads = new SubscribeMap(SubscribeValue)
this.readMarker = new ReadMarker(this)
this.from = null this.from = null
} }
@ -224,13 +292,21 @@ class Timeline extends Subscribable {
this.updateStateEvents(events) this.updateStateEvents(events)
for (const eventData of events) { for (const eventData of events) {
// set variables // set variables
this.latest = Math.max(this.latest, eventData.origin_server_ts)
let id = eventData.event_id let id = eventData.event_id
if (eventData.origin_server_ts > this.latest) {
this.latest = eventData.origin_server_ts
this.latestEventID = id
}
// handle local echoes // handle local echoes
if (eventData.sender === lsm.get("mx_user_id") && eventData.content && this.pending.has(eventData.content["chat.carbon.message.pending_id"])) { if (eventData.sender === lsm.get("mx_user_id") && eventData.content && this.pending.has(eventData.content["chat.carbon.message.pending_id"])) {
const target = this.map.get(eventData.content["chat.carbon.message.pending_id"]) const pendingID = eventData.content["chat.carbon.message.pending_id"]
if (id !== pendingID) {
const target = this.map.get(pendingID)
this.map.set(id, target) this.map.set(id, target)
this.map.delete(eventData.content["chat.carbon.message.pending_id"]) this.map.delete(pendingID)
// update fully read marker - assume we have fully read up to messages we send
markFullyRead(this.id, id)
}
} }
// handle timeline events // handle timeline events
if (this.map.has(id)) { if (this.map.has(id)) {
@ -259,6 +335,8 @@ class Timeline extends Subscribable {
const event = renderEvent(eventData) const event = renderEvent(eventData)
this.map.set(id, event) this.map.set(id, event)
this.reactiveTimeline.addEvent(event) this.reactiveTimeline.addEvent(event)
// update read receipt for sender on their own event
this.moveReadReceipt(eventData.sender, id)
} }
} }
// apply edits // apply edits
@ -285,7 +363,7 @@ class Timeline extends Subscribable {
if (eventData.type === "m.receipt") { if (eventData.type === "m.receipt") {
for (const eventID of Object.keys(eventData.content)) { for (const eventID of Object.keys(eventData.content)) {
for (const user of Object.keys(eventData.content[eventID]["m.read"])) { for (const user of Object.keys(eventData.content[eventID]["m.read"])) {
this.userReads.set(user, eventID) this.moveReadReceipt(user, eventID)
} }
} }
// console.log("Updated read receipts:", this.userReads) // console.log("Updated read receipts:", this.userReads)
@ -293,6 +371,23 @@ class Timeline extends Subscribable {
} }
} }
moveReadReceipt(user, eventID) {
if (!this.map.has(eventID)) return // ignore receipts we don't have events for
// check for a previous event to move from
const prev = this.userReads.get(user)
if (prev.exists()) {
const prevID = prev.value()
if (this.map.has(prevID)) {
// ensure new message came later
if (this.map.get(eventID).data.origin_server_ts < this.map.get(prevID).data.origin_server_ts) return
this.map.get(prevID).readBy.delete(user)
}
}
// set on new message
this.userReads.set(user, eventID)
if (this.map.has(eventID)) this.map.get(eventID).readBy.add(user)
}
updateUnreadCount(count) { updateUnreadCount(count) {
this.room.number.update({unreads: count}) this.room.number.update({unreads: count})
} }

View file

@ -36,11 +36,13 @@ $out-width: $base-width + rooms.$list-width
box-sizing: border-box box-sizing: border-box
.c-group .c-group
position: relative
display: flex display: flex
align-items: center align-items: center
padding: $icon-padding / 2 $icon-padding padding: $icon-padding / 2 $icon-padding
cursor: pointer cursor: pointer
border-radius: 8px border-radius: 8px
background-color: c.$darkest
&:hover &:hover
background-color: c.$darker background-color: c.$darker
@ -62,6 +64,29 @@ $out-width: $base-width + rooms.$list-width
overflow: hidden overflow: hidden
text-overflow: ellipsis text-overflow: ellipsis
&__number
position: absolute
right: 240px
bottom: 0px
background: #ddd
color: #000
font-size: 14px
line-height: 1
padding: 3px 4px
border-radius: 7px
border: 3px solid c.$darkest
opacity: 0
transform: translate(6px, 6px)
transition: transform 0.15s ease-out, opacity 0.15s ease-out
pointer-events: none
@at-root .c-group:hover &
border-color: c.$darker
&--active
opacity: 1
transform: translate(0px, 0px)
.c-group-marker .c-group-marker
position: absolute position: absolute
top: 5px top: 5px

View file

@ -0,0 +1,37 @@
.c-read-marker
display: none
position: relative
&--attached
display: block
&__inner
position: absolute
left: -64px
right: 0px
height: 2px
top: 0px
background-color: #ffac4b // TODO
@at-root .c-message:last-child &
top: 11px
&__text
position: absolute
right: -14px
top: -9px
display: flex
align-items: center
background-color: #ffac4b // TODO
color: #000
font-size: 12px
font-weight: 600
line-height: 1
padding: 4px
border-radius: 5px
text-transform: uppercase
&__loading
background-color: #000
width: 10px
height: 10px

View file

@ -51,6 +51,7 @@ $icon-padding: 8px
padding: 4px 5px padding: 4px 5px
border-radius: 5px border-radius: 5px
font-size: 14px font-size: 14px
pointer-events: none
&--none &--none
display: none display: none

View file

@ -1,4 +1,5 @@
@use "./base" @use "./base"
@use "./loading"
@use "./components/groups" @use "./components/groups"
@use "./components/rooms" @use "./components/rooms"
@use "./components/messages" @use "./components/messages"
@ -7,4 +8,4 @@
@use "./components/typing" @use "./components/typing"
@use "./components/anchor" @use "./components/anchor"
@use "./components/highlighted-code" @use "./components/highlighted-code"
@use "./loading" @use "./components/read-marker"