This commit is contained in:
Ryan Lahfa 2024-04-13 21:57:30 -06:00 committed by GitHub
commit ff0cc5ce03
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 352 additions and 36 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

@ -36,6 +36,7 @@
"../server/models/user.ts",
"../server/models/msg.ts",
"../server/models/prefix.ts",
"../server/models/client-tags.ts",
"./js/helpers/fullnamemap.json",
"./js/helpers/simplemap.json",
"../webpack.config.ts",

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";
@ -447,6 +448,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

@ -0,0 +1,42 @@
import _ from "lodash";
export enum ClientTagKey {
// https://ircv3.net/specs/client-tags/reply
DRAFT_REPLY = "draft/reply",
// https://ircv3.net/specs/client-tags/react
DRAFT_REACT = "draft/react",
// https://ircv3.net/specs/client-tags/channel-context
DRAFT_CHANNEL_CONTEXT = "draft/channel-context",
// https://ircv3.net/specs/client-tags/typing.html
TYPING = "typing",
}
export enum TypingStatus {
ACTIVE = "active",
PAUSED = "paused",
DONE = "done",
}
export class ClientTags {
reaction?: string;
repliedTo?: string;
channelContext?: string;
rawTags: Record<string, string>;
public constructor(rawClientTags: Record<string, string>) {
this.rawTags = rawClientTags;
this.reaction = this.get(ClientTagKey.DRAFT_REACT);
this.repliedTo = this.get(ClientTagKey.DRAFT_REPLY);
this.channelContext = this.get(ClientTagKey.DRAFT_CHANNEL_CONTEXT);
}
public get(key: string): string | undefined {
return this.rawTags[`+${key}`];
}
public has(key: string): boolean {
return Object.prototype.hasOwnProperty.call(this.rawTags, `+${key}`);
}
}

View file

@ -1,6 +1,7 @@
import _ from "lodash";
import {LinkPreview} from "../plugins/irc-events/link";
import User from "./user";
import {ClientTags} from "./client-tags";
export type UserInMessage = Partial<User> & {
mode: string;
@ -18,6 +19,7 @@ export enum MessageType {
LOGIN = "login",
LOGOUT = "logout",
MESSAGE = "message",
TAGMSG = "tagmsg",
MODE = "mode",
MODE_CHANNEL = "mode_channel",
MODE_USER = "mode_user", // RPL_UMODEIS
@ -61,6 +63,8 @@ class Msg {
gecos!: string;
account!: boolean;
client_tags!: ClientTags;
// these are all just for error:
error!: string;
nick!: string;
@ -94,6 +98,7 @@ class Msg {
text: "",
type: MessageType.MESSAGE,
self: false,
client_tags: {},
});
if (this.time) {

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

@ -16,6 +16,7 @@ function getTarget(cmd: string, args: string[], chan: Chan) {
const input: PluginInputHandler = function (network, chan, cmd, args) {
let targetName = getTarget(cmd, args, chan);
const tags = {};
if (cmd === "query") {
if (!targetName) {
@ -91,7 +92,7 @@ const input: PluginInputHandler = function (network, chan, cmd, args) {
return true;
}
network.irc.say(targetName, msg);
network.irc.say(targetName, msg, tags);
// If the IRCd does not support echo-message, simulate the message
// being sent back to us.

View file

@ -5,6 +5,7 @@ import Helper from "../../helper";
import {IrcEventHandler} from "../../client";
import Chan, {ChanType} from "../../models/chan";
import User from "../../models/user";
import {ClientTags, ClientTagKey, TypingStatus} from "../../models/client-tags";
const nickRegExp = /(?:\x03[0-9]{1,2}(?:,[0-9]{1,2})?)?([\w[\]\\`^{|}-]+)/g;
@ -26,6 +27,11 @@ export default <IrcEventHandler>function (irc, network) {
handleMessage(data);
});
irc.on("tagmsg", function (data) {
data.type = MessageType.TAGMSG;
handleMessage(data);
});
irc.on("privmsg", function (data) {
data.type = MessageType.MESSAGE;
handleMessage(data);
@ -44,6 +50,7 @@ export default <IrcEventHandler>function (irc, network) {
target: string;
type: MessageType;
time: number;
tags: Record<string, string>;
text?: string;
from_server?: boolean;
message: string;
@ -114,6 +121,33 @@ export default <IrcEventHandler>function (irc, network) {
from = chan.getUser(data.nick);
if (data.type === MessageType.TAGMSG) {
const typingTag = `+${ClientTagKey.TYPING}` as const;
if (Object.hasOwn(data.tags, typingTag)) {
const status = data.tags[typingTag];
if (status === TypingStatus.ACTIVE) {
from.lastActiveTyping = data.time || Date.now();
} else if (status === TypingStatus.PAUSED) {
from.lastPausedTyping = data.time || Date.now();
}
client.emit("channel:isTyping", {
network: network.uuid,
chanId: chan.id,
from: from.toJSON(),
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;
@ -131,14 +165,20 @@ export default <IrcEventHandler>function (irc, network) {
from: from,
highlight: highlight,
users: [],
client_tags: new ClientTags(data.tags),
});
if (showInActive) {
msg.showInActive = true;
}
// remove IRC formatting for custom highlight testing
const cleanMessage = cleanIrcMessage(data.message);
// Not all messages have bodies.
let cleanMessage = data.message;
if (data.message) {
// remove IRC formatting for custom highlight testing
cleanMessage = cleanIrcMessage(data.message);
}
// Self messages in channels are never highlighted
// Non-self messages are highlighted as soon as the nick is detected
@ -174,10 +214,19 @@ export default <IrcEventHandler>function (irc, network) {
LinkPrefetch(client, chan, msg, cleanMessage);
}
if (!data.message) {
return;
}
chan.pushMessage(client, msg, !msg.self);
// Do not send notifications if the channel is muted or for messages older than 15 minutes (znc buffer for example)
if (!chan.muted && msg.highlight && (!data.time || data.time > Date.now() - 900000)) {
if (
!chan.muted &&
msg.highlight &&
(!data.time || data.time > Date.now() - 900000) &&
msg.type !== MessageType.TAGMSG
) {
let title = chan.name;
let body = cleanMessage;

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

@ -174,11 +174,18 @@ declare module "irc-framework" {
changeNick(nick: string): void;
sendMessage(commandName: string, target: string, message: string): string[];
sendMessage(
commandName: string,
target: string,
message: string,
tags: Record<string, string> = {}
): string[];
say(target: string, message: string): string[];
say(target: string, message: string, tags: Record<string, string> = {}): string[];
notice(target: string, message: string): string[];
notice(target: string, message: string, tags: Record<string, string> = {}): string[];
tagmsg(target: string, tags: Record<string, string> = {}): string[];
join(channel: string, key?: string): void;

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",