Display typing notifications
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
eb573fc17c
commit
c87b6dcaa7
11 changed files with 141 additions and 21 deletions
|
@ -53,3 +53,4 @@ html
|
||||||
.c-chat__inner#c-chat
|
.c-chat__inner#c-chat
|
||||||
.c-chat-input
|
.c-chat-input
|
||||||
textarea(placeholder="Send a message..." autocomplete="off").c-chat-input__textarea#c-chat-textarea
|
textarea(placeholder="Send a message..." autocomplete="off").c-chat-input__textarea#c-chat-textarea
|
||||||
|
.c-typing#c-typing
|
||||||
|
|
|
@ -10,4 +10,28 @@ function resolveMxc(url, size, method) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {resolveMxc}
|
function extractLocalpart(mxid) {
|
||||||
|
// try to extract the localpart from the mxid
|
||||||
|
let match = mxid.match(/^@([^:]+):/)
|
||||||
|
if (match) {
|
||||||
|
return match[1]
|
||||||
|
}
|
||||||
|
// localpart extraction failed, use the whole mxid
|
||||||
|
return mxid
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDisplayName(stateEvent) {
|
||||||
|
const mxid = stateEvent.state_key
|
||||||
|
// see if a display name is set
|
||||||
|
if (stateEvent.content.displayname) {
|
||||||
|
return stateEvent.content.displayname
|
||||||
|
}
|
||||||
|
// fall back to the mxid
|
||||||
|
return extractLocalpart(mxid)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
resolveMxc,
|
||||||
|
extractLocalpart,
|
||||||
|
extractDisplayName
|
||||||
|
}
|
||||||
|
|
|
@ -2,7 +2,8 @@ const groups = require("./groups.js")
|
||||||
const chat_input = require("./chat-input.js")
|
const chat_input = require("./chat-input.js")
|
||||||
const room_picker = require("./room-picker.js")
|
const room_picker = require("./room-picker.js")
|
||||||
const sync = require("./sync/sync.js")
|
const sync = require("./sync/sync.js")
|
||||||
const chat = require("./chat.js")
|
const chat = require("./chat.js")
|
||||||
|
require("./typing.js")
|
||||||
|
|
||||||
if (!localStorage.getItem("access_token")) {
|
if (!localStorage.getItem("access_token")) {
|
||||||
location.assign("./login/")
|
location.assign("./login/")
|
||||||
|
|
|
@ -4,7 +4,7 @@ const {SubscribeMapList} = require("./store/subscribe_map_list.js")
|
||||||
const {SubscribeValue} = require("./store/subscribe_value.js")
|
const {SubscribeValue} = require("./store/subscribe_value.js")
|
||||||
const {Timeline} = require("./timeline.js")
|
const {Timeline} = require("./timeline.js")
|
||||||
const lsm = require("./lsm.js")
|
const lsm = require("./lsm.js")
|
||||||
const {resolveMxc} = require("./functions.js")
|
const {resolveMxc, extractLocalpart, extractDisplayName} = require("./functions.js")
|
||||||
|
|
||||||
class ActiveGroupMarker extends ElemJS {
|
class ActiveGroupMarker extends ElemJS {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
@ -93,6 +93,15 @@ class Room extends ElemJS {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMemberName(mxid) {
|
||||||
|
if (this.members.has(mxid)) {
|
||||||
|
const state = this.members.get(mxid).value()
|
||||||
|
return extractDisplayName(state)
|
||||||
|
} else {
|
||||||
|
return extractLocalpart(mxid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getName() {
|
getName() {
|
||||||
// if the room has a name
|
// if the room has a name
|
||||||
let name = this.data.state.events.find(e => e.type === "m.room.name")
|
let name = this.data.state.events.find(e => e.type === "m.room.name")
|
||||||
|
@ -107,22 +116,7 @@ class Room extends ElemJS {
|
||||||
// if the room has no alias, use the names of its members ("heroes")
|
// if the room has no alias, use the names of its members ("heroes")
|
||||||
const users = this.data.summary["m.heroes"]
|
const users = this.data.summary["m.heroes"]
|
||||||
if (users && users.length) {
|
if (users && users.length) {
|
||||||
const usernames = users.map(u => {
|
const usernames = users.map(mxid => this.getMemberName(mxid))
|
||||||
// if the member is in the room, use their display name
|
|
||||||
if (this.members.has(u)) {
|
|
||||||
const displayname = this.members.get(u).value().content.displayname
|
|
||||||
if (displayname) {
|
|
||||||
return displayname
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// we don't have the member, so extract the localpart from the mxid
|
|
||||||
const match = u.match(/^@([^:]+):/)
|
|
||||||
if (match) {
|
|
||||||
return match[1]
|
|
||||||
}
|
|
||||||
// localpart extraction failed, use the whole mxid
|
|
||||||
return u
|
|
||||||
})
|
|
||||||
return usernames.join(", ")
|
return usernames.join(", ")
|
||||||
}
|
}
|
||||||
// the room is empty
|
// the room is empty
|
||||||
|
|
|
@ -20,6 +20,8 @@ class Subscribable {
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Cannot subscribe to non-existent event ${event}, available events are: ${Object.keys(this.events).join(", ")}`)
|
throw new Error(`Cannot subscribe to non-existent event ${event}, available events are: ${Object.keys(this.events).join(", ")}`)
|
||||||
}
|
}
|
||||||
|
// return a function we can call to easily unsubscribe
|
||||||
|
return () => this.unsubscribe(event, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
unsubscribe(event, callback) {
|
unsubscribe(event, callback) {
|
||||||
|
|
|
@ -57,6 +57,7 @@ function manageSync(root) {
|
||||||
if (data.timeline.events.length) newEvents = true
|
if (data.timeline.events.length) newEvents = true
|
||||||
timeline.updateStateEvents(data.state.events)
|
timeline.updateStateEvents(data.state.events)
|
||||||
timeline.updateEvents(data.timeline.events)
|
timeline.updateEvents(data.timeline.events)
|
||||||
|
timeline.updateEphemeral(data.ephemeral.events)
|
||||||
})
|
})
|
||||||
|
|
||||||
// set up groups
|
// set up groups
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
const {ElemJS, ejs} = require("./basic.js")
|
const {ElemJS, ejs} = require("./basic.js")
|
||||||
const {Subscribable} = require("./store/subscribable.js")
|
const {Subscribable} = require("./store/subscribable.js")
|
||||||
|
const {SubscribeValue} = require("./store/subscribe_value.js")
|
||||||
const {store} = require("./store/store.js")
|
const {store} = require("./store/store.js")
|
||||||
const {Anchor} = require("./anchor.js")
|
const {Anchor} = require("./anchor.js")
|
||||||
const {Sender} = require("./sender.js")
|
const {Sender} = require("./sender.js")
|
||||||
|
@ -39,7 +40,6 @@ function eventSearch(list, event, min = 0, max = NO_MAX) {
|
||||||
else return eventSearch(list, event, mid + 1, max)
|
else return eventSearch(list, event, mid + 1, max)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class EventGroup extends ElemJS {
|
class EventGroup extends ElemJS {
|
||||||
constructor(reactive, list) {
|
constructor(reactive, list) {
|
||||||
super("div")
|
super("div")
|
||||||
|
@ -81,7 +81,6 @@ class EventGroup extends ElemJS {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Displays a spinner and creates an event to notify timeline to load more messages */
|
/** Displays a spinner and creates an event to notify timeline to load more messages */
|
||||||
class LoadMore extends ElemJS {
|
class LoadMore extends ElemJS {
|
||||||
constructor(id) {
|
constructor(id) {
|
||||||
|
@ -197,6 +196,7 @@ class Timeline extends Subscribable {
|
||||||
this.latest = 0
|
this.latest = 0
|
||||||
this.pending = new Set()
|
this.pending = new Set()
|
||||||
this.pendingEdits = []
|
this.pendingEdits = []
|
||||||
|
this.typing = new SubscribeValue().set([])
|
||||||
this.from = null
|
this.from = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -275,6 +275,14 @@ class Timeline extends Subscribable {
|
||||||
this.broadcast("afterChange")
|
this.broadcast("afterChange")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateEphemeral(events) {
|
||||||
|
for (const eventData of events) {
|
||||||
|
if (eventData.type === "m.typing") {
|
||||||
|
this.typing.set(eventData.content.user_ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
removeEvent(id) {
|
removeEvent(id) {
|
||||||
if (!this.map.has(id)) throw new Error(`Tried to delete event ID ${id} which does not exist`)
|
if (!this.map.has(id)) throw new Error(`Tried to delete event ID ${id} which does not exist`)
|
||||||
this.map.get(id).removeEvent()
|
this.map.get(id).removeEvent()
|
||||||
|
|
64
src/js/typing.js
Normal file
64
src/js/typing.js
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
const {ElemJS, ejs, q} = require("./basic")
|
||||||
|
const {store} = require("./store/store")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of typing users to display all names for.
|
||||||
|
* More will be shown as "X users are typing".
|
||||||
|
*/
|
||||||
|
const maxUsers = 4
|
||||||
|
|
||||||
|
function getMemberName(mxid) {
|
||||||
|
return store.activeRoom.value().getMemberName(mxid)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Typing extends ElemJS {
|
||||||
|
constructor() {
|
||||||
|
super(q("#c-typing"))
|
||||||
|
|
||||||
|
this.typingUnsubscribe = null
|
||||||
|
|
||||||
|
this.message = ejs("span")
|
||||||
|
this.child(this.message)
|
||||||
|
|
||||||
|
store.activeRoom.subscribe("changeSelf", this.changeRoom.bind(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
changeRoom() {
|
||||||
|
if (this.typingUnsubscribe) {
|
||||||
|
this.typingUnsubscribe()
|
||||||
|
}
|
||||||
|
if (!store.activeRoom.exists()) return
|
||||||
|
const room = store.activeRoom.value()
|
||||||
|
this.typingUnsubscribe = room.timeline.typing.subscribe("changeSelf", this.render.bind(this))
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!store.activeRoom.exists()) return
|
||||||
|
const room = store.activeRoom.value()
|
||||||
|
const users = room.timeline.typing.value()
|
||||||
|
if (users.length === 0) {
|
||||||
|
this.removeClass("c-typing--typing")
|
||||||
|
} else {
|
||||||
|
let message = ""
|
||||||
|
if (users.length === 1) {
|
||||||
|
message = `${getMemberName(users[0])} is typing...`
|
||||||
|
} else if (users.length <= maxUsers) {
|
||||||
|
// feel free to rewrite this loop if you know a better way
|
||||||
|
for (let i = 0; i < users.length; i++) {
|
||||||
|
if (i < users.length-1) {
|
||||||
|
message += `${getMemberName(users[i])}, `
|
||||||
|
} else {
|
||||||
|
message += `and ${getMemberName(users[i])} are typing...`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message = `${users.length} people are typing...`
|
||||||
|
}
|
||||||
|
this.class("c-typing--typing")
|
||||||
|
this.message.text(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
new Typing()
|
|
@ -6,11 +6,14 @@
|
||||||
-webkit-appearance: $value
|
-webkit-appearance: $value
|
||||||
|
|
||||||
.c-chat-input
|
.c-chat-input
|
||||||
|
position: relative
|
||||||
width: 100%
|
width: 100%
|
||||||
border-top: 2px solid c.$divider
|
border-top: 2px solid c.$divider
|
||||||
background-color: c.$dark
|
background-color: c.$dark
|
||||||
|
|
||||||
&__textarea
|
&__textarea
|
||||||
|
position: relative
|
||||||
|
z-index: 1
|
||||||
width: calc(100% - 40px)
|
width: calc(100% - 40px)
|
||||||
height: 16px + (16px * 1.45)
|
height: 16px + (16px * 1.45)
|
||||||
box-sizing: border-box
|
box-sizing: border-box
|
||||||
|
|
21
src/sass/components/typing.sass
Normal file
21
src/sass/components/typing.sass
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
@use "../colors" as c
|
||||||
|
|
||||||
|
.c-typing
|
||||||
|
height: 39px
|
||||||
|
background: c.$divider
|
||||||
|
position: absolute
|
||||||
|
right: 0
|
||||||
|
left: 0
|
||||||
|
top: 0
|
||||||
|
z-index: 0
|
||||||
|
margin: 20px
|
||||||
|
border-radius: 8px
|
||||||
|
padding: 0px 12px
|
||||||
|
font-size: 14px
|
||||||
|
line-height: 19px
|
||||||
|
transform: translateY(0px)
|
||||||
|
transition: transform 0.15s ease
|
||||||
|
color: #fff
|
||||||
|
|
||||||
|
&--typing
|
||||||
|
transform: translateY(-21px)
|
|
@ -4,6 +4,7 @@
|
||||||
@use "./components/messages"
|
@use "./components/messages"
|
||||||
@use "./components/chat"
|
@use "./components/chat"
|
||||||
@use "./components/chat-input"
|
@use "./components/chat-input"
|
||||||
|
@use "./components/typing"
|
||||||
@use "./components/anchor"
|
@use "./components/anchor"
|
||||||
@use "./components/highlighted-code"
|
@use "./components/highlighted-code"
|
||||||
@use "./loading"
|
@use "./loading"
|
||||||
|
|
Loading…
Reference in a new issue