refactor: fix up User to be loaded client-side and add typing state

This commit is contained in:
Raito Bezarius 2022-07-23 17:53:25 +02:00
parent 20036b5727
commit d03ec1ef1d
18 changed files with 246 additions and 31 deletions

View file

@ -1,4 +1,20 @@
<template>
<span
v-if="
channel.users.filter((user) => user.isTyping && user.nick !== network.nick).length > 0
"
id="activeTypers"
>
<span
v-for="(user, index) in channel.users.filter(
(user) => user.isTyping && user.nick !== network.nick
)"
>
<span v-if="index != 0">, </span>
<Username :user="user" :key="user.nick + '-typing'" />
</span>
is typing...
</span>
<form id="form" method="post" action="" @submit.prevent="onSubmit">
<span id="upload-progressbar" />
<span id="nick">{{ network.nick }}</span>
@ -63,6 +79,9 @@ import eventbus from "../js/eventbus";
import {watch, defineComponent, nextTick, onMounted, PropType, ref, onUnmounted} from "vue";
import type {ClientNetwork, ClientChan} from "../js/types";
import {useStore} from "../js/store";
import {TypingStatus} from "../../server/models/client-tags";
import Username from "./Username.vue";
import _ from "lodash";
const formattingHotkeys = {
"mod+k": "\x03",
@ -91,6 +110,9 @@ const bracketWraps = {
export default defineComponent({
name: "ChatInput",
components: {
Username,
},
props: {
network: {type: Object as PropType<ClientNetwork>, required: true},
channel: {type: Object as PropType<ClientChan>, required: true},
@ -123,9 +145,35 @@ export default defineComponent({
});
};
const sendTypingNotification = _.debounce(
(status) => {
const {channel} = props;
if (channel.type === "channel" || channel.type === "query") {
console.log("emitting", status, Date.now());
console.trace();
socket.emit("input:typing", {target: props.channel.id, status});
}
},
3 * 1000,
{leading: true, trailing: false}
); // At least, every 3 seconds
const setPendingMessage = (e: Event) => {
props.channel.pendingMessage = (e.target as HTMLInputElement).value;
props.channel.inputHistoryPosition = 0;
if (input.value?.value.length === 0) {
sendTypingNotification(TypingStatus.DONE);
} else if (
input.value &&
input.value.value.length > 0 &&
input.value.value[0] !== "/"
) {
console.log("send active", Date.now());
sendTypingNotification(TypingStatus.ACTIVE);
}
setInputSize();
};
@ -165,6 +213,7 @@ export default defineComponent({
props.channel.inputHistoryPosition = 0;
props.channel.pendingMessage = "";
input.value.value = "";
sendTypingNotification.cancel();
setInputSize();
// Store new message in history if last message isn't already equal
@ -218,6 +267,10 @@ export default defineComponent({
if (autocompletionRef.value) {
autocompletionRef.value.hide();
}
if (input.value?.value.length > 0 && input.value?.value[0] !== "/") {
sendTypingNotification(TypingStatus.PAUSED);
}
};
watch(

View file

@ -86,6 +86,18 @@
/>
</label>
</div>
<div>
<h2>IRCv3 features</h2>
<label class="opt">
<input
:checked="store.state.settings.enableTypingNotifications"
type="checkbox"
name="enableTypingNotifications"
/>
Send typing notifications to others
</label>
</div>
</div>
</template>

View file

@ -2198,6 +2198,12 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
transition: 0.3s width ease-in-out;
}
#activeTypers {
border-top: 1px solid #e7e7e7;
padding: 6px;
margin: 0 6px;
}
#form {
flex: 0 0 auto;
border: 0;

View file

@ -61,6 +61,10 @@ const defaultConfig = {
default: "",
sync: "always",
},
enableTypingNotifications: {
default: false,
sync: "never",
},
links: {
default: true,
},

View file

@ -25,3 +25,4 @@ import "./history_clear";
import "./mentions";
import "./search";
import "./mute_changed";
import "./typing";

View file

@ -4,6 +4,7 @@ import {cleanIrcMessage} from "../../../shared/irc";
import {store} from "../store";
import {switchToChannel} from "../router";
import {ClientChan, ClientMention, ClientMessage, NetChan} from "../types";
import {MessageType} from "../../../server/models/msg";
let pop;
@ -66,6 +67,15 @@ socket.on("msg", function (data) {
}
}
// Reset typing indicator
if (data.msg.type === MessageType.MESSAGE) {
const user = channel.users.find((u) => u.nick === data.msg.from.nick);
if (user) {
user.stopTyping();
}
}
channel.messages.push(data.msg);
if (data.msg.self) {

View file

@ -1,10 +1,11 @@
import socket from "../socket";
import {User} from "../../../server/models/user";
import {store} from "../store";
socket.on("names", function (data) {
const netChan = store.getters.findChannel(data.id);
if (netChan) {
netChan.channel.users = data.users;
netChan.channel.users = data.users?.map((u) => new User(u));
}
});

View file

@ -0,0 +1,31 @@
import socket from "../socket";
import {store} from "../store";
import {User} from "../../../server/models/user";
import {TypingStatus} from "../../../server/models/client-tags";
type ServerTypingNotification = {
status: TypingStatus;
from: Partial<User>;
chanId: number;
};
socket.on("channel:isTyping", (data: ServerTypingNotification) => {
const receivingChannel = store.getters.findChannel(data.chanId);
if (!receivingChannel) {
return;
}
const channel = receivingChannel.channel;
const user = channel.users.find((u) => u.nick === data.from.nick);
if (!user) {
return;
}
if (data.status !== TypingStatus.DONE) {
user.startTyping(data.status);
} else {
user.stopTyping();
}
});

View file

@ -8,6 +8,7 @@ import colors from "chalk";
import log from "./log";
import Chan, {ChanConfig, Channel, ChanType} from "./models/chan";
import Msg, {MessageType, UserInMessage} from "./models/msg";
import {ClientTagKey, TypingStatus} from "./models/client-tags";
import Config from "./config";
import {condensedTypes} from "../shared/irc";
@ -437,6 +438,22 @@ class Client {
});
}
setTyping({target, status}: {target: string; status: TypingStatus}) {
const targetNode = this.find(+target);
if (!targetNode) {
return;
}
const irc = targetNode.network.irc;
if (irc && irc.connection && irc.connection.connected) {
irc!.tagmsg(targetNode.chan.name, {
[`+${ClientTagKey.TYPING}`]: status,
});
}
}
input(data) {
const client = this;
data.text.split("\n").forEach((line) => {

View file

@ -189,7 +189,7 @@ class Chan {
return this.users.get(nick.toLowerCase());
}
getUser(nick: string) {
return this.findUser(nick) || new User({nick}, new Prefix([]));
return this.findUser(nick) || new User({nick});
}
setUser(user: User) {
this.users.set(user.nick.toLowerCase(), user);

View file

@ -1,20 +1,35 @@
import _ from "lodash";
import Prefix from "./prefix";
import {TypingStatus} from "./client-tags";
class User {
const ELAPSED_SINCE_LAST_ACTIVE_TYPING = 6 * 1000; // 6 seconds
const ELAPSED_SINCE_LAST_PAUSED_TYPING = 30 * 1000; // 30 seconds
export class User {
modes!: string[];
// Users in the channel have only one mode assigned
mode!: string;
away!: string;
nick!: string;
lastMessage!: number;
lastActiveTyping!: number;
lastPausedTyping!: number;
constructor(attr: Partial<User>, prefix?: Prefix) {
// Client-side
isTyping!: boolean;
_waitForPausedNotificationHandle?: ReturnType<typeof setTimeout>;
_waitForActiveNotificationHandle?: ReturnType<typeof setTimeout>;
constructor(attr: Partial<User>) {
_.defaults(this, attr, {
modes: [],
away: "",
nick: "",
lastMessage: 0,
lastActiveTyping: 0,
lastPausedTyping: 0,
isTyping: false,
});
Object.defineProperty(this, "mode", {
@ -23,11 +38,55 @@ class User {
return this.modes[0] || "";
},
});
this.setModes(this.modes, prefix || new Prefix([]));
}
setModes(modes: string[], prefix: Prefix) {
static withPrefixLookup(attr: Partial<User>, prefix: Prefix): User {
const user = new User(attr);
user.setModesForServer(attr.modes || [], prefix);
return user;
}
_clearTypingTimers() {
if (this._waitForActiveNotificationHandle) {
clearTimeout(this._waitForActiveNotificationHandle);
this._waitForActiveNotificationHandle = undefined;
}
if (this._waitForPausedNotificationHandle) {
clearTimeout(this._waitForPausedNotificationHandle);
this._waitForPausedNotificationHandle = undefined;
}
}
stopTyping() {
this.isTyping = false;
this._clearTypingTimers();
}
startTyping(status: TypingStatus) {
this.isTyping = true;
if (status === TypingStatus.ACTIVE) {
this._clearTypingTimers();
this._waitForActiveNotificationHandle = setTimeout(() => {
if (Date.now() - this.lastActiveTyping > ELAPSED_SINCE_LAST_ACTIVE_TYPING) {
this.stopTyping();
}
}, ELAPSED_SINCE_LAST_ACTIVE_TYPING);
}
if (status === TypingStatus.PAUSED) {
this._clearTypingTimers();
this._waitForActiveNotificationHandle = setTimeout(() => {
if (Date.now() - this.lastPausedTyping > ELAPSED_SINCE_LAST_PAUSED_TYPING) {
this.stopTyping();
}
}, ELAPSED_SINCE_LAST_PAUSED_TYPING);
}
}
setModesForServer(modes: string[], prefix: Prefix) {
// irc-framework sets character mode, but The Lounge works with symbols
this.modes = modes.map((mode) => prefix.modeToSymbol[mode]);
}
@ -37,6 +96,8 @@ class User {
nick: this.nick,
modes: this.modes,
lastMessage: this.lastMessage,
lastActiveTyping: this.lastActiveTyping,
lastPausedTyping: this.lastPausedTyping,
};
}
}

View file

@ -133,17 +133,21 @@ export default <IrcEventHandler>function (irc, network) {
from.lastPausedTyping = data.time || Date.now();
}
client.emit("isTyping", {
client.emit("channel:isTyping", {
network: network.uuid,
chanId: chan.id,
from: from.toJSON(),
status
status,
});
return;
}
}
// Any other message should stop
// the typing indicator.
from.stopTyping();
// Query messages (unless self or muted) always highlight
if (chan.type === ChanType.QUERY) {
highlight = !self;

View file

@ -14,7 +14,7 @@ export default <IrcEventHandler>function (irc, network) {
data.users.forEach((user) => {
const newUser = chan.getUser(user.nick);
newUser.setModes(user.modes, network.serverOptions.PREFIX);
newUser.setModesForServer(user.modes, network.serverOptions.PREFIX);
newUsers.set(user.nick.toLowerCase(), newUser);
});

View file

@ -468,6 +468,12 @@ function initializeClient(
}
});
socket.on("input:typing", (data) => {
if (_.isPlainObject(data)) {
client.setTyping(data);
}
});
socket.on("more", (data) => {
if (_.isPlainObject(data)) {
const history = client.more(data);

View file

@ -1,5 +1,6 @@
import {ClientMessage, ClientNetwork, InitClientChan} from "../../client/js/types";
import {Mention} from "../client";
import {ServerTypingNotification} from "../client/js/socket-events/typing";
import {ChanState} from "../models/chan";
import Msg from "../models/msg";
import Network from "../models/network";
@ -28,6 +29,7 @@ interface ServerToClientEvents {
"changelog:newversion": () => void;
"channel:state": (data: {chan: number; state: ChanState}) => void;
"channel:isTyping": (data: ServerTypingNotification) => void;
"change-password": ({success, error}: {success: boolean; error?: any}) => void;

View file

@ -57,8 +57,8 @@ describe("Chan", function () {
it("should update user object", function () {
const chan = new Chan();
chan.setUser(new User({nick: "TestUser"}, prefixLookup));
chan.setUser(new User({nick: "TestUseR", modes: ["o"]}, prefixLookup));
chan.setUser(User.withPrefixLookup({nick: "TestUser"}, prefixLookup));
chan.setUser(User.withPrefixLookup({nick: "TestUseR", modes: ["o"]}, prefixLookup));
const user = chan.getUser("TestUSER");
expect(user.mode).to.equal("@");
@ -68,13 +68,13 @@ describe("Chan", function () {
describe("#getUser(nick)", function () {
it("should returning existing object", function () {
const chan = new Chan();
chan.setUser(new User({nick: "TestUseR", modes: ["o"]}, prefixLookup));
chan.setUser(User.withPrefixLookup({nick: "TestUseR", modes: ["o"]}, prefixLookup));
const user = chan.getUser("TestUSER");
expect(user.mode).to.equal("@");
});
it("should make new User object if not found", function () {
it("should make User.withPrefixLookup object if not found", function () {
const chan = new Chan();
const user = chan.getUser("very-testy-user");
@ -105,7 +105,7 @@ describe("Chan", function () {
it("should sort a simple user list", function () {
const chan = new Chan();
["JocelynD", "YaManicKill", "astorije", "xPaw", "Max-P"].forEach((nick) =>
chan.setUser(new User({nick}, prefixLookup))
chan.setUser(User.withPrefixLookup({nick}, prefixLookup))
);
expect(getUserNames(chan)).to.deep.equal([
@ -119,11 +119,13 @@ describe("Chan", function () {
it("should group users by modes", function () {
const chan = new Chan();
chan.setUser(new User({nick: "JocelynD", modes: ["a", "o"]}, prefixLookup));
chan.setUser(new User({nick: "YaManicKill", modes: ["v"]}, prefixLookup));
chan.setUser(new User({nick: "astorije", modes: ["h"]}, prefixLookup));
chan.setUser(new User({nick: "xPaw", modes: ["q"]}, prefixLookup));
chan.setUser(new User({nick: "Max-P", modes: ["o"]}, prefixLookup));
chan.setUser(
User.withPrefixLookup({nick: "JocelynD", modes: ["a", "o"]}, prefixLookup)
);
chan.setUser(User.withPrefixLookup({nick: "YaManicKill", modes: ["v"]}, prefixLookup));
chan.setUser(User.withPrefixLookup({nick: "astorije", modes: ["h"]}, prefixLookup));
chan.setUser(User.withPrefixLookup({nick: "xPaw", modes: ["q"]}, prefixLookup));
chan.setUser(User.withPrefixLookup({nick: "Max-P", modes: ["o"]}, prefixLookup));
expect(getUserNames(chan)).to.deep.equal([
"xPaw",
@ -136,11 +138,11 @@ describe("Chan", function () {
it("should sort a mix of users and modes", function () {
const chan = new Chan();
chan.setUser(new User({nick: "JocelynD"}, prefixLookup));
chan.setUser(new User({nick: "YaManicKill", modes: ["o"]}, prefixLookup));
chan.setUser(new User({nick: "astorije"}, prefixLookup));
chan.setUser(new User({nick: "xPaw"}, prefixLookup));
chan.setUser(new User({nick: "Max-P", modes: ["o"]}, prefixLookup));
chan.setUser(User.withPrefixLookup({nick: "JocelynD"}, prefixLookup));
chan.setUser(User.withPrefixLookup({nick: "YaManicKill", modes: ["o"]}, prefixLookup));
chan.setUser(User.withPrefixLookup({nick: "astorije"}, prefixLookup));
chan.setUser(User.withPrefixLookup({nick: "xPaw"}, prefixLookup));
chan.setUser(User.withPrefixLookup({nick: "Max-P", modes: ["o"]}, prefixLookup));
expect(getUserNames(chan)).to.deep.equal([
"Max-P",
@ -154,7 +156,7 @@ describe("Chan", function () {
it("should be case-insensitive", function () {
const chan = new Chan();
["aB", "Ad", "AA", "ac"].forEach((nick) =>
chan.setUser(new User({nick}, prefixLookup))
chan.setUser(User.withPrefixLookup({nick}, prefixLookup))
);
expect(getUserNames(chan)).to.deep.equal(["AA", "aB", "ac", "Ad"]);
@ -175,7 +177,7 @@ describe("Chan", function () {
"!foo",
"+foo",
"Foo",
].forEach((nick) => chan.setUser(new User({nick}, prefixLookup)));
].forEach((nick) => chan.setUser(User.withPrefixLookup({nick}, prefixLookup)));
expect(getUserNames(chan)).to.deep.equal([
"!foo",

View file

@ -1,5 +1,6 @@
import {expect} from "chai";
import Prefix from "../../server/models/prefix";
import Msg from "../../server/models/msg";
import User from "../../server/models/user";
import {LinkPreview} from "../../server/plugins/irc-events/link";
@ -8,17 +9,17 @@ describe("Msg", function () {
["from", "target"].forEach((prop) => {
it(`should keep a copy of the original user in the \`${prop}\` property`, function () {
const prefixLookup = {modeToSymbol: {a: "&", o: "@"}};
const user = new User(
const user = User.withPrefixLookup(
{
modes: ["o"],
nick: "foo",
},
prefixLookup as any
prefixLookup as unknown as Prefix
);
const msg = new Msg({[prop]: user});
// Mutating the user
user.setModes(["a"], prefixLookup as any);
user.setModesForServer(["a"], prefixLookup as any);
user.nick = "bar";
// Message's `.from`/etc. should still refer to the original user

View file

@ -58,7 +58,11 @@ const config: webpack.Configuration = {
},
{
test: /\.ts$/i,
include: [path.resolve(__dirname, "client"), path.resolve(__dirname, "shared")],
include: [
path.resolve(__dirname, "client"),
path.resolve(__dirname, "server"),
path.resolve(__dirname, "shared"),
],
exclude: path.resolve(__dirname, "node_modules"),
use: {
loader: "babel-loader",