diff --git a/src/home.pug b/src/home.pug index 09a00c7..3277b89 100644 --- a/src/home.pug +++ b/src/home.pug @@ -53,3 +53,4 @@ html .c-chat__inner#c-chat .c-chat-input textarea(placeholder="Send a message..." autocomplete="off").c-chat-input__textarea#c-chat-textarea + .c-typing#c-typing diff --git a/src/js/functions.js b/src/js/functions.js index da60793..db43a16 100644 --- a/src/js/functions.js +++ b/src/js/functions.js @@ -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 +} diff --git a/src/js/main.js b/src/js/main.js index a15bc7f..fa5dc06 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -2,7 +2,8 @@ const groups = require("./groups.js") const chat_input = require("./chat-input.js") const room_picker = require("./room-picker.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")) { location.assign("./login/") diff --git a/src/js/room-picker.js b/src/js/room-picker.js index b04bcc6..3046612 100644 --- a/src/js/room-picker.js +++ b/src/js/room-picker.js @@ -4,7 +4,7 @@ const {SubscribeMapList} = require("./store/subscribe_map_list.js") const {SubscribeValue} = require("./store/subscribe_value.js") const {Timeline} = require("./timeline.js") const lsm = require("./lsm.js") -const {resolveMxc} = require("./functions.js") +const {resolveMxc, extractLocalpart, extractDisplayName} = require("./functions.js") class ActiveGroupMarker extends ElemJS { 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() { // if the room has a 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") const users = this.data.summary["m.heroes"] if (users && users.length) { - const usernames = users.map(u => { - // 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 - }) + const usernames = users.map(mxid => this.getMemberName(mxid)) return usernames.join(", ") } // the room is empty diff --git a/src/js/store/subscribable.js b/src/js/store/subscribable.js index e87bab2..56bf971 100644 --- a/src/js/store/subscribable.js +++ b/src/js/store/subscribable.js @@ -20,6 +20,8 @@ class Subscribable { } else { 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) { diff --git a/src/js/sync/sync.js b/src/js/sync/sync.js index c17b0e9..945e5cc 100644 --- a/src/js/sync/sync.js +++ b/src/js/sync/sync.js @@ -57,6 +57,7 @@ function manageSync(root) { if (data.timeline.events.length) newEvents = true timeline.updateStateEvents(data.state.events) timeline.updateEvents(data.timeline.events) + timeline.updateEphemeral(data.ephemeral.events) }) // set up groups diff --git a/src/js/timeline.js b/src/js/timeline.js index 3204a5c..2743404 100644 --- a/src/js/timeline.js +++ b/src/js/timeline.js @@ -1,5 +1,6 @@ const {ElemJS, ejs} = require("./basic.js") const {Subscribable} = require("./store/subscribable.js") +const {SubscribeValue} = require("./store/subscribe_value.js") const {store} = require("./store/store.js") const {Anchor} = require("./anchor.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) } - class EventGroup extends ElemJS { constructor(reactive, list) { super("div") @@ -81,7 +81,6 @@ class EventGroup extends ElemJS { } } - /** Displays a spinner and creates an event to notify timeline to load more messages */ class LoadMore extends ElemJS { constructor(id) { @@ -197,6 +196,7 @@ class Timeline extends Subscribable { this.latest = 0 this.pending = new Set() this.pendingEdits = [] + this.typing = new SubscribeValue().set([]) this.from = null } @@ -275,6 +275,14 @@ class Timeline extends Subscribable { this.broadcast("afterChange") } + updateEphemeral(events) { + for (const eventData of events) { + if (eventData.type === "m.typing") { + this.typing.set(eventData.content.user_ids) + } + } + } + removeEvent(id) { if (!this.map.has(id)) throw new Error(`Tried to delete event ID ${id} which does not exist`) this.map.get(id).removeEvent() diff --git a/src/js/typing.js b/src/js/typing.js new file mode 100644 index 0000000..46990d6 --- /dev/null +++ b/src/js/typing.js @@ -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() diff --git a/src/sass/components/chat-input.sass b/src/sass/components/chat-input.sass index 892fee6..bb82dde 100644 --- a/src/sass/components/chat-input.sass +++ b/src/sass/components/chat-input.sass @@ -6,11 +6,14 @@ -webkit-appearance: $value .c-chat-input + position: relative width: 100% border-top: 2px solid c.$divider background-color: c.$dark &__textarea + position: relative + z-index: 1 width: calc(100% - 40px) height: 16px + (16px * 1.45) box-sizing: border-box diff --git a/src/sass/components/typing.sass b/src/sass/components/typing.sass new file mode 100644 index 0000000..dad8d90 --- /dev/null +++ b/src/sass/components/typing.sass @@ -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) diff --git a/src/sass/main.sass b/src/sass/main.sass index 24cd6ca..9d7251f 100644 --- a/src/sass/main.sass +++ b/src/sass/main.sass @@ -4,6 +4,7 @@ @use "./components/messages" @use "./components/chat" @use "./components/chat-input" +@use "./components/typing" @use "./components/anchor" @use "./components/highlighted-code" @use "./loading"