add typing indicators

This commit is contained in:
hippoz 2022-08-07 03:00:14 +03:00
parent 4264d9ffac
commit 579ff19921
Signed by: hippoz
GPG key ID: 7C52899193467641
6 changed files with 188 additions and 22 deletions

View file

@ -6,7 +6,7 @@
font-weight: 400; font-weight: 400;
font-stretch: normal; font-stretch: normal;
font-style: 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 */ /* top-level */
@ -296,7 +296,7 @@ b, strong {
} }
code, kbd, samp, pre { 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; font-size: 1em;
} }
@ -317,11 +317,11 @@ button, select {
} }
/* Correct the inability to style clickable types in iOS and Safari. */ /* 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; -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 */ /* See: https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737 */
:-moz-ui-invalid { :-moz-ui-invalid {
box-shadow: none; box-shadow: none;

View file

@ -2,13 +2,42 @@
import { SendIcon } from "svelte-feather-icons"; import { SendIcon } from "svelte-feather-icons";
import request from "../request"; import request from "../request";
import { apiRoute } from "../storage"; import { apiRoute } from "../storage";
import { messagesStoreProvider, overlayStore, smallViewport, userInfoStore } from "../stores"; import { messagesStoreProvider, overlayStore, smallViewport, typingStore, userInfoStore } from "../stores";
export let channel; export let channel;
let messageInput = ""; let messageInput = "";
let messageTextarea; let messageTextarea;
let typingList = "?no one?";
let typingMessage = "is typing...";
$: messages = messagesStoreProvider.getStore(channel.id); $: 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 () => { const sendMessage = async () => {
messageTextarea.focus(); messageTextarea.focus();
@ -56,15 +85,20 @@
} }
} }
}; };
const onInput = () => {
typingStore.didInputKey();
};
</script> </script>
<style> <style>
.message-input-container { .message-input-container {
display: flex; display: flex;
flex-direction: column;
justify-content: center; justify-content: center;
align-items: center;
width: 100%; width: 100%;
padding: var(--space-norm); padding: var(--space-norm);
padding-bottom: 0;
} }
.message-input { .message-input {
@ -86,21 +120,53 @@
.send-button { .send-button {
margin-left: var(--space-sm); margin-left: var(--space-sm);
} }
.invisible {
visibility: hidden;
}
.inner-input-container {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
padding: 0;
margin: 0;
}
.typing-info-container {
padding-left: var(--space-xxs);
}
.typing-list {
font-weight: bolder;
}
.typing-message {
color: var(--foreground-color-2);
}
</style> </style>
<div class="message-input-container"> <div class="message-input-container">
<textarea <div class="inner-input-container">
placeholder={$smallViewport ? `Message #${channel.name}` : `Send something interesting to #${channel.name}`} <textarea
type="text" placeholder={$smallViewport ? `Message #${channel.name}` : `Send something interesting to #${channel.name}`}
class="message-input" type="text"
rows="1" class="message-input"
on:keydown={ onKeydown } rows="1"
bind:value={ messageInput } on:keydown={ onKeydown }
bind:this={ messageTextarea } on:input={ onInput }
/> bind:value={ messageInput }
{#if $smallViewport} bind:this={ messageTextarea }
<button class="icon-button send-button" on:click="{ sendMessage }"> />
<SendIcon /> {#if $smallViewport}
</button> <button class="icon-button send-button" on:click="{ sendMessage }">
{/if} <SendIcon />
</button>
{/if}
</div>
<div class="typing-info-container">
<span class="typing-list" class:invisible={ typingList === "?no one?" }>{ typingList }</span>
<span class="typing-message" class:invisible={ typingList === "?no one?" }>{ typingMessage }</span>
</div>
</div> </div>

View file

@ -25,6 +25,8 @@ export const GatewayPayloadType = {
MessageCreate: 120, MessageCreate: 120,
MessageUpdate: 121, MessageUpdate: 121,
MessageDelete: 122, MessageDelete: 122,
TypingStart: 130,
} }
export const GatewayEventType = { export const GatewayEventType = {

View file

@ -1,4 +1,4 @@
import gateway, { GatewayEventType } from "./gateway"; import gateway, { GatewayEventType, GatewayPayloadType } from "./gateway";
import logger from "./logging"; import logger from "./logging";
import request from "./request"; import request from "./request";
import { apiRoute, getItem, setItem } from "./storage"; 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 selectedChannel = new Store({ id: -1, name: "none", creator_id: -1 }, "selectedChannel");
export const showSidebar = new Store(true, "showSidebar"); export const showSidebar = new Store(true, "showSidebar");
export const smallViewport = new Store(false, "smallViewport"); export const smallViewport = new Store(false, "smallViewport");
@ -330,6 +402,7 @@ export const gatewayStatus = new GatewayStatusStore();
export const messagesStoreProvider = new MessagesStoreProvider(); export const messagesStoreProvider = new MessagesStoreProvider();
export const userInfoStore = new UserInfoStore(); export const userInfoStore = new UserInfoStore();
export const overlayStore = new OverlayStore(); export const overlayStore = new OverlayStore();
export const typingStore = new TypingStore();
export const allStores = { export const allStores = {
selectedChannel, selectedChannel,
@ -343,6 +416,7 @@ export const allStores = {
messagesStoreProvider, messagesStoreProvider,
userInfoStore, userInfoStore,
overlayStore, overlayStore,
typingStore,
}; };
selectedChannel.watch((newSelectedChannel) => { selectedChannel.watch((newSelectedChannel) => {

View file

@ -10,5 +10,7 @@ export enum GatewayPayloadType {
MessageCreate = 120, MessageCreate = 120,
MessageUpdate, MessageUpdate,
MessageDelete MessageDelete,
TypingStart = 130,
} }

View file

@ -4,6 +4,8 @@ import express from "express";
import { body, validationResult } from "express-validator"; import { body, validationResult } from "express-validator";
import { compare, hash } from "bcrypt"; import { compare, hash } from "bcrypt";
import { authenticateRoute, signToken } from "../../../auth"; import { authenticateRoute, signToken } from "../../../auth";
import { dispatch } from "../../../gateway";
import { GatewayPayloadType } from "../../../gateway/gatewaypayloadtype";
const router = express.Router(); 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; export default router;