Read marker lines in chat, badges on groups
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
Also fixed stopping typing after sending a message.
This commit is contained in:
parent
babd098d18
commit
b7905bc3be
9 changed files with 248 additions and 23 deletions
|
@ -73,6 +73,7 @@ input.addEventListener("keydown", event => {
|
|||
event.preventDefault()
|
||||
const body = input.value
|
||||
send(input.value)
|
||||
typingManager.update(null) // stop typing
|
||||
input.value = ""
|
||||
fixHeight()
|
||||
return
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
const {ElemJS, ejs} = require("../basic")
|
||||
const {dateFormatter} = require("../date-formatter")
|
||||
const {SubscribeSet} = require("../store/subscribe_set.js")
|
||||
|
||||
class MatrixEvent extends ElemJS {
|
||||
constructor(data) {
|
||||
|
@ -8,6 +9,7 @@ class MatrixEvent extends ElemJS {
|
|||
this.data = null
|
||||
this.group = null
|
||||
this.editedAt = null
|
||||
this.readBy = new SubscribeSet()
|
||||
this.update(data)
|
||||
}
|
||||
|
||||
|
|
|
@ -25,12 +25,43 @@ class ActiveGroupMarker extends ElemJS {
|
|||
|
||||
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 {
|
||||
constructor(key, data) {
|
||||
super("div")
|
||||
|
||||
this.data = data
|
||||
this.order = this.data.order
|
||||
this.number = new GroupNotifier()
|
||||
|
||||
this.class("c-group")
|
||||
this.child(
|
||||
|
@ -38,6 +69,7 @@ class Group extends ElemJS {
|
|||
? ejs("img").class("c-group__icon").attribute("src", this.data.icon)
|
||||
: ejs("div").class("c-group__icon")
|
||||
),
|
||||
this.number,
|
||||
ejs("div").class("c-group__name").text(this.data.name)
|
||||
)
|
||||
|
||||
|
@ -57,16 +89,17 @@ class Group extends ElemJS {
|
|||
}
|
||||
|
||||
class RoomNotifier extends ElemJS {
|
||||
constructor() {
|
||||
constructor(room) {
|
||||
super("div")
|
||||
|
||||
this.class("c-room__number")
|
||||
|
||||
this.room = room
|
||||
this.classes = [
|
||||
"notifications",
|
||||
"unreads",
|
||||
"none"
|
||||
]
|
||||
|
||||
this.class("c-room__number")
|
||||
this.state = {
|
||||
notifications: 0,
|
||||
unreads: 0
|
||||
|
@ -81,9 +114,16 @@ class RoomNotifier extends ElemJS {
|
|||
*/
|
||||
update(state) {
|
||||
Object.assign(this.state, state)
|
||||
this.informGroup()
|
||||
this.render()
|
||||
}
|
||||
|
||||
informGroup() {
|
||||
this.room.getGroup().number.update({[this.room.id]: (
|
||||
this.state.notifications || (this.state.unreads ? 1 : 0)
|
||||
)})
|
||||
}
|
||||
|
||||
render() {
|
||||
const display = {
|
||||
number: this.state.notifications || this.state.unreads,
|
||||
|
@ -114,7 +154,7 @@ class Room extends ElemJS {
|
|||
|
||||
this.id = id
|
||||
this.data = data
|
||||
this.number = new RoomNotifier()
|
||||
this.number = new RoomNotifier(this)
|
||||
this.timeline = new Timeline(this)
|
||||
this.group = null
|
||||
this.members = new SubscribeMapList(SubscribeValue)
|
||||
|
@ -306,8 +346,12 @@ class Groups extends ElemJS {
|
|||
render() {
|
||||
this.clearChildren()
|
||||
store.groups.forEach((key, item) => {
|
||||
item.value().number.clear()
|
||||
this.child(item.value())
|
||||
})
|
||||
store.rooms.forEach((id, room) => {
|
||||
room.value().number.informGroup() // update group notification number
|
||||
})
|
||||
}
|
||||
}
|
||||
const groups = new Groups()
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
const {Subscribable} = require("./subscribable.js")
|
||||
|
||||
class SubscribeMap extends Subscribable {
|
||||
constructor() {
|
||||
constructor(inner) {
|
||||
super()
|
||||
this.inner = inner
|
||||
Object.assign(this.events, {
|
||||
addItem: [],
|
||||
editItem: [],
|
||||
|
@ -17,31 +18,49 @@ class SubscribeMap extends Subscribable {
|
|||
changeItem: [],
|
||||
askSet: []
|
||||
})
|
||||
this.backing = new Map()
|
||||
this.map = new Map()
|
||||
}
|
||||
|
||||
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) {
|
||||
for (const key of this.backing.keys()) {
|
||||
f(key, this.backing.get(key))
|
||||
for (const entry of this.map.entries()) {
|
||||
f(entry[0], entry[1])
|
||||
}
|
||||
}
|
||||
|
||||
askSet(key, value) {
|
||||
this.broadcast("askSet", key, value)
|
||||
this.broadcast("askSet", {key, value})
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
const existed = this.backing.has(key)
|
||||
this.backing.set(key, value)
|
||||
if (existed) {
|
||||
this.broadcast("addItem", key)
|
||||
let s
|
||||
if (this.map.has(key)) {
|
||||
const exists = this.map.get(key).exists()
|
||||
s = this.map.get(key).set(value)
|
||||
if (exists) {
|
||||
this.broadcast("editItem", key)
|
||||
} else {
|
||||
this.broadcast("addItem", key)
|
||||
}
|
||||
} else {
|
||||
this.broadcast("editItem", key)
|
||||
s = new this.inner().set(value)
|
||||
this.map.set(key, s)
|
||||
this.broadcast("addItem", key)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
delete(key) {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
const {ElemJS, ejs} = require("./basic.js")
|
||||
const {ElemJS, ejs, q} = require("./basic.js")
|
||||
const {Subscribable} = require("./store/subscribable.js")
|
||||
const {SubscribeValue} = require("./store/subscribe_value.js")
|
||||
const {SubscribeMap} = require("./store/subscribe_map.js")
|
||||
|
@ -20,6 +20,16 @@ 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}
|
||||
|
||||
|
@ -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 {
|
||||
constructor(room) {
|
||||
super()
|
||||
|
@ -195,10 +261,12 @@ class Timeline extends Subscribable {
|
|||
this.map = new Map()
|
||||
this.reactiveTimeline = new ReactiveTimeline(this.id, [])
|
||||
this.latest = 0
|
||||
this.latestEventID = null
|
||||
this.pending = new Set()
|
||||
this.pendingEdits = []
|
||||
this.typing = new SubscribeValue().set([])
|
||||
this.userReads = new SubscribeMap()
|
||||
this.userReads = new SubscribeMap(SubscribeValue)
|
||||
this.readMarker = new ReadMarker(this)
|
||||
this.from = null
|
||||
}
|
||||
|
||||
|
@ -224,13 +292,21 @@ class Timeline extends Subscribable {
|
|||
this.updateStateEvents(events)
|
||||
for (const eventData of events) {
|
||||
// set variables
|
||||
this.latest = Math.max(this.latest, eventData.origin_server_ts)
|
||||
let id = eventData.event_id
|
||||
if (eventData.origin_server_ts > this.latest) {
|
||||
this.latest = eventData.origin_server_ts
|
||||
this.latestEventID = id
|
||||
}
|
||||
// handle local echoes
|
||||
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"])
|
||||
this.map.set(id, target)
|
||||
this.map.delete(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.delete(pendingID)
|
||||
// update fully read marker - assume we have fully read up to messages we send
|
||||
markFullyRead(this.id, id)
|
||||
}
|
||||
}
|
||||
// handle timeline events
|
||||
if (this.map.has(id)) {
|
||||
|
@ -259,6 +335,8 @@ class Timeline extends Subscribable {
|
|||
const event = renderEvent(eventData)
|
||||
this.map.set(id, event)
|
||||
this.reactiveTimeline.addEvent(event)
|
||||
// update read receipt for sender on their own event
|
||||
this.moveReadReceipt(eventData.sender, id)
|
||||
}
|
||||
}
|
||||
// apply edits
|
||||
|
@ -285,7 +363,7 @@ class Timeline extends Subscribable {
|
|||
if (eventData.type === "m.receipt") {
|
||||
for (const eventID of Object.keys(eventData.content)) {
|
||||
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)
|
||||
|
@ -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) {
|
||||
this.room.number.update({unreads: count})
|
||||
}
|
||||
|
|
|
@ -36,11 +36,13 @@ $out-width: $base-width + rooms.$list-width
|
|||
box-sizing: border-box
|
||||
|
||||
.c-group
|
||||
position: relative
|
||||
display: flex
|
||||
align-items: center
|
||||
padding: $icon-padding / 2 $icon-padding
|
||||
cursor: pointer
|
||||
border-radius: 8px
|
||||
background-color: c.$darkest
|
||||
|
||||
&:hover
|
||||
background-color: c.$darker
|
||||
|
@ -62,6 +64,29 @@ $out-width: $base-width + rooms.$list-width
|
|||
overflow: hidden
|
||||
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
|
||||
position: absolute
|
||||
top: 5px
|
||||
|
|
37
src/sass/components/read-marker.sass
Normal file
37
src/sass/components/read-marker.sass
Normal 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
|
|
@ -51,6 +51,7 @@ $icon-padding: 8px
|
|||
padding: 4px 5px
|
||||
border-radius: 5px
|
||||
font-size: 14px
|
||||
pointer-events: none
|
||||
|
||||
&--none
|
||||
display: none
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
@use "./base"
|
||||
@use "./loading"
|
||||
@use "./components/groups"
|
||||
@use "./components/rooms"
|
||||
@use "./components/messages"
|
||||
|
@ -7,4 +8,4 @@
|
|||
@use "./components/typing"
|
||||
@use "./components/anchor"
|
||||
@use "./components/highlighted-code"
|
||||
@use "./loading"
|
||||
@use "./components/read-marker"
|
||||
|
|
Loading…
Reference in a new issue