mirror of
https://github.com/thelounge/thelounge.git
synced 2024-06-04 06:42:36 +02:00
refactor: fix up User to be loaded client-side and add typing state
This commit is contained in:
parent
20036b5727
commit
d03ec1ef1d
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -61,6 +61,10 @@ const defaultConfig = {
|
|||
default: "",
|
||||
sync: "always",
|
||||
},
|
||||
enableTypingNotifications: {
|
||||
default: false,
|
||||
sync: "never",
|
||||
},
|
||||
links: {
|
||||
default: true,
|
||||
},
|
||||
|
|
|
@ -25,3 +25,4 @@ import "./history_clear";
|
|||
import "./mentions";
|
||||
import "./search";
|
||||
import "./mute_changed";
|
||||
import "./typing";
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
});
|
||||
|
|
31
client/js/socket-events/typing.ts
Normal file
31
client/js/socket-events/typing.ts
Normal 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();
|
||||
}
|
||||
});
|
|
@ -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) => {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
2
server/types/socket-events.d.ts
vendored
2
server/types/socket-events.d.ts
vendored
|
@ -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;
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in a new issue