From 579ff199219661b47ddf25063f2be1f4fd79105d Mon Sep 17 00:00:00 2001 From: hippoz <10706925-hippoz@users.noreply.gitlab.com> Date: Sun, 7 Aug 2022 03:00:14 +0300 Subject: [PATCH] add typing indicators --- frontend/public/global.css | 8 +- frontend/src/components/MessageInput.svelte | 98 +++++++++++++++++---- frontend/src/gateway.js | 2 + frontend/src/stores.js | 76 +++++++++++++++- src/gateway/gatewaypayloadtype.ts | 4 +- src/routes/api/v1/users.ts | 22 +++++ 6 files changed, 188 insertions(+), 22 deletions(-) diff --git a/frontend/public/global.css b/frontend/public/global.css index 3e5d976..1131c0e 100644 --- a/frontend/public/global.css +++ b/frontend/public/global.css @@ -6,7 +6,7 @@ font-weight: 400; font-stretch: normal; font-style: normal; - src: url('assets/woff2/iosevka-waffle-regular.woff2') format('woff2'); + src: url("assets/woff2/iosevka-waffle-regular.woff2") format("woff2"); } /* top-level */ @@ -296,7 +296,7 @@ b, strong { } code, kbd, samp, pre { - font-family: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; font-size: 1em; } @@ -317,11 +317,11 @@ button, select { } /* Correct the inability to style clickable types in iOS and Safari. */ -button, [type='button'], [type='reset'], [type='submit'] { +button, [type="button"], [type="reset"], [type="submit"] { -webkit-appearance: button; } -/* Remove the additional ':invalid' styles in Firefox. */ +/* Remove the additional ":invalid" styles in Firefox. */ /* See: https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737 */ :-moz-ui-invalid { box-shadow: none; diff --git a/frontend/src/components/MessageInput.svelte b/frontend/src/components/MessageInput.svelte index e11ff8a..1115bd8 100644 --- a/frontend/src/components/MessageInput.svelte +++ b/frontend/src/components/MessageInput.svelte @@ -2,13 +2,42 @@ import { SendIcon } from "svelte-feather-icons"; import request from "../request"; import { apiRoute } from "../storage"; - import { messagesStoreProvider, overlayStore, smallViewport, userInfoStore } from "../stores"; + import { messagesStoreProvider, overlayStore, smallViewport, typingStore, userInfoStore } from "../stores"; export let channel; let messageInput = ""; let messageTextarea; + let typingList = "?no one?"; + let typingMessage = "is typing..."; $: messages = messagesStoreProvider.getStore(channel.id); + $: { + const typing = [ ...$typingStore ]; + const ownIndex = typing.findIndex(a => a.id === $userInfoStore.id); + if (ownIndex !== -1) { + typing.splice(ownIndex, 1); + } + + if (typing.length === 0) { + typingList = "?no one?"; + typingMessage = "is typing..."; + } else if (typing.length === 1) { + typingList = `${typing[0].username}`; + typingMessage = "is typing..."; + } else if (typing.length > 1) { + typingList = ""; + for (let i = 0; i < typing.length; i++) { + const item = typing[i]; + if (i == (typing.length - 1)) { + // we are at the end + typingList += `and ${item.username} `; + } else { + typingList += `${item.username}, `; + } + } + typingMessage = "are typing..."; + } + } const sendMessage = async () => { messageTextarea.focus(); @@ -56,15 +85,20 @@ } } }; + + const onInput = () => { + typingStore.didInputKey(); + };
diff --git a/frontend/src/gateway.js b/frontend/src/gateway.js index 937edaa..e190e83 100644 --- a/frontend/src/gateway.js +++ b/frontend/src/gateway.js @@ -25,6 +25,8 @@ export const GatewayPayloadType = { MessageCreate: 120, MessageUpdate: 121, MessageDelete: 122, + + TypingStart: 130, } export const GatewayEventType = { diff --git a/frontend/src/stores.js b/frontend/src/stores.js index 1d454fc..7861518 100644 --- a/frontend/src/stores.js +++ b/frontend/src/stores.js @@ -1,4 +1,4 @@ -import gateway, { GatewayEventType } from "./gateway"; +import gateway, { GatewayEventType, GatewayPayloadType } from "./gateway"; import logger from "./logging"; import request from "./request"; import { apiRoute, getItem, setItem } from "./storage"; @@ -319,6 +319,78 @@ class OverlayStore extends Store { } } +class TypingStore extends Store { + constructor() { + super([], "TypingStore"); + this.timeouts = new Map(); + this.ownTimeout = null; + this.ownNeedsUpdate = true; + + gateway.subscribe(GatewayPayloadType.TypingStart, ({ user, time }) => { + if (userInfoStore.value && user.id === userInfoStore.value.id) + return; + this.startedTyping(user, time); + }); + + // assume someone has stopped typing once they send a message + gateway.subscribe(GatewayPayloadType.MessageCreate, ({ author_id }) => { + this.stoppedTyping(author_id); + }); + } + + stoppedTyping(id) { + const index = this.value.findIndex(e => e.id === id); + this.value.splice(index, 1); + + if (this.timeouts.get(id)) { + clearTimeout(this.timeouts.get(id)); + this.timeouts.delete(id); + } + + if (userInfoStore.value && id === userInfoStore.value.id) { + clearTimeout(this.ownTimeout); + this.ownTimeout = null; + this.ownNeedsUpdate = true; + } + + this.updated(); + } + + startedTyping(user, time) { + if (this.timeouts.get(user.id)) { + clearTimeout(this.timeouts.get(user.id)); + } + + this.timeouts.set(user.id, setTimeout(() => { + this.stoppedTyping(user.id); + }, time)); + + if (userInfoStore.value && user.id === userInfoStore.value.id && !this.ownTimeout) { + this.ownTimeout = setTimeout(() => { + this.ownNeedsUpdate = true; + this.ownTimeout = null; + }, time); + } + + const index = this.value.findIndex(e => e.id === user.id); + if (index === -1) { + this.value.push(user); + this.updated(); + } + } + + async didInputKey() { + if (!userInfoStore.value) + return; + + this.startedTyping(userInfoStore.value, 6500); + if (this.ownNeedsUpdate) { + this.ownNeedsUpdate = false; + await request("PUT", apiRoute("users/self/typing"), true, {}); + } + } +} + export const selectedChannel = new Store({ id: -1, name: "none", creator_id: -1 }, "selectedChannel"); export const showSidebar = new Store(true, "showSidebar"); export const smallViewport = new Store(false, "smallViewport"); @@ -330,6 +402,7 @@ export const gatewayStatus = new GatewayStatusStore(); export const messagesStoreProvider = new MessagesStoreProvider(); export const userInfoStore = new UserInfoStore(); export const overlayStore = new OverlayStore(); +export const typingStore = new TypingStore(); export const allStores = { selectedChannel, @@ -343,6 +416,7 @@ export const allStores = { messagesStoreProvider, userInfoStore, overlayStore, + typingStore, }; selectedChannel.watch((newSelectedChannel) => { diff --git a/src/gateway/gatewaypayloadtype.ts b/src/gateway/gatewaypayloadtype.ts index b859e18..b8bdc16 100644 --- a/src/gateway/gatewaypayloadtype.ts +++ b/src/gateway/gatewaypayloadtype.ts @@ -10,5 +10,7 @@ export enum GatewayPayloadType { MessageCreate = 120, MessageUpdate, - MessageDelete + MessageDelete, + + TypingStart = 130, } diff --git a/src/routes/api/v1/users.ts b/src/routes/api/v1/users.ts index 817ddd4..2b37644 100644 --- a/src/routes/api/v1/users.ts +++ b/src/routes/api/v1/users.ts @@ -4,6 +4,8 @@ import express from "express"; import { body, validationResult } from "express-validator"; import { compare, hash } from "bcrypt"; import { authenticateRoute, signToken } from "../../../auth"; +import { dispatch } from "../../../gateway"; +import { GatewayPayloadType } from "../../../gateway/gatewaypayloadtype"; const router = express.Router(); @@ -107,4 +109,24 @@ router.post( } ); +router.put( + "/self/typing", + authenticateRoute(), + (req, res) => { + // TODO: add a ratelimit to this + dispatch("*", { + t: GatewayPayloadType.TypingStart, + d: { + user: { + id: req.publicUser.id, + username: req.publicUser.username + }, + time: 7500 + } + }); + + return res.status(204).send(""); + } +); + export default router;