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