import { app } from "./app.js";
import { NjetComponent,eventBus } from "./njet.js";
import { FileUploadGrid } from "./file-upload-grid.js";
class ChatInputComponent extends NjetComponent {
autoCompletions = {
"example 1": () => {},
"example 2": () => {},
};
hiddenCompletions = {
"/starsRender": () => {
app.rpc.starsRender(this.channelUid, this.value.replace("/starsRender ", ""))
},
"/leet": () => {
this.value = this.textToLeet(this.value);
this._leetSpeak = !this._leetSpeak;
},
"/l33t": () => {
this._leetSpeakAdvanced = !this._leetSpeakAdvanced;
}
};
users = [];
textarea = null;
_value = "";
lastUpdateEvent = null;
queuedMessage = null;
lastMessagePromise = null;
_leetSpeak = false;
_leetSpeakAdvanced = false;
constructor() {
super();
this.lastUpdateEvent = new Date();
this.textarea = document.createElement("textarea");
this.textarea.classList.add("chat-input-textarea");
this.value = this.getAttribute("value") || "";
}
get value() {
return this._value;
}
set value(value) {
this._value = value;
this.textarea.value = this._value;
}
get allAutoCompletions() {
return Object.assign({}, this.autoCompletions, this.hiddenCompletions);
}
resolveAutoComplete(input) {
let value = null;
for (const key of Object.keys(this.allAutoCompletions)) {
if (key.startsWith(input.split(" ", 1)[0])) {
if (value) {
return null;
}
value = key;
}
}
return value;
}
isActive() {
return document.activeElement === this.textarea;
}
focus() {
this.textarea.focus();
}
getAuthors() {
return this.users.flatMap((user) => [user.username, user.nick]);
}
extractMentions(text) {
return Array.from(text.matchAll(/@([a-zA-Z0-9_-]+)/g), m => m[1]);
}
matchMentionsToAuthors(mentions, authors) {
return mentions.map(mention => {
let closestAuthor = null;
let minDistance = Infinity;
const lowerMention = mention.toLowerCase();
authors.forEach(author => {
const lowerAuthor = author.toLowerCase();
let distance = this.levenshteinDistance(lowerMention, lowerAuthor);
if (!this.isSubsequence(lowerMention, lowerAuthor)) {
distance += 10;
}
if (distance < minDistance) {
minDistance = distance;
closestAuthor = author;
}
});
return { mention, closestAuthor, distance: minDistance };
});
}
levenshteinDistance(a, b) {
const matrix = [];
// Initialize the first row and column
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
// Fill in the matrix
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // Deletion
matrix[i][j - 1] + 1, // Insertion
matrix[i - 1][j - 1] + 1 // Substitution
);
}
}
}
return matrix[b.length][a.length];
}
replaceMentionsWithAuthors(text) {
const authors = this.getAuthors();
const mentions = this.extractMentions(text);
const matches = this.matchMentionsToAuthors(mentions, authors);
let updatedText = text;
matches.forEach(({ mention, closestAuthor }) => {
const mentionRegex = new RegExp(`@${mention}`, 'g');
updatedText = updatedText.replace(mentionRegex, `@${closestAuthor}`);
});
return updatedText;
}
textToLeet(text) {
// L33t speak character mapping
const leetMap = {
'a': '4',
'A': '4',
'e': '3',
'E': '3',
'i': '1',
'I': '1',
'o': '0',
'O': '0',
's': '5',
'S': '5',
't': '7',
'T': '7',
'l': '1',
'L': '1',
'g': '9',
'G': '9',
'b': '6',
'B': '6',
'z': '2',
'Z': '2'
};
// Convert text to l33t speak
return text.split('').map(char => {
return leetMap[char] || char;
}).join('');
}
// Advanced version with random character selection
textToLeetAdvanced(text) {
const leetMap = {
'a': ['4', '@', '/\\'],
'A': ['4', '@', '/\\'],
'e': ['3', '€'],
'E': ['3', '€'],
'i': ['1', '!', '|'],
'I': ['1', '!', '|'],
'o': ['0', '()'],
'O': ['0', '()'],
's': ['5', '$'],
'S': ['5', '$'],
't': ['7', '+'],
'T': ['7', '+'],
'l': ['1', '|'],
'L': ['1', '|'],
'g': ['9', '6'],
'G': ['9', '6'],
'b': ['6', '|3'],
'B': ['6', '|3'],
'z': ['2'],
'Z': ['2'],
'h': ['#', '|-|'],
'H': ['#', '|-|'],
'n': ['|\\|'],
'N': ['|\\|'],
'm': ['|\\/|'],
'M': ['|\\/|'],
'w': ['\\/\\/'],
'W': ['\\/\\/'],
'v': ['\\/', 'V'],
'V': ['\\/', 'V'],
'u': ['|_|'],
'U': ['|_|'],
'r': ['|2'],
'R': ['|2'],
'f': ['|='],
'F': ['|='],
'd': ['|)'],
'D': ['|)'],
'c': ['(', '['],
'C': ['(', '['],
'k': ['|<'],
'K': ['|<'],
'p': ['|>'],
'P': ['|>'],
'x': ['><'],
'X': ['><'],
'y': ['`/'],
'Y': ['`/']
};
return text.split('').map(char => {
const options = leetMap[char];
if (options) {
return options[Math.floor(Math.random() * options.length)];
}
return char;
}).join('');
}
async connectedCallback() {
this.user = null;
app.rpc.getUser(null).then((user) => {
this.user = user;
});
this.liveType = this.getAttribute("live-type") == "true";
this.liveTypeInterval = parseInt(this.getAttribute("live-type-interval")) || 6;
this.channelUid = this.getAttribute("channel");
app.rpc.getRecentUsers(this.channelUid).then(users => {
this.users = users;
});
this.messageUid = null;
this.classList.add("chat-input");
this.fileUploadGrid = new FileUploadGrid();
this.fileUploadGrid.setAttribute("channel", this.channelUid);
this.fileUploadGrid.style.display = "none";
this.appendChild(this.fileUploadGrid);
this.textarea.setAttribute("placeholder", "Type a message...");
this.textarea.setAttribute("rows", "2");
this.appendChild(this.textarea);
this.ttsButton = document.createElement("stt-button");
this.appendChild(this.ttsButton);
this.uploadButton = document.createElement("upload-button");
this.uploadButton.setAttribute("channel", this.channelUid);
this.uploadButton.addEventListener("upload", (e) => {
this.dispatchEvent(new CustomEvent("upload", e));
});
this.uploadButton.addEventListener("uploaded", (e) => {
this.dispatchEvent(new CustomEvent("uploaded", e));
});
this.uploadButton.addEventListener("click", (e) => {
e.preventDefault();
this.fileUploadGrid.openFileDialog();
});
this.subscribe("file-uploading", (e) => {
this.fileUploadGrid.style.display = "block";
this.uploadButton.style.display = "none";
this.textarea.style.display = "none";
})
this.appendChild(this.uploadButton);
this.textarea.addEventListener("blur", () => {
this.updateFromInput("");
});
this.subscribe("file-uploads-done", (data)=>{
this.textarea.style.display = "block";
this.uploadButton.style.display = "block";
this.fileUploadGrid.style.display = "none";
let msg =data.reduce((message, file) => {
return `${message}[${file.filename || file.name || file.remoteFile}](/channel/attachment/${file.remoteFile})`;
}, '');
app.rpc.sendMessage(this.channelUid, msg, true);
});
this.textarea.addEventListener("change",(e)=>{
this.value = this.textarea.value;
this.updateFromInput(e.target.value);
})
this.textarea.addEventListener("keyup", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
const message = this.replaceMentionsWithAuthors(this.value);
e.target.value = "";
if (!message) {
return;
}
let autoCompletionHandler = this.allAutoCompletions[this.value.split(" ", 1)[0]];
if (autoCompletionHandler) {
autoCompletionHandler();
this.value = "";
e.target.value = "";
return;
}
this.finalizeMessage(this.messageUid);
return;
}
this.updateFromInput(e.target.value);
});
this.textarea.addEventListener("keydown", (e) => {
this.value = e.target.value;
let autoCompletion = null;
if (e.key === "Tab") {
e.preventDefault();
autoCompletion = this.resolveAutoComplete(this.value);
if (autoCompletion) {
e.target.value = autoCompletion;
this.value = autoCompletion;
return;
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
}
if (e.repeat) {
this.updateFromInput(e.target.value);
}
});
this.addEventListener("upload", (e) => {
this.focus();
});
this.addEventListener("uploaded", function (e) {
let message = e.detail.files.reduce((message, file) => {
return `${message}[${file.name}](/channel/attachment/${file.relative_url})`;
}, '');
app.rpc.sendMessage(this.channelUid, message, true);
});
setTimeout(() => {
this.focus();
}, 1000);
}
trackSecondsBetweenEvents(event1Time, event2Time) {
const millisecondsDifference = event2Time.getTime() - event1Time.getTime();
return millisecondsDifference / 1000;
}
isSubsequence(s, t) {
let i = 0, j = 0;
while (i < s.length && j < t.length) {
if (s[i] === t[j]) {
i++;
}
j++;
}
return i === s.length;
}
flagTyping() {
if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) >= 1) {
this.lastUpdateEvent = new Date();
app.rpc.set_typing(this.channelUid, this.user?.color).catch(() => {});
}
}
finalizeMessage(messageUid) {
let value = this.value;
value = this.replaceMentionsWithAuthors(value)
if(this._leetSpeak){
value = this.textToLeet(value);
}else if(this._leetSpeakAdvanced){
value = this.textToLeetAdvanced(value);
}
app.rpc.sendMessage(this.channelUid, value , true);
this.value = "";
this.messageUid = null;
this.queuedMessage = null;
this.lastMessagePromise = null;
}
updateFromInput(value) {
this.value = value;
this.flagTyping();
if (this.liveType && value[0] !== "/") {
const messageText = this.replaceMentionsWithAuthors(value);
this.messageUid = this.sendMessage(this.channelUid, messageText, !this.liveType);
return this.messageUid;
}
}
async sendMessage(channelUid, value, is_final) {
return await app.rpc.sendMessage(channelUid, value, is_final);
}
}
customElements.define("chat-input", ChatInputComponent);