diff --git a/src/js/chat-input.js b/src/js/chat-input.js index e3fd796..a2fa323 100644 --- a/src/js/chat-input.js +++ b/src/js/chat-input.js @@ -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 diff --git a/src/js/events/event.js b/src/js/events/event.js index a5978a1..189d6e2 100644 --- a/src/js/events/event.js +++ b/src/js/events/event.js @@ -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) } diff --git a/src/js/room-picker.js b/src/js/room-picker.js index 63d1da7..d0bc0b9 100644 --- a/src/js/room-picker.js +++ b/src/js/room-picker.js @@ -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() diff --git a/src/js/store/subscribe_map.js b/src/js/store/subscribe_map.js index a15d4e2..6159597 100644 --- a/src/js/store/subscribe_map.js +++ b/src/js/store/subscribe_map.js @@ -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) { diff --git a/src/js/timeline.js b/src/js/timeline.js index fa797da..de17b9c 100644 --- a/src/js/timeline.js +++ b/src/js/timeline.js @@ -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}) } diff --git a/src/sass/components/groups.sass b/src/sass/components/groups.sass index e91f4cc..6eca6ec 100644 --- a/src/sass/components/groups.sass +++ b/src/sass/components/groups.sass @@ -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 diff --git a/src/sass/components/read-marker.sass b/src/sass/components/read-marker.sass new file mode 100644 index 0000000..84435fb --- /dev/null +++ b/src/sass/components/read-marker.sass @@ -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 diff --git a/src/sass/components/rooms.sass b/src/sass/components/rooms.sass index a8bca58..670d9ba 100644 --- a/src/sass/components/rooms.sass +++ b/src/sass/components/rooms.sass @@ -51,6 +51,7 @@ $icon-padding: 8px padding: 4px 5px border-radius: 5px font-size: 14px + pointer-events: none &--none display: none diff --git a/src/sass/main.sass b/src/sass/main.sass index 9d7251f..11c85ec 100644 --- a/src/sass/main.sass +++ b/src/sass/main.sass @@ -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"