Compare commits
2 commits
f188d66645
...
c87b6dcaa7
Author | SHA1 | Date | |
---|---|---|---|
c87b6dcaa7 | |||
eb573fc17c |
12 changed files with 141 additions and 23 deletions
|
@ -55,9 +55,7 @@ early in development. These important features still need to be
|
|||
implemented:
|
||||
|
||||
- Unreads
|
||||
- Chat history
|
||||
- Typing indicators
|
||||
- Formatting
|
||||
- Emojis
|
||||
- Reactions
|
||||
- Encryption
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ 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")
|
||||
require("./typing.js")
|
||||
|
||||
if (!localStorage.getItem("access_token")) {
|
||||
location.assign("./login/")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
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
|
||||
|
||||
.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
|
||||
|
|
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/chat"
|
||||
@use "./components/chat-input"
|
||||
@use "./components/typing"
|
||||
@use "./components/anchor"
|
||||
@use "./components/highlighted-code"
|
||||
@use "./loading"
|
||||
|
|
Loading…
Reference in a new issue