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-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;
|
||||||
|
|
|
@ -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,15 +120,42 @@
|
||||||
.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">
|
||||||
|
<div class="inner-input-container">
|
||||||
<textarea
|
<textarea
|
||||||
placeholder={$smallViewport ? `Message #${channel.name}` : `Send something interesting to #${channel.name}`}
|
placeholder={$smallViewport ? `Message #${channel.name}` : `Send something interesting to #${channel.name}`}
|
||||||
type="text"
|
type="text"
|
||||||
class="message-input"
|
class="message-input"
|
||||||
rows="1"
|
rows="1"
|
||||||
on:keydown={ onKeydown }
|
on:keydown={ onKeydown }
|
||||||
|
on:input={ onInput }
|
||||||
bind:value={ messageInput }
|
bind:value={ messageInput }
|
||||||
bind:this={ messageTextarea }
|
bind:this={ messageTextarea }
|
||||||
/>
|
/>
|
||||||
|
@ -104,3 +165,8 @@
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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,
|
MessageCreate: 120,
|
||||||
MessageUpdate: 121,
|
MessageUpdate: 121,
|
||||||
MessageDelete: 122,
|
MessageDelete: 122,
|
||||||
|
|
||||||
|
TypingStart: 130,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GatewayEventType = {
|
export const GatewayEventType = {
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -10,5 +10,7 @@ export enum GatewayPayloadType {
|
||||||
|
|
||||||
MessageCreate = 120,
|
MessageCreate = 120,
|
||||||
MessageUpdate,
|
MessageUpdate,
|
||||||
MessageDelete
|
MessageDelete,
|
||||||
|
|
||||||
|
TypingStart = 130,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in a new issue