diff --git a/internal/hub/hub.go b/internal/hub/hub.go index 7b0cb8f..2451df5 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -12,6 +12,7 @@ import ( // Types of messages sent to peers. const ( + TypeTyping = "typing" TypeMessage = "message" TypePeerList = "peer.list" TypePeerInfo = "peer.info" diff --git a/internal/hub/peer.go b/internal/hub/peer.go index 2435237..a9979b3 100644 --- a/internal/hub/peer.go +++ b/internal/hub/peer.go @@ -133,7 +133,11 @@ func (p *Peer) processMessage(b []byte) { // TODO: Respond return } - p.room.Broadcast(p.room.makeMessagePayload(msg, p)) + p.room.Broadcast(p.room.makeMessagePayload(msg, p), true) + + // "Typing" status. + case TypeTyping: + p.room.Broadcast(p.room.makePeerUpdatePayload(p, TypeTyping), false) // Request for peers list case TypePeerList: diff --git a/internal/hub/room.go b/internal/hub/room.go index 6cfa660..c9df84a 100644 --- a/internal/hub/room.go +++ b/internal/hub/room.go @@ -87,9 +87,11 @@ func (r *Room) Dispose() { } // Broadcast broadcasts a message to all connected peers. -func (r *Room) Broadcast(data []byte) { - r.recordMsgPayload(data) +func (r *Room) Broadcast(data []byte, record bool) { r.broadcastQ <- data + if record { + r.recordMsgPayload(data) + } // Extend the room's expiry. // if time.Since(r.timestamp) > time.Duration(30)*time.Second { @@ -140,13 +142,13 @@ loop: } // Notify all peers of the new addition. - r.Broadcast(r.makePeerUpdatePayload(req.peer, TypePeerJoin)) + r.Broadcast(r.makePeerUpdatePayload(req.peer, TypePeerJoin), true) r.hub.log.Printf("%s@%s joined %s", req.peer.Handle, req.peer.ID, r.ID) // A peer has left. case TypePeerLeave: r.removePeer(req.peer) - r.Broadcast(r.makePeerUpdatePayload(req.peer, TypePeerLeave)) + r.Broadcast(r.makePeerUpdatePayload(req.peer, TypePeerLeave), true) r.hub.log.Printf("%s@%s left %s", req.peer.Handle, req.peer.ID, r.ID) // A peer has requested the room's peer list. @@ -221,7 +223,7 @@ func (r *Room) removePeer(p *Peer) { delete(r.peers, p) // Notify all peers of the event. - r.Broadcast(r.makePeerUpdatePayload(p, TypePeerLeave)) + r.Broadcast(r.makePeerUpdatePayload(p, TypePeerLeave), true) } // sendPeerList sends the peer list to the given peer. diff --git a/theme/static/client.js b/theme/static/client.js index a7d5414..0dfb9f7 100644 --- a/theme/static/client.js +++ b/theme/static/client.js @@ -4,6 +4,7 @@ var Client = new function () { "Disconnect": "disconnect", "DisposeRoom": "room.dispose", "Message": "message", + "Typing": "typing", "PeerList": "peer.list", "PeerInfo": "peer.info", "PeerJoin": "peer.join", diff --git a/theme/static/style.css b/theme/static/style.css index 842fb0d..b7b801c 100644 --- a/theme/static/style.css +++ b/theme/static/style.css @@ -70,7 +70,7 @@ a { color: #f74600; } a:hover { - color: #333; + color: #222; } ul.no { @@ -95,6 +95,50 @@ input:focus { box-shadow: 2px 2px 0px #aaa; } +/* Dot spinner */ +.dot-spinner { + display: inline-block; +} +.dot-spinner i { + display: inline-block; + width: 6px; + height: 6px; + + border-radius: 50%; + background: #999; + vertical-align: middle; +} +.dot-spinner i:first-child { + transform: translate(-5px); + animation: dot-spinner-ani2 0.5s linear infinite; + opacity: 0; +} +.dot-spinner i:nth-child(2), +.dot-spinner i:nth-child(3) { + animation: dot-spinner-ani3 0.5s linear infinite; +} +.dot-spinner i:last-child { + animation: dot-spinner-ani1 0.5s linear infinite; +} + +@keyframes dot-spinner-ani1 { + 100% { + transform: translate(10px); + opacity: 0; + } +} +@keyframes dot-spinner-ani2 { + 100% { + transform: translate(5px); + opacity: 1; + } +} +@keyframes dot-spinner-ani3 { + 100% { + transform: translate(5px); + } +} + /* Helpers */ button, .button { @@ -172,7 +216,7 @@ form .charlimit-counter { form .help { display: block; font-size: 0.75em; - color: #bbb; + color: #999; } /* Expand link component */ @@ -220,7 +264,7 @@ form .help { text-align: center; } .footer a { - color: #aaa; + color: #999; margin: 0 15px; } .footer a:hover { @@ -317,6 +361,15 @@ form .help { left: 0; right: 0; } +.form-chat .typing { + background: #fff; + color: #999; + font-size: 0.775em; +} +.form-chat .typing .handle { + margin-left: 10px; + display: inline-block; +} .form-chat fieldset { position: relative; margin: 0; @@ -377,3 +430,59 @@ form .help { width: 100%; } } + +.lds-ellipsis { + display: inline-block; + position: relative; + width: 80px; + height: 80px; +} +.lds-ellipsis span { + position: absolute; + top: 33px; + width: 13px; + height: 13px; + border-radius: 50%; + background: #fff; + animation-timing-function: cubic-bezier(0, 1, 1, 0); +} +.lds-ellipsis span:nth-child(1) { + left: 8px; + animation: lds-ellipsis1 0.6s infinite; +} +.lds-ellipsis span:nth-child(2) { + left: 8px; + animation: lds-ellipsis2 0.6s infinite; +} +.lds-ellipsis span:nth-child(3) { + left: 32px; + animation: lds-ellipsis2 0.6s infinite; +} +.lds-ellipsis span:nth-child(4) { + left: 56px; + animation: lds-ellipsis3 0.6s infinite; +} +@keyframes lds-ellipsis1 { + 0% { + transform: scale(0); + } + 100% { + transform: scale(1); + } +} +@keyframes lds-ellipsis3 { + 0% { + transform: scale(1); + } + 100% { + transform: scale(0); + } +} +@keyframes lds-ellipsis2 { + 0% { + transform: translate(0, 0); + } + 100% { + transform: translate(24px, 0); + } +} diff --git a/theme/static/vue.js b/theme/static/vue.js index 4becbcc..1bf3d5c 100644 --- a/theme/static/vue.js +++ b/theme/static/vue.js @@ -3,6 +3,7 @@ const notifType = { notice: "notice", error: "error" }; +const typingDebounceInterval = 3000; Vue.component("expand-link", { props: ["link"], @@ -33,13 +34,21 @@ var app = new Vue({ sidebarOn: true, disposed: false, hasSound: true, + + // Global flash / notifcation properties. notifTimer: null, notifMessage: "", notifType: "", + + // New activity animation in title bar. Page title is cached on load + // to use in the animation. newActivity: false, newActivityCounter: 0, pageTitle: document.title, + typingTimer: null, + typingPeers: new Map(), + // Form fields. roomName: "", handle: "", @@ -53,23 +62,7 @@ var app = new Vue({ }, created: function () { this.initClient(); - - // Title bar "new activity" animation. - window.setInterval(() => { - if(!this.newActivity) { - return; - } - if(this.newActivityCounter % 2 === 0) { - document.title = "[•] " + this.pageTitle; - } else { - document.title = this.pageTitle; - } - this.newActivityCounter++; - }, 2500); - window.onfocus = () => { - this.newActivity = false; - document.title = this.pageTitle; - }; + this.initTimers(); }, computed: { Client() { @@ -133,17 +126,38 @@ var app = new Vue({ }); }, - // Send message. - handleEnter(e) { + // Capture keypresses to send message on Enter key and to broadcast + // "typing" statuses. + handleChatKeyPress(e) { if (e.keyCode == 13 && !e.shiftKey) { e.preventDefault(); this.handleSendMessage(); + return; + } + + // If it's a non "text" key, ignore. + if (!String.fromCharCode(e.keyCode).match(/(\w|\s)/g)) { + return; } + + // Debounce and wait for N seconds before sending a typing status. + if (this.typingTimer) { + return; + } + + // Send the 'typing' status. + Client.sendMessage(Client.MsgType.Typing); + + this.typingTimer = window.setTimeout(() => { + this.typingTimer = null; + }, typingDebounceInterval); }, handleSendMessage() { Client.sendMessage(Client.MsgType.Message, this.message); this.message = ""; + window.clearTimeout(this.typingTimer); + this.typingTimer = null; }, handleDisposeRoom() { @@ -295,14 +309,23 @@ var app = new Vue({ this.peers = peers; }, + onTyping(data) { + if (data.data.id === this.self.id) { + return; + } + this.typingPeers.set(data.data.id, { ...data.data, time: Date.now() }); + this.$forceUpdate(); + }, + onMessage(data) { // If the window isn't in focus, start the "new activity" animation // in the title bar. - if(!document.hasFocus()) { + if (!document.hasFocus()) { this.newActivity = true; this.beep(); } + this.typingPeers.delete(data.data.peer_id); this.messages.push({ type: Client.MsgType.Message, timestamp: data.timestamp, @@ -315,7 +338,6 @@ var app = new Vue({ }); this.$nextTick().then(function () { this.$refs["messages"].scrollTop = this.$refs["messages"].scrollHeight; - console.log("scroll") }.bind(this)); }, @@ -341,7 +363,41 @@ var app = new Vue({ Client.on(Client.MsgType.PeerLeave, (data) => { this.onPeerJoinLeave(data, Client.MsgType.PeerLeave); }); Client.on(Client.MsgType.PeerRateLimited, this.onRateLimited); Client.on(Client.MsgType.Message, this.onMessage); + Client.on(Client.MsgType.Typing, this.onTyping); Client.on(Client.MsgType.Dispose, this.onDispose); + }, + + initTimers() { + // Title bar "new activity" animation. + window.setInterval(() => { + if (!this.newActivity) { + return; + } + if (this.newActivityCounter % 2 === 0) { + document.title = "[•] " + this.pageTitle; + } else { + document.title = this.pageTitle; + } + this.newActivityCounter++; + }, 2500); + window.onfocus = () => { + this.newActivity = false; + document.title = this.pageTitle; + }; + + // Sweep "typing" statuses at regular intervals. + window.setInterval(() => { + let changed = false; + this.typingPeers.forEach((p) => { + if ((p.time + typingDebounceInterval) < Date.now()) { + this.typingPeers.delete(p.id); + changed = true; + } + }); + if(changed) { + this.$forceUpdate(); + } + }, typingDebounceInterval); } } }); diff --git a/theme/templates/room.html b/theme/templates/room.html index e1185b4..e354fcc 100644 --- a/theme/templates/room.html +++ b/theme/templates/room.html @@ -71,7 +71,11 @@