mirror of
https://codeberg.org/emersion/gamja.git
synced 2024-11-14 10:55:06 -05:00
360 lines
8.5 KiB
JavaScript
360 lines
8.5 KiB
JavaScript
import { html, Component, createRef } from "../lib/index.js";
|
|
|
|
function encodeContentDisposition(filename) {
|
|
// Encode filename according to RFC 5987 if necessary. Note,
|
|
// encodeURIComponent will percent-encode a superset of attr-char.
|
|
let encodedFilename = encodeURIComponent(filename);
|
|
if (encodedFilename === filename) {
|
|
return "attachment; filename=\"" + filename + "\"";
|
|
} else {
|
|
return "attachment; filename*=UTF-8''" + encodedFilename;
|
|
}
|
|
}
|
|
|
|
export default class Composer extends Component {
|
|
state = {
|
|
text: "",
|
|
};
|
|
textInput = createRef();
|
|
lastAutocomplete = null;
|
|
|
|
constructor(props) {
|
|
super(props);
|
|
|
|
this.handleInput = this.handleInput.bind(this);
|
|
this.handleSubmit = this.handleSubmit.bind(this);
|
|
this.handleInputKeyDown = this.handleInputKeyDown.bind(this);
|
|
this.handleInputPaste = this.handleInputPaste.bind(this);
|
|
this.handleDragOver = this.handleDragOver.bind(this);
|
|
this.handleDrop = this.handleDrop.bind(this);
|
|
this.handleWindowKeyDown = this.handleWindowKeyDown.bind(this);
|
|
this.handleWindowPaste = this.handleWindowPaste.bind(this);
|
|
}
|
|
|
|
handleInput(event) {
|
|
this.setState({ [event.target.name]: event.target.value });
|
|
|
|
if (this.props.readOnly && event.target.name === "text" && !event.target.value) {
|
|
event.target.blur();
|
|
}
|
|
}
|
|
|
|
handleSubmit(event) {
|
|
event.preventDefault();
|
|
this.props.onSubmit(this.state.text);
|
|
this.setState({ text: "" });
|
|
}
|
|
|
|
handleInputKeyDown(event) {
|
|
let input = event.target;
|
|
|
|
if (!this.props.autocomplete || event.key !== "Tab") {
|
|
return;
|
|
}
|
|
|
|
if (input.selectionStart !== input.selectionEnd) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
let carretPos = input.selectionStart;
|
|
let text = this.state.text;
|
|
let autocomplete;
|
|
if (this.lastAutocomplete && this.lastAutocomplete.text === text && this.lastAutocomplete.carretPos === carretPos) {
|
|
autocomplete = this.lastAutocomplete;
|
|
} else {
|
|
this.lastAutocomplete = null;
|
|
|
|
let wordStart;
|
|
for (wordStart = carretPos - 1; wordStart >= 0; wordStart--) {
|
|
if (text[wordStart] === " ") {
|
|
break;
|
|
}
|
|
}
|
|
wordStart++;
|
|
|
|
let wordEnd;
|
|
for (wordEnd = carretPos; wordEnd < text.length; wordEnd++) {
|
|
if (text[wordEnd] === " ") {
|
|
break;
|
|
}
|
|
}
|
|
|
|
let word = text.slice(wordStart, wordEnd);
|
|
if (!word) {
|
|
return;
|
|
}
|
|
|
|
let replacements = this.props.autocomplete(word);
|
|
if (replacements.length === 0) {
|
|
return;
|
|
}
|
|
|
|
autocomplete = {
|
|
text,
|
|
carretPos: input.selectionStart,
|
|
prefix: text.slice(0, wordStart),
|
|
suffix: text.slice(wordEnd),
|
|
replacements,
|
|
replIndex: -1,
|
|
};
|
|
}
|
|
|
|
let n = autocomplete.replacements.length;
|
|
if (event.shiftKey) {
|
|
autocomplete.replIndex--;
|
|
} else {
|
|
autocomplete.replIndex++;
|
|
}
|
|
autocomplete.replIndex = (autocomplete.replIndex + n) % n;
|
|
|
|
let repl = autocomplete.replacements[autocomplete.replIndex];
|
|
if (!autocomplete.prefix && !autocomplete.suffix) {
|
|
if (repl.startsWith("/")) {
|
|
repl += " ";
|
|
} else {
|
|
repl += ": ";
|
|
}
|
|
}
|
|
|
|
autocomplete.text = autocomplete.prefix + repl + autocomplete.suffix;
|
|
autocomplete.carretPos = autocomplete.prefix.length + repl.length;
|
|
|
|
input.value = autocomplete.text;
|
|
input.selectionStart = autocomplete.carretPos;
|
|
input.selectionEnd = input.selectionStart;
|
|
|
|
this.lastAutocomplete = autocomplete;
|
|
|
|
this.setState({ text: autocomplete.text });
|
|
}
|
|
|
|
canUploadFiles() {
|
|
let client = this.props.client;
|
|
return client && client.isupport.filehost() && !this.props.readOnly;
|
|
}
|
|
|
|
async uploadFile(file) {
|
|
let client = this.props.client;
|
|
let endpoint = client.isupport.filehost();
|
|
|
|
let auth;
|
|
if (client.params.saslPlain) {
|
|
let params = client.params.saslPlain;
|
|
auth = "Basic " + btoa(params.username + ":" + params.password);
|
|
} else if (client.params.saslOauthBearer) {
|
|
auth = "Bearer " + client.params.saslOauthBearer.token;
|
|
}
|
|
|
|
let headers = {
|
|
"Content-Length": file.size,
|
|
"Content-Disposition": encodeContentDisposition(file.name),
|
|
};
|
|
if (file.type) {
|
|
headers["Content-Type"] = file.type;
|
|
}
|
|
if (auth) {
|
|
headers["Authorization"] = auth;
|
|
}
|
|
|
|
// TODO: show a loading UI while uploading
|
|
// TODO: show a cancel button
|
|
let resp = await fetch(endpoint, {
|
|
method: "POST",
|
|
body: file,
|
|
headers,
|
|
credentials: "include",
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
throw new Error(`HTTP request failed (${resp.status})`);
|
|
}
|
|
|
|
let loc = resp.headers.get("Location");
|
|
if (!loc) {
|
|
throw new Error("filehost response missing Location header field");
|
|
}
|
|
|
|
return new URL(loc, endpoint);
|
|
}
|
|
|
|
async uploadFileList(fileList) {
|
|
let promises = [];
|
|
for (let file of fileList) {
|
|
promises.push(this.uploadFile(file));
|
|
}
|
|
|
|
let urls = await Promise.all(promises);
|
|
|
|
this.setState((state) => {
|
|
if (state.text) {
|
|
return { text: state.text + " " + urls.join(" ") };
|
|
} else {
|
|
return { text: urls.join(" ") };
|
|
}
|
|
});
|
|
}
|
|
|
|
async handleInputPaste(event) {
|
|
if (event.clipboardData.files.length === 0 || !this.canUploadFiles()) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
|
|
await this.uploadFileList(event.clipboardData.files);
|
|
}
|
|
|
|
handleDragOver(event) {
|
|
if (event.dataTransfer.items.length === 0 || !this.canUploadFiles()) {
|
|
return;
|
|
}
|
|
|
|
for (let item of event.dataTransfer.items) {
|
|
if (item.kind !== "file") {
|
|
return;
|
|
}
|
|
}
|
|
|
|
event.preventDefault();
|
|
}
|
|
|
|
async handleDrop(event) {
|
|
if (event.dataTransfer.files.length === 0 || !this.canUploadFiles()) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
|
|
await this.uploadFileList(event.dataTransfer.files);
|
|
}
|
|
|
|
handleWindowKeyDown(event) {
|
|
// If an <input> or <button> is focused, ignore.
|
|
if (document.activeElement && document.activeElement !== document.body) {
|
|
switch (document.activeElement.tagName.toLowerCase()) {
|
|
case "section":
|
|
case "a":
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
}
|
|
|
|
// If a modifier is pressed, reserve for key bindings.
|
|
if (event.altKey || event.ctrlKey || event.metaKey) {
|
|
return;
|
|
}
|
|
|
|
// Ignore events that don't produce a Unicode string. If the key event
|
|
// result in a character being typed by the user, KeyboardEvent.key
|
|
// will contain the typed string. The key string may contain one
|
|
// Unicode non-control character and multiple Unicode combining
|
|
// characters. String.prototype.length cannot be used since it would
|
|
// return the number of Unicode code-points. Instead, the spread
|
|
// operator is used to count the number of non-combining Unicode
|
|
// characters.
|
|
if ([...event.key].length !== 1) {
|
|
return;
|
|
}
|
|
|
|
if (this.state.text) {
|
|
return;
|
|
}
|
|
|
|
if (this.props.readOnly || (this.props.commandOnly && event.key !== "/")) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
this.setState({ text: event.key }, () => {
|
|
this.focus();
|
|
});
|
|
}
|
|
|
|
handleWindowPaste(event) {
|
|
// If an <input> is focused, ignore.
|
|
if (document.activeElement !== document.body && document.activeElement.tagName !== "SECTION") {
|
|
return;
|
|
}
|
|
|
|
if (this.props.readOnly) {
|
|
return;
|
|
}
|
|
|
|
if (!this.textInput.current) {
|
|
return;
|
|
}
|
|
|
|
if (event.clipboardData.files.length > 0) {
|
|
this.handleInputPaste(event);
|
|
return;
|
|
}
|
|
|
|
let text = event.clipboardData.getData("text");
|
|
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
|
|
this.textInput.current.focus();
|
|
this.textInput.current.setRangeText(text, undefined, undefined, "end");
|
|
this.setState({ text: this.textInput.current.value });
|
|
}
|
|
|
|
componentDidMount() {
|
|
window.addEventListener("keydown", this.handleWindowKeyDown);
|
|
window.addEventListener("paste", this.handleWindowPaste);
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
window.removeEventListener("keydown", this.handleWindowKeyDown);
|
|
window.removeEventListener("paste", this.handleWindowPaste);
|
|
}
|
|
|
|
focus() {
|
|
if (!this.textInput.current) {
|
|
return;
|
|
}
|
|
document.activeElement.blur(); // in case we're read-only
|
|
this.textInput.current.focus();
|
|
}
|
|
|
|
render() {
|
|
let className = "";
|
|
if (this.props.readOnly && !this.state.text) {
|
|
className = "read-only";
|
|
}
|
|
|
|
let placeholder = "Type a message";
|
|
if (this.props.commandOnly) {
|
|
placeholder = "Type a command (see /help)";
|
|
}
|
|
|
|
return html`
|
|
<form
|
|
id="composer"
|
|
class=${className}
|
|
onInput=${this.handleInput}
|
|
onSubmit=${this.handleSubmit}
|
|
>
|
|
<input
|
|
type="text"
|
|
name="text"
|
|
ref=${this.textInput}
|
|
value=${this.state.text}
|
|
autocomplete="off"
|
|
placeholder=${placeholder}
|
|
enterkeyhint="send"
|
|
onKeyDown=${this.handleInputKeyDown}
|
|
onPaste=${this.handleInputPaste}
|
|
onDragOver=${this.handleDragOver}
|
|
onDrop=${this.handleDrop}
|
|
maxlength=${this.props.maxLen}
|
|
/>
|
|
</form>
|
|
`;
|
|
}
|
|
}
|