diff --git a/client/components/Chat.vue b/client/components/Chat.vue index b1447ed6..34e962ce 100644 --- a/client/components/Chat.vue +++ b/client/components/Chat.vue @@ -37,7 +37,7 @@ (); const topicInput = ref(null); + const plainTopic = computed(() => { + const topic = props.channel.topic; + + if (!topic) { + return ""; + } + + return parseStyle(topic) + .map((fragment) => fragment.text) + .join(""); + }); + const specialComponent = computed(() => { switch (props.channel.special) { case SpecialChanType.BANLIST: @@ -262,6 +275,7 @@ export default defineComponent({ store, messageList, topicInput, + plainTopic, specialComponent, hideUserVisibleError, editTopic, diff --git a/client/components/Mentions.vue b/client/components/Mentions.vue index 63144948..a85e7195 100644 --- a/client/components/Mentions.vue +++ b/client/components/Mentions.vue @@ -154,7 +154,13 @@ import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import {computed, watch, defineComponent, ref, onMounted, onUnmounted} from "vue"; import {useStore} from "../js/store"; -import {ClientMention} from "../js/types"; +import type {SharedMention} from "../../shared/types/mention"; +import type {NetChan} from "../js/types"; + +type MentionWithContext = SharedMention & { + localetime: string; + channel: NetChan | null; +}; dayjs.extend(relativeTime); @@ -169,14 +175,15 @@ export default defineComponent({ const isOpen = ref(false); const isLoading = ref(false); const resolvedMessages = computed(() => { - const messages = store.state.mentions.slice().reverse(); - - for (const message of messages) { - message.localetime = localetime(message.time); - message.channel = store.getters.findChannel(message.chanId); - } - - return messages.filter((message) => !message.channel?.channel.muted); + return store.state.mentions + .slice() + .reverse() + .map((message) => ({ + ...message, + localetime: localetime(message.time), + channel: store.getters.findChannel(message.chanId), + })) + .filter((message) => !message.channel?.channel.muted); }); watch( @@ -190,7 +197,7 @@ export default defineComponent({ return dayjs(time).fromNow(); }; - const dismissMention = (message: ClientMention) => { + const dismissMention = (message: MentionWithContext) => { store.state.mentions.splice( store.state.mentions.findIndex((m) => m.msgId === message.msgId), 1 diff --git a/client/js/chan.ts b/client/js/chan.ts index f10db4a8..26e0b630 100644 --- a/client/js/chan.ts +++ b/client/js/chan.ts @@ -1,17 +1,11 @@ import {ClientChan, ClientMessage} from "./types"; import {SharedNetworkChan} from "../../shared/types/network"; -import {SharedMsg, MessageType} from "../../shared/types/msg"; +import {SharedMsg} from "../../shared/types/msg"; import {ChanType} from "../../shared/types/chan"; +import {extractInputHistory} from "./helpers/inputHistory"; export function toClientChan(shared: SharedNetworkChan): ClientChan { - const history: string[] = [""].concat( - shared.messages - .filter((m) => m.self && m.text && m.type === MessageType.MESSAGE) - // TS is too stupid to see the nil guard on filter... so we monkey patch it - .map((m): string => (m.text ? m.text : "")) - .reverse() - .slice(0, 99) - ); + const history: string[] = [""].concat(extractInputHistory(shared.messages, 99)); // filter the unused vars const {messages, totalMessages: _, ...props} = shared; const channel: ClientChan = { diff --git a/client/js/helpers/inputHistory.ts b/client/js/helpers/inputHistory.ts new file mode 100644 index 00000000..179680b3 --- /dev/null +++ b/client/js/helpers/inputHistory.ts @@ -0,0 +1,13 @@ +import {MessageType, SharedMsg} from "../../../shared/types/msg"; + +export function extractInputHistory(messages: SharedMsg[], limit: number): string[] { + return ( + messages + .filter((m) => m.self && m.text && m.type === MessageType.MESSAGE) + // TS is too stupid to see the guard in .filter(), so we monkey patch it + // to please the compiler + .map((m) => m.text!) + .reverse() + .slice(0, limit) + ); +} diff --git a/client/js/socket-events/mentions.ts b/client/js/socket-events/mentions.ts index 98c28fe6..3e748812 100644 --- a/client/js/socket-events/mentions.ts +++ b/client/js/socket-events/mentions.ts @@ -1,17 +1,6 @@ import socket from "../socket"; import {store} from "../store"; -import {ClientMention} from "../types"; -import {SharedMention} from "../../../shared/types/mention"; socket.on("mentions:list", function (data) { - store.commit("mentions", data.map(sharedToClientMention)); + store.commit("mentions", data); }); - -function sharedToClientMention(shared: SharedMention): ClientMention { - const mention: ClientMention = { - ...shared, - localetime: "", // TODO: can't be right - channel: null, - }; - return mention; -} diff --git a/client/js/socket-events/more.ts b/client/js/socket-events/more.ts index 7e3060c9..811825e1 100644 --- a/client/js/socket-events/more.ts +++ b/client/js/socket-events/more.ts @@ -2,7 +2,7 @@ import {nextTick} from "vue"; import socket from "../socket"; import {store} from "../store"; -import {MessageType} from "../../../shared/types/msg"; +import {extractInputHistory} from "../helpers/inputHistory"; socket.on("more", async (data) => { const channel = store.getters.findChannel(data.chan)?.channel; @@ -12,13 +12,7 @@ socket.on("more", async (data) => { } channel.inputHistory = channel.inputHistory.concat( - data.messages - .filter((m) => m.self && m.text && m.type === MessageType.MESSAGE) - // TS is too stupid to see the guard in .filter(), so we monkey patch it - // to please the compiler - .map((m) => (m.text ? m.text : "")) - .reverse() - .slice(0, 100 - channel.inputHistory.length) + extractInputHistory(data.messages, 100 - channel.inputHistory.length) ); channel.moreHistoryAvailable = data.totalMessages > channel.messages.length + data.messages.length; diff --git a/client/js/types.d.ts b/client/js/types.d.ts index c8757ec1..d57d3512 100644 --- a/client/js/types.d.ts +++ b/client/js/types.d.ts @@ -50,10 +50,7 @@ type NetChan = { network: ClientNetwork; }; -type ClientMention = SharedMention & { - localetime: string; // TODO: this needs to go the way of the dodo, nothing but a single component uses it - channel: NetChan | null; -}; +type ClientMention = SharedMention; type ClientLinkPreview = LinkPreview & { sourceLoaded?: boolean; diff --git a/server/models/network.ts b/server/models/network.ts index ee5b7d92..6eb8085d 100644 --- a/server/models/network.ts +++ b/server/models/network.ts @@ -478,6 +478,10 @@ class Network { this.channels.forEach((channel) => channel.destroy()); } + isIgnoredUser(data: Hostmask) { + return this.ignoreList.some((entry) => Helper.compareHostmask(entry, data)); + } + setNick(this: Network, nick: string) { this.nick = nick; this.highlightRegex = new RegExp( diff --git a/server/plugins/inputs/ignore.ts b/server/plugins/inputs/ignore.ts index c9d2332e..00c19bdc 100644 --- a/server/plugins/inputs/ignore.ts +++ b/server/plugins/inputs/ignore.ts @@ -37,11 +37,7 @@ const input: PluginInputHandler = function (network, chan, cmd, args) { return; } - if ( - network.ignoreList.some(function (entry) { - return Helper.compareHostmask(entry, hostmask); - }) - ) { + if (network.isIgnoredUser(hostmask)) { chan.pushMessage( client, new Msg({ diff --git a/server/plugins/irc-events/ctcp.ts b/server/plugins/irc-events/ctcp.ts index 1d72d73b..0fffb39c 100644 --- a/server/plugins/irc-events/ctcp.ts +++ b/server/plugins/irc-events/ctcp.ts @@ -1,6 +1,5 @@ import _ from "lodash"; import {IrcEventHandler} from "../../client"; -import Helper from "../../helper"; import Msg from "../../models/msg"; import User from "../../models/user"; import pkg from "../../../package.json"; @@ -21,9 +20,7 @@ export default function (irc, network) { const lobby = network.getLobby(); irc.on("ctcp response", function (data) { - const shouldIgnore = network.ignoreList.some(function (entry) { - return Helper.compareHostmask(entry, data); - }); + const shouldIgnore = network.isIgnoredUser(data); if (shouldIgnore) { return; @@ -59,9 +56,7 @@ export default function (irc, network) { return; } - const shouldIgnore = network.ignoreList.some(function (entry) { - return Helper.compareHostmask(entry, data); - }); + const shouldIgnore = network.isIgnoredUser(data); if (shouldIgnore) { return; diff --git a/server/plugins/irc-events/message.ts b/server/plugins/irc-events/message.ts index e6d06dd8..fe1ff524 100644 --- a/server/plugins/irc-events/message.ts +++ b/server/plugins/irc-events/message.ts @@ -1,7 +1,6 @@ import Msg from "../../models/msg"; import LinkPrefetch from "./link"; import {cleanIrcMessage} from "../../../shared/irc"; -import Helper from "../../helper"; import {IrcEventHandler} from "../../client"; import Chan from "../../models/chan"; import User from "../../models/user"; @@ -70,11 +69,7 @@ export default function (irc, network) { } // Check if the sender is in our ignore list - const shouldIgnore = - !self && - network.ignoreList.some(function (entry) { - return Helper.compareHostmask(entry, data); - }); + const shouldIgnore = !self && network.isIgnoredUser(data); // Server messages that aren't targeted at a channel go to the server window if ( diff --git a/server/plugins/storage.ts b/server/plugins/storage.ts index 2422962c..ed157db1 100644 --- a/server/plugins/storage.ts +++ b/server/plugins/storage.ts @@ -25,8 +25,9 @@ class Storage { return; } - // TODO: Use `fs.rmdirSync(dir, {recursive: true});` when it's stable (node 13+) - items.forEach((item) => deleteFolder(path.join(dir, item))); + items.forEach((item) => { + fs.rmSync(path.join(dir, item), {recursive: true, force: true}); + }); } dereference(url) { @@ -88,17 +89,3 @@ class Storage { } export default new Storage(); - -function deleteFolder(dir: string) { - fs.readdirSync(dir).forEach((item) => { - item = path.join(dir, item); - - if (fs.lstatSync(item).isDirectory()) { - deleteFolder(item); - } else { - fs.unlinkSync(item); - } - }); - - fs.rmdirSync(dir); -} diff --git a/test/models/networkIgnoreTest.ts b/test/models/networkIgnoreTest.ts new file mode 100644 index 00000000..82927006 --- /dev/null +++ b/test/models/networkIgnoreTest.ts @@ -0,0 +1,68 @@ +import {expect} from "chai"; +import Network from "../../server/models/network"; +import Helper from "../../server/helper"; + +describe("Network#isIgnoredUser", function () { + it("should return false when ignore list is empty", function () { + const network = new Network(); + const result = network.isIgnoredUser({nick: "foo", ident: "bar", hostname: "baz"}); + expect(result).to.be.false; + }); + + it("should match an exact hostmask", function () { + const network = new Network(); + network.ignoreList.push({ + ...Helper.parseHostmask("nick!user@host"), + when: Date.now(), + }); + + expect(network.isIgnoredUser({nick: "nick", ident: "user", hostname: "host"})).to.be.true; + }); + + it("should match case-insensitively", function () { + const network = new Network(); + network.ignoreList.push({ + ...Helper.parseHostmask("NICK!USER@HOST"), + when: Date.now(), + }); + + expect(network.isIgnoredUser({nick: "nick", ident: "user", hostname: "host"})).to.be.true; + }); + + it("should match wildcard patterns", function () { + const network = new Network(); + network.ignoreList.push({ + ...Helper.parseHostmask("*!*@spammer.example.com"), + when: Date.now(), + }); + + expect( + network.isIgnoredUser({nick: "anyone", ident: "any", hostname: "spammer.example.com"}) + ).to.be.true; + expect(network.isIgnoredUser({nick: "anyone", ident: "any", hostname: "good.example.com"})) + .to.be.false; + }); + + it("should return false when no entry matches", function () { + const network = new Network(); + network.ignoreList.push({ + ...Helper.parseHostmask("badnick!*@*"), + when: Date.now(), + }); + + expect(network.isIgnoredUser({nick: "goodnick", ident: "user", hostname: "host"})).to.be + .false; + }); + + it("should match against multiple ignore entries", function () { + const network = new Network(); + network.ignoreList.push( + {...Helper.parseHostmask("nick1!*@*"), when: Date.now()}, + {...Helper.parseHostmask("nick2!*@*"), when: Date.now()} + ); + + expect(network.isIgnoredUser({nick: "nick1", ident: "a", hostname: "b"})).to.be.true; + expect(network.isIgnoredUser({nick: "nick2", ident: "a", hostname: "b"})).to.be.true; + expect(network.isIgnoredUser({nick: "nick3", ident: "a", hostname: "b"})).to.be.false; + }); +}); diff --git a/test/shared/inputHistoryTest.ts b/test/shared/inputHistoryTest.ts new file mode 100644 index 00000000..449f8f59 --- /dev/null +++ b/test/shared/inputHistoryTest.ts @@ -0,0 +1,68 @@ +import {expect} from "chai"; +import {extractInputHistory} from "../../client/js/helpers/inputHistory"; +import {MessageType} from "../../shared/types/msg"; + +describe("extractInputHistory helper", function () { + it("should extract self-authored messages", function () { + const messages = [ + {self: true, text: "hello", type: MessageType.MESSAGE}, + {self: false, text: "world", type: MessageType.MESSAGE}, + {self: true, text: "foo", type: MessageType.MESSAGE}, + ]; + + const result = extractInputHistory(messages as any, 100); + expect(result).to.deep.equal(["foo", "hello"]); + }); + + it("should only include MESSAGE type", function () { + const messages = [ + {self: true, text: "msg", type: MessageType.MESSAGE}, + {self: true, text: "action", type: MessageType.ACTION}, + {self: true, text: "notice", type: MessageType.NOTICE}, + ]; + + const result = extractInputHistory(messages as any, 100); + expect(result).to.deep.equal(["msg"]); + }); + + it("should skip messages with empty text", function () { + const messages = [ + {self: true, text: "", type: MessageType.MESSAGE}, + {self: true, text: undefined, type: MessageType.MESSAGE}, + {self: true, text: "valid", type: MessageType.MESSAGE}, + ]; + + const result = extractInputHistory(messages as any, 100); + expect(result).to.deep.equal(["valid"]); + }); + + it("should return most recent first", function () { + const messages = [ + {self: true, text: "first", type: MessageType.MESSAGE}, + {self: true, text: "second", type: MessageType.MESSAGE}, + {self: true, text: "third", type: MessageType.MESSAGE}, + ]; + + const result = extractInputHistory(messages as any, 100); + expect(result[0]).to.equal("third"); + expect(result[2]).to.equal("first"); + }); + + it("should respect the limit parameter", function () { + const messages = [ + {self: true, text: "a", type: MessageType.MESSAGE}, + {self: true, text: "b", type: MessageType.MESSAGE}, + {self: true, text: "c", type: MessageType.MESSAGE}, + ]; + + const result = extractInputHistory(messages as any, 2); + expect(result).to.have.lengthOf(2); + }); + + it("should return empty array for no matching messages", function () { + const messages = [{self: false, text: "nope", type: MessageType.MESSAGE}]; + + const result = extractInputHistory(messages as any, 100); + expect(result).to.be.empty; + }); +});