Merge branch 'master' into max/04-11-channel-context

This commit is contained in:
Max Leiter
2026-04-11 15:17:43 -05:00
committed by GitHub
14 changed files with 199 additions and 78 deletions

View File

@@ -37,7 +37,7 @@
</div>
<span
v-else
:title="channel.topic"
:title="plainTopic"
:class="{topic: true, empty: !channel.topic}"
@dblclick="editTopic"
><ParsedMessage
@@ -137,6 +137,7 @@ import {defineComponent, PropType, ref, computed, watch, nextTick, onMounted, Co
import type {ClientNetwork, ClientChan} from "../js/types";
import {useStore} from "../js/store";
import {SpecialChanType, ChanType} from "../../shared/types/chan";
import parseStyle from "../js/helpers/ircmessageparser/parseStyle";
export default defineComponent({
name: "Chat",
@@ -160,6 +161,18 @@ export default defineComponent({
const messageList = ref<typeof MessageList>();
const topicInput = ref<HTMLInputElement | null>(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,

View File

@@ -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

View File

@@ -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 = {

View File

@@ -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)
);
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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(

View File

@@ -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({

View File

@@ -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 <IrcEventHandler>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 <IrcEventHandler>function (irc, network) {
return;
}
const shouldIgnore = network.ignoreList.some(function (entry) {
return Helper.compareHostmask(entry, data);
});
const shouldIgnore = network.isIgnoredUser(data);
if (shouldIgnore) {
return;

View File

@@ -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 <IrcEventHandler>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 (

View File

@@ -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);
}

View File

@@ -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;
});
});

View File

@@ -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;
});
});