add typing indicators
This commit is contained in:
parent
4264d9ffac
commit
579ff19921
6 changed files with 188 additions and 22 deletions
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.message-input-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: var(--space-norm);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.message-input {
|
||||
|
@ -86,21 +120,53 @@
|
|||
.send-button {
|
||||
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>
|
||||
|
||||
<div class="message-input-container">
|
||||
<textarea
|
||||
placeholder={$smallViewport ? `Message #${channel.name}` : `Send something interesting to #${channel.name}`}
|
||||
type="text"
|
||||
class="message-input"
|
||||
rows="1"
|
||||
on:keydown={ onKeydown }
|
||||
bind:value={ messageInput }
|
||||
bind:this={ messageTextarea }
|
||||
/>
|
||||
{#if $smallViewport}
|
||||
<button class="icon-button send-button" on:click="{ sendMessage }">
|
||||
<SendIcon />
|
||||
</button>
|
||||
{/if}
|
||||
<div class="inner-input-container">
|
||||
<textarea
|
||||
placeholder={$smallViewport ? `Message #${channel.name}` : `Send something interesting to #${channel.name}`}
|
||||
type="text"
|
||||
class="message-input"
|
||||
rows="1"
|
||||
on:keydown={ onKeydown }
|
||||
on:input={ onInput }
|
||||
bind:value={ messageInput }
|
||||
bind:this={ messageTextarea }
|
||||
/>
|
||||
{#if $smallViewport}
|
||||
<button class="icon-button send-button" on:click="{ sendMessage }">
|
||||
<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>
|
||||
|
|
|
@ -25,6 +25,8 @@ export const GatewayPayloadType = {
|
|||
MessageCreate: 120,
|
||||
MessageUpdate: 121,
|
||||
MessageDelete: 122,
|
||||
|
||||
TypingStart: 130,
|
||||
}
|
||||
|
||||
export const GatewayEventType = {
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -10,5 +10,7 @@ export enum GatewayPayloadType {
|
|||
|
||||
MessageCreate = 120,
|
||||
MessageUpdate,
|
||||
MessageDelete
|
||||
MessageDelete,
|
||||
|
||||
TypingStart = 130,
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue