Compare commits

...

2 commits

Author SHA1 Message Date
c87b6dcaa7
Display typing notifications
All checks were successful
continuous-integration/drone/push Build is passing
2020-11-09 00:19:56 +13:00
eb573fc17c
Update readme feature list 2020-11-08 01:20:58 +13:00
12 changed files with 141 additions and 23 deletions

View file

@ -55,9 +55,7 @@ early in development. These important features still need to be
implemented: implemented:
- Unreads - Unreads
- Chat history
- Typing indicators - Typing indicators
- Formatting
- Emojis - Emojis
- Reactions - Reactions
- Encryption - Encryption

View file

@ -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

View file

@ -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
}

View file

@ -3,6 +3,7 @@ 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/")

View file

@ -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

View file

@ -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) {

View file

@ -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

View file

@ -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
View 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()

View file

@ -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

View 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)

View file

@ -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"