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()
|
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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
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
|
padding: 4px 5px
|
||||||
border-radius: 5px
|
border-radius: 5px
|
||||||
font-size: 14px
|
font-size: 14px
|
||||||
|
pointer-events: none
|
||||||
|
|
||||||
&--none
|
&--none
|
||||||
display: none
|
display: none
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue