2018-07-08 13:42:54 +00:00
|
|
|
<template>
|
2019-07-17 09:33:59 +00:00
|
|
|
<form id="form" method="post" action="" @submit.prevent="onSubmit">
|
2019-12-04 06:58:23 +00:00
|
|
|
<span id="upload-progressbar" />
|
2018-07-08 13:42:54 +00:00
|
|
|
<span id="nick">{{ network.nick }}</span>
|
|
|
|
<textarea
|
|
|
|
id="input"
|
2018-07-08 15:17:20 +00:00
|
|
|
ref="input"
|
2019-08-03 12:35:13 +00:00
|
|
|
dir="auto"
|
2019-11-27 14:28:54 +00:00
|
|
|
class="mousetrap"
|
2020-08-29 08:46:11 +00:00
|
|
|
enterkeyhint="send"
|
2018-08-29 07:34:21 +00:00
|
|
|
:value="channel.pendingMessage"
|
2018-07-08 13:42:54 +00:00
|
|
|
:placeholder="getInputPlaceholder(channel)"
|
|
|
|
:aria-label="getInputPlaceholder(channel)"
|
2018-08-29 07:34:21 +00:00
|
|
|
@input="setPendingMessage"
|
2019-02-25 05:38:13 +00:00
|
|
|
@keypress.enter.exact.prevent="onSubmit"
|
2022-04-12 01:11:43 +00:00
|
|
|
@blur="onBlur"
|
2019-02-25 05:38:13 +00:00
|
|
|
/>
|
2018-09-03 07:58:33 +00:00
|
|
|
<span
|
2022-06-19 00:25:21 +00:00
|
|
|
v-if="store.state.serverConfiguration?.fileUpload"
|
2018-09-03 07:58:33 +00:00
|
|
|
id="upload-tooltip"
|
|
|
|
class="tooltipped tooltipped-w tooltipped-no-touch"
|
2019-02-18 07:19:44 +00:00
|
|
|
aria-label="Upload file"
|
2019-02-25 05:38:13 +00:00
|
|
|
@click="openFileUpload"
|
|
|
|
>
|
2019-10-21 15:52:46 +00:00
|
|
|
<input
|
|
|
|
id="upload-input"
|
|
|
|
ref="uploadInput"
|
|
|
|
type="file"
|
2020-08-28 01:57:12 +00:00
|
|
|
aria-labelledby="upload"
|
2019-10-21 15:52:46 +00:00
|
|
|
multiple
|
|
|
|
@change="onUploadInputChange"
|
|
|
|
/>
|
2018-09-03 07:58:33 +00:00
|
|
|
<button
|
|
|
|
id="upload"
|
|
|
|
type="button"
|
2018-09-12 19:44:49 +00:00
|
|
|
aria-label="Upload file"
|
2022-06-19 00:25:21 +00:00
|
|
|
:disabled="!store.state.isConnected"
|
2019-02-25 05:38:13 +00:00
|
|
|
/>
|
2018-09-03 07:58:33 +00:00
|
|
|
</span>
|
2018-07-08 13:42:54 +00:00
|
|
|
<span
|
|
|
|
id="submit-tooltip"
|
|
|
|
class="tooltipped tooltipped-w tooltipped-no-touch"
|
2019-02-25 05:38:13 +00:00
|
|
|
aria-label="Send message"
|
|
|
|
>
|
2018-07-08 13:42:54 +00:00
|
|
|
<button
|
|
|
|
id="submit"
|
|
|
|
type="submit"
|
2018-09-12 19:54:20 +00:00
|
|
|
aria-label="Send message"
|
2022-06-19 00:25:21 +00:00
|
|
|
:disabled="!store.state.isConnected"
|
2019-02-25 05:38:13 +00:00
|
|
|
/>
|
2018-07-08 13:42:54 +00:00
|
|
|
</span>
|
|
|
|
</form>
|
|
|
|
</template>
|
|
|
|
|
2022-06-19 00:25:21 +00:00
|
|
|
<script lang="ts">
|
2019-11-16 17:24:03 +00:00
|
|
|
import Mousetrap from "mousetrap";
|
|
|
|
import {wrapCursor} from "undate";
|
2019-11-15 18:53:38 +00:00
|
|
|
import autocompletion from "../js/autocompletion";
|
2019-11-16 17:24:03 +00:00
|
|
|
import commands from "../js/commands/index";
|
|
|
|
import socket from "../js/socket";
|
|
|
|
import upload from "../js/upload";
|
2020-03-16 17:58:40 +00:00
|
|
|
import eventbus from "../js/eventbus";
|
2022-06-19 00:25:21 +00:00
|
|
|
import {watch, defineComponent, nextTick, onMounted, PropType, ref, onUnmounted} from "vue";
|
|
|
|
import type {ClientNetwork, ClientChan} from "../js/types";
|
|
|
|
import {useStore} from "../js/store";
|
2018-07-09 18:51:27 +00:00
|
|
|
|
2018-10-13 10:11:38 +00:00
|
|
|
const formattingHotkeys = {
|
2019-08-03 11:16:13 +00:00
|
|
|
"mod+k": "\x03",
|
|
|
|
"mod+b": "\x02",
|
|
|
|
"mod+u": "\x1F",
|
|
|
|
"mod+i": "\x1D",
|
|
|
|
"mod+o": "\x0F",
|
|
|
|
"mod+s": "\x1e",
|
|
|
|
"mod+m": "\x11",
|
2018-07-09 18:51:27 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
// Autocomplete bracket and quote characters like in a modern IDE
|
|
|
|
// For example, select `text`, press `[` key, and it becomes `[text]`
|
|
|
|
const bracketWraps = {
|
|
|
|
'"': '"',
|
|
|
|
"'": "'",
|
|
|
|
"(": ")",
|
|
|
|
"<": ">",
|
|
|
|
"[": "]",
|
|
|
|
"{": "}",
|
|
|
|
"*": "*",
|
|
|
|
"`": "`",
|
|
|
|
"~": "~",
|
2019-07-17 09:33:59 +00:00
|
|
|
_: "_",
|
2018-07-09 18:51:27 +00:00
|
|
|
};
|
2018-07-08 15:17:20 +00:00
|
|
|
|
2022-06-19 00:25:21 +00:00
|
|
|
export default defineComponent({
|
2018-07-08 13:42:54 +00:00
|
|
|
name: "ChatInput",
|
|
|
|
props: {
|
2022-06-19 00:25:21 +00:00
|
|
|
network: {type: Object as PropType<ClientNetwork>, required: true},
|
|
|
|
channel: {type: Object as PropType<ClientChan>, required: true},
|
2018-07-13 10:24:05 +00:00
|
|
|
},
|
2022-06-19 00:25:21 +00:00
|
|
|
setup(props) {
|
|
|
|
const store = useStore();
|
|
|
|
const input = ref<HTMLTextAreaElement>();
|
|
|
|
const uploadInput = ref<HTMLInputElement>();
|
|
|
|
const autocompletionRef = ref<ReturnType<typeof autocompletion>>();
|
|
|
|
|
|
|
|
const setInputSize = () => {
|
|
|
|
void nextTick(() => {
|
|
|
|
if (!input.value) {
|
2021-04-30 21:53:57 +00:00
|
|
|
return;
|
2018-09-09 12:23:12 +00:00
|
|
|
}
|
|
|
|
|
2022-06-19 00:25:21 +00:00
|
|
|
const style = window.getComputedStyle(input.value);
|
|
|
|
const lineHeight = parseFloat(style.lineHeight) || 1;
|
2018-08-29 07:34:21 +00:00
|
|
|
|
|
|
|
// Start by resetting height before computing as scrollHeight does not
|
|
|
|
// decrease when deleting characters
|
2022-06-19 00:25:21 +00:00
|
|
|
input.value.style.height = "";
|
2018-08-29 07:34:21 +00:00
|
|
|
|
|
|
|
// Use scrollHeight to calculate how many lines there are in input, and ceil the value
|
|
|
|
// because some browsers tend to incorrently round the values when using high density
|
|
|
|
// displays or using page zoom feature
|
2022-06-19 00:25:21 +00:00
|
|
|
input.value.style.height = `${
|
|
|
|
Math.ceil(input.value.scrollHeight / lineHeight) * lineHeight
|
|
|
|
}px`;
|
2018-08-29 07:34:21 +00:00
|
|
|
});
|
2022-06-19 00:25:21 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
const setPendingMessage = (e: Event) => {
|
|
|
|
props.channel.pendingMessage = (e.target as HTMLInputElement).value;
|
|
|
|
props.channel.inputHistoryPosition = 0;
|
|
|
|
setInputSize();
|
|
|
|
};
|
|
|
|
|
|
|
|
const getInputPlaceholder = (channel: ClientChan) => {
|
2018-07-08 13:42:54 +00:00
|
|
|
if (channel.type === "channel" || channel.type === "query") {
|
2023-10-05 11:26:52 +00:00
|
|
|
return `HARD CHATS IN ${channel.name}`;
|
2018-07-08 13:42:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return "";
|
2022-06-19 00:25:21 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
const onSubmit = () => {
|
|
|
|
if (!input.value) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2018-07-08 15:17:20 +00:00
|
|
|
// Triggering click event opens the virtual keyboard on mobile
|
|
|
|
// This can only be called from another interactive event (e.g. button click)
|
2022-06-19 00:25:21 +00:00
|
|
|
input.value.click();
|
|
|
|
input.value.focus();
|
2018-07-08 15:17:20 +00:00
|
|
|
|
2022-06-19 00:25:21 +00:00
|
|
|
if (!store.state.isConnected) {
|
2018-08-15 09:00:54 +00:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2022-06-19 00:25:21 +00:00
|
|
|
const target = props.channel.id;
|
|
|
|
const text = props.channel.pendingMessage;
|
2018-07-08 15:17:20 +00:00
|
|
|
|
|
|
|
if (text.length === 0) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
2022-06-19 00:25:21 +00:00
|
|
|
if (autocompletionRef.value) {
|
|
|
|
autocompletionRef.value.hide();
|
2019-11-15 18:53:38 +00:00
|
|
|
}
|
|
|
|
|
2022-06-19 00:25:21 +00:00
|
|
|
props.channel.inputHistoryPosition = 0;
|
|
|
|
props.channel.pendingMessage = "";
|
|
|
|
input.value.value = "";
|
|
|
|
setInputSize();
|
2018-07-08 15:17:20 +00:00
|
|
|
|
2018-09-09 12:23:12 +00:00
|
|
|
// Store new message in history if last message isn't already equal
|
2022-06-19 00:25:21 +00:00
|
|
|
if (props.channel.inputHistory[1] !== text) {
|
|
|
|
props.channel.inputHistory.splice(1, 0, text);
|
2018-09-09 12:23:12 +00:00
|
|
|
}
|
|
|
|
|
2018-09-12 20:35:55 +00:00
|
|
|
// Limit input history to a 100 entries
|
2022-06-19 00:25:21 +00:00
|
|
|
if (props.channel.inputHistory.length > 100) {
|
|
|
|
props.channel.inputHistory.pop();
|
2018-09-12 20:35:55 +00:00
|
|
|
}
|
|
|
|
|
2018-07-10 07:49:23 +00:00
|
|
|
if (text[0] === "/") {
|
2022-06-19 00:25:21 +00:00
|
|
|
const args = text.substring(1).split(" ");
|
|
|
|
const cmd = args.shift()?.toLowerCase();
|
|
|
|
|
|
|
|
if (!cmd) {
|
|
|
|
return false;
|
|
|
|
}
|
2018-07-08 15:17:20 +00:00
|
|
|
|
2019-07-17 09:33:59 +00:00
|
|
|
if (
|
|
|
|
Object.prototype.hasOwnProperty.call(commands, cmd) &&
|
|
|
|
commands[cmd].input(args)
|
|
|
|
) {
|
2018-07-08 15:17:20 +00:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
socket.emit("input", {target, text});
|
2022-06-19 00:25:21 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
const onUploadInputChange = () => {
|
|
|
|
if (!uploadInput.value || !uploadInput.value.files) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const files = Array.from(uploadInput.value.files);
|
2019-10-21 15:52:46 +00:00
|
|
|
upload.triggerUpload(files);
|
2022-06-19 00:25:21 +00:00
|
|
|
uploadInput.value.value = ""; // Reset <input> element so you can upload the same file
|
|
|
|
};
|
|
|
|
|
|
|
|
const openFileUpload = () => {
|
|
|
|
uploadInput.value?.click();
|
|
|
|
};
|
|
|
|
|
|
|
|
const blurInput = () => {
|
|
|
|
input.value?.blur();
|
|
|
|
};
|
|
|
|
|
|
|
|
const onBlur = () => {
|
|
|
|
if (autocompletionRef.value) {
|
|
|
|
autocompletionRef.value.hide();
|
2022-04-12 01:11:43 +00:00
|
|
|
}
|
2022-06-19 00:25:21 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
watch(
|
|
|
|
() => props.channel.id,
|
|
|
|
() => {
|
|
|
|
if (autocompletionRef.value) {
|
|
|
|
autocompletionRef.value.hide();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
|
|
|
watch(
|
|
|
|
() => props.channel.pendingMessage,
|
|
|
|
() => {
|
|
|
|
setInputSize();
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
eventbus.on("escapekey", blurInput);
|
|
|
|
|
|
|
|
if (store.state.settings.autocomplete) {
|
|
|
|
if (!input.value) {
|
|
|
|
throw new Error("ChatInput autocomplete: input element is not available");
|
|
|
|
}
|
|
|
|
|
|
|
|
autocompletionRef.value = autocompletion(input.value);
|
|
|
|
}
|
|
|
|
|
|
|
|
const inputTrap = Mousetrap(input.value);
|
|
|
|
|
|
|
|
inputTrap.bind(Object.keys(formattingHotkeys), function (e, key) {
|
|
|
|
const modifier = formattingHotkeys[key];
|
|
|
|
|
|
|
|
if (!e.target) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
wrapCursor(
|
|
|
|
e.target as HTMLTextAreaElement,
|
|
|
|
modifier,
|
|
|
|
(e.target as HTMLTextAreaElement).selectionStart ===
|
|
|
|
(e.target as HTMLTextAreaElement).selectionEnd
|
|
|
|
? ""
|
|
|
|
: modifier
|
|
|
|
);
|
|
|
|
|
|
|
|
return false;
|
|
|
|
});
|
|
|
|
|
|
|
|
inputTrap.bind(Object.keys(bracketWraps), function (e, key) {
|
|
|
|
if (
|
|
|
|
(e.target as HTMLTextAreaElement)?.selectionStart !==
|
|
|
|
(e.target as HTMLTextAreaElement).selectionEnd
|
|
|
|
) {
|
|
|
|
wrapCursor(e.target as HTMLTextAreaElement, key, bracketWraps[key]);
|
|
|
|
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
inputTrap.bind(["up", "down"], (e, key) => {
|
|
|
|
if (
|
|
|
|
store.state.isAutoCompleting ||
|
|
|
|
(e.target as HTMLTextAreaElement).selectionStart !==
|
|
|
|
(e.target as HTMLTextAreaElement).selectionEnd ||
|
|
|
|
!input.value
|
|
|
|
) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const onRow = (
|
|
|
|
input.value.value.slice(undefined, input.value.selectionStart).match(/\n/g) ||
|
|
|
|
[]
|
|
|
|
).length;
|
|
|
|
const totalRows = (input.value.value.match(/\n/g) || []).length;
|
|
|
|
|
|
|
|
const {channel} = props;
|
|
|
|
|
|
|
|
if (channel.inputHistoryPosition === 0) {
|
|
|
|
channel.inputHistory[channel.inputHistoryPosition] = channel.pendingMessage;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (key === "up" && onRow === 0) {
|
|
|
|
if (channel.inputHistoryPosition < channel.inputHistory.length - 1) {
|
|
|
|
channel.inputHistoryPosition++;
|
|
|
|
} else {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
} else if (
|
|
|
|
key === "down" &&
|
|
|
|
channel.inputHistoryPosition > 0 &&
|
|
|
|
onRow === totalRows
|
|
|
|
) {
|
|
|
|
channel.inputHistoryPosition--;
|
|
|
|
} else {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
channel.pendingMessage = channel.inputHistory[channel.inputHistoryPosition];
|
|
|
|
input.value.value = channel.pendingMessage;
|
|
|
|
setInputSize();
|
|
|
|
|
|
|
|
return false;
|
|
|
|
});
|
|
|
|
|
|
|
|
if (store.state.serverConfiguration?.fileUpload) {
|
|
|
|
upload.mounted();
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
eventbus.off("escapekey", blurInput);
|
|
|
|
|
|
|
|
if (autocompletionRef.value) {
|
|
|
|
autocompletionRef.value.destroy();
|
|
|
|
autocompletionRef.value = undefined;
|
|
|
|
}
|
|
|
|
|
2023-02-06 03:32:12 +00:00
|
|
|
upload.unmounted();
|
2022-06-19 00:25:21 +00:00
|
|
|
upload.abort();
|
|
|
|
});
|
|
|
|
|
|
|
|
return {
|
|
|
|
store,
|
|
|
|
input,
|
|
|
|
uploadInput,
|
|
|
|
onUploadInputChange,
|
|
|
|
openFileUpload,
|
|
|
|
blurInput,
|
|
|
|
onBlur,
|
|
|
|
setInputSize,
|
|
|
|
upload,
|
|
|
|
getInputPlaceholder,
|
|
|
|
onSubmit,
|
|
|
|
setPendingMessage,
|
|
|
|
};
|
2018-07-08 13:42:54 +00:00
|
|
|
},
|
2022-06-19 00:25:21 +00:00
|
|
|
});
|
2018-07-08 13:42:54 +00:00
|
|
|
</script>
|