Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@
"context": "..",
"dockerfile": "./Dockerfile"
},
"forwardPorts": [8282]
"forwardPorts": [8282],
"runArgs": ["--publish=8282:8282"]
}
74 changes: 74 additions & 0 deletions assets/chat/css/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -1811,3 +1811,77 @@ button.btn {
display: block;
font-size: 10px;
}

.msg-reply-preview {
font-size: 0.85em;
color: #888;
margin-bottom: 2px;

.reply-arrow {
margin-right: 4px;
color: #666;
}

.reply-label {
margin-right: 2px;
color: #aaa;
}

.reply-user {
font-weight: 600;
color: #ccc;
margin: 0 2px;
}

.reply-text {
color: #bbb;
font-style: italic;
}
}

#chat-reply-banner {
background: #030303;
border: 1px solid #321a10;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
padding: $chat-gutter-sm;
display: flex;
align-items: center;
justify-content: space-between;
font-size: $text-size-sm;
color: $color-chat-text2;
position: relative;
z-index: 220;
box-shadow: 0px -1px 4px rgba(black, 0.3);
}

#chat-reply-container {
display: flex;
}

#chat-reply-user {
color: $color-chat-text1;
font-weight: 500;
margin-left: 5px;
}

.chat-reply-cancel {
cursor: pointer;
display: flex;
align-items: center;
color: $color-chat-text3;
transition: color 0.2s ease;

svg {
width: 20px;
height: 20px;
}

&:hover {
color: $color-chat-text1;
}

&:active {
scale: 0.95;
}
}
144 changes: 137 additions & 7 deletions assets/chat/js/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ const commandsinfo = new Map([
],
["unhideemote", { desc: "Unhide a hidden emote." }],
["spoiler", { desc: "Wraps a message in the spoiler tags `||`." }],
["reply", { desc: "Reply to a user's most recent message." }],
]);
const banstruct = {
id: 0,
Expand Down Expand Up @@ -263,6 +264,7 @@ class Chat {
this.source.on("NAMES", data => this.onNAMES(data));
this.source.on("QUIT", data => this.onQUIT(data));
this.source.on("MSG", data => this.onMSG(data));
this.source.on("MSGREPLY", data => this.onREPLY(data));
this.source.on("MUTE", data => this.onMUTE(data));
this.source.on("UNMUTE", data => this.onUNMUTE(data));
this.source.on("BAN", data => this.onBAN(data));
Expand Down Expand Up @@ -317,6 +319,7 @@ class Chat {
this.cmdHIDEEMOTE(data, "UNHIDEEMOTE")
);
this.control.on("SPOILER", data => this.cmdSPOILER(data))
this.control.on("REPLY", data => this.cmdREPLY(data))

notificationSound.loadConfig();
}
Expand Down Expand Up @@ -441,6 +444,25 @@ class Chat {
e.stopPropagation();
if (!this.authenticated) {
this.loginscrn.show();
return;
}
if (this.input.hasClass("invalid-msg-warning")) return;

const text = this.input.val().toString().trim();
if (!text) return;

const prevMessageId = $("#chat-reply-banner").data("replyTo");
const prevText = $("#chat-reply-banner").data("prevText");
const targetUser = $("#chat-reply-banner").data("targetUser");

if (prevMessageId && prevText && targetUser) {
// Build the MSGREPLY payload
this.source.emit("MSGREPLY", { data: text, nick: this.user.nick, target: targetUser.nick, prev: prevText, prevMessageId: prevMessageId });

// Clear banner state
$("#chat-reply-banner").hide().removeData("replyTo").removeData("prevText").removeData("targetUser");
$("#chat-reply-user").text("");
this.input.val("").trigger("input");
} else {
// don't do anything if the message is marked invalid client-side (currently only when the message is too long)
if (!this.input.hasClass("invalid-msg-warning")) {
Expand Down Expand Up @@ -541,6 +563,33 @@ class Chat {
}
});

// Right click a reply to scroll to the orginal message
this.ui.on("contextmenu", ".msg-reply-preview", function (e) {
e.preventDefault();

const preview = $(this); // the reply preview div
const targetId = preview.data("reply-to"); // uses data-reply-to attr

if (targetId) {
const original = $(`[data-msg-id="${targetId}"]`);

if (original.length) {
original[0].scrollIntoView({ behavior: "smooth", block: "center" });

// temporary highlight
original.addClass("msg-highlight-reply");
setTimeout(() => original.removeClass("msg-highlight-reply"), 1500);
} else {
console.log("Original message not found in DOM:", targetId);
}
}
});

// Cancel a repl to someone

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: reply

this.ui.on("click", ".chat-reply-cancel", () => {
$("#chat-reply-banner").hide().removeData("replyTo").removeData("prevText").removeData("targetUser");
});

// Visibility
document.addEventListener(
"visibilitychange",
Expand Down Expand Up @@ -951,21 +1000,35 @@ class Chat {
win.lastmessage.user &&
win.lastmessage.user.username.toLowerCase() ===
message.user.username.toLowerCase();
// get mentions from message
message.mentioned = Chat.extractNicks(message.message).reduce((m, a) => {
// Gets mentions from message and a reply mentions
message.mentioned = [
...Chat.extractNicks(message.message),
...(message.prevMessage ? Chat.extractNicks(message.prevMessage) : [])
].reduce((m, a) => {
const user = this.users.get(a.toLowerCase());
return user ? [...m, user.nick] : m;
if (user && !m.includes(user.nick)) {
m.push(user.nick);
}
return m;
}, []);
// add replytarget nick if present
if (message.replytarget?.nick && !message.mentioned.includes(message.replytarget.nick)) {
message.mentioned.push(message.replytarget.nick);
}

// set tagged state
message.tag = this.taggednicks.get(message.user.nick.toLowerCase());
// set highlighted state if this is not the current users message or a bot, as well as other highlight criteria
message.highlighted =
!message.isown &&
!message.user.hasFeature(UserFeatures.BOT) &&
// Check current user nick against msg.message (if highlight setting is on)
((this.regexhighlightself &&
this.settings.get("highlight") &&
this.regexhighlightself.test(message.message)) ||
(
// Highlight if the replytarget is the current user
(message.replytarget?.username === this.user.username) ||
// Check current user nick against msg.message (if highlight setting is on)
(this.regexhighlightself &&
this.settings.get("highlight") &&
this.regexhighlightself.test(message.message)) ||
// Check /highlight nicks against msg.nick
(this.regexhighlightnicks &&
this.regexhighlightnicks.test(message.user.username)) ||
Expand Down Expand Up @@ -1248,6 +1311,21 @@ class Chat {
}
}

onREPLY(data) {
const user = this.users.get(data.nick.toLowerCase());
const target = this.users.get(data.target.toLowerCase());

MessageBuilder.reply(
data.data, // current message
user, // sender user object
target, // target user object
data.prev, // previoustext
data.prevMessageId, // previoustext
data.messageId, // optional message ID
data.timestamp // optional timestamp
).into(this);
}

onMUTE(data) {
// data.data is the nick which has been banned, no info about duration
if (this.user.username.toLowerCase() === data.data.toLowerCase()) {
Expand Down Expand Up @@ -1986,6 +2064,58 @@ class Chat {
this.control.emit("SEND", `|| ${data.join(' ')} ||`)
}

findLastMessageByUser({ nick, id }, win = this.getActiveWindow()) {
const children = win.getlines();

for (let i = children.length - 1; i >= 0; i--) {
const line = $(children[i]);
const msg = line.data("message");

if (!msg) continue;

// Match by ID first
if (id && msg.id === id) {
return msg;
}

// Match by nickname
if (nick && msg.user && msg.user.nick.toLowerCase() === nick.toLowerCase()) {
return msg;
}
}

return null;
}


cmdREPLY(parts) {
if (!parts[0] || !nickregex.test(parts[0])) {
MessageBuilder.error("Usage: /reply <nick> <message>").into(this);
return;
}

const targetNick = parts[0];
const replyText = parts.slice(1).join(" ");

if (!replyText) {
MessageBuilder.error("Reply message cannot be empty").into(this);
return;
}

// Find the last message from that user in the active window
const prevMessage = this.findLastMessageByUser({ nick: targetNick });
if (!prevMessage) {
MessageBuilder.error(`No recent message found from ${targetNick}`).into(this);
return;
}

this.source.emit("MSGREPLY", { data: replyText, nick: this.user.nick, target: targetNick, prev: prevMessage.message, prevMessageId: prevMessage.msgid });

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does the server handle this command?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't able to fully get the chat WebSocket server to connect to the gui for a full on production test but from testing with the mock server I was able to emit the reply like a message would and get the client to store it in its dom and render / show it in chat


// Add to input history
this.inputhistory.add(`/reply ${targetNick} ${replyText}`);
}


openConversation(nick) {
const normalized = nick.toLowerCase();

Expand Down
19 changes: 19 additions & 0 deletions assets/chat/js/menus.js
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,25 @@ class ChatContextMenu {
.focus()
.val(`/whisper ${this.targetUsername} `)
})

this.button.reply = this.addButton("contextmenu-reply", (id, e) => {
const msgChat = $(this.event.currentTarget).closest(".msg-chat");
const msgObj = msgChat.data("message");
const prevId = msgChat.attr("data-msg-id") || (msgObj && msgObj.id);
const targetUser = msgObj && msgObj.user;
const prevText = msgObj && msgObj.message;

if (!prevId || !targetUser || !prevText) return;

$("#chat-reply-user").text(targetUser.username ?? targetUser);
$("#chat-reply-banner")
.data("replyTo", prevId)
.data("prevText", prevText)
.data("targetUser", targetUser)
.show();

$("#chat-input-control").focus();
});

if (this.chat.settings.get("highlightnicks").includes(this.targetUsername.toLowerCase())) {
this.button.highlight = this.addButton("contextmenu-unhighlight", (id, e) => {
Expand Down
Loading