forked from cory/tildefriends
583 lines
14 KiB
JavaScript
583 lines
14 KiB
JavaScript
import {LitElement, html, unsafeHTML, live} from './lit-all.min.js';
|
|
import * as tfutils from './tf-utils.js';
|
|
import * as tfrpc from '/static/tfrpc.js';
|
|
import {styles} from './tf-styles.js';
|
|
import Tribute from './tribute.esm.js';
|
|
|
|
class TfComposeElement extends LitElement {
|
|
static get properties() {
|
|
return {
|
|
whoami: {type: String},
|
|
users: {type: Object},
|
|
root: {type: String},
|
|
branch: {type: String},
|
|
apps: {type: Object},
|
|
drafts: {type: Object},
|
|
author: {type: String},
|
|
channel: {type: String},
|
|
};
|
|
}
|
|
|
|
static styles = styles;
|
|
|
|
constructor() {
|
|
super();
|
|
this.users = {};
|
|
this.root = undefined;
|
|
this.branch = undefined;
|
|
this.apps = undefined;
|
|
this.drafts = {};
|
|
this.author = undefined;
|
|
}
|
|
|
|
process_text(text) {
|
|
if (!text) {
|
|
return '';
|
|
}
|
|
/* Update mentions. */
|
|
let draft = this.get_draft();
|
|
let updated = false;
|
|
for (let match of text.matchAll(/\[([^\[]+)]\(([@&%][^\)]+)/g)) {
|
|
let name = match[1];
|
|
let link = match[2];
|
|
let balance = 0;
|
|
let bracket_end = match.index + match[1].length + '[]'.length - 1;
|
|
for (let i = bracket_end; i >= 0; i--) {
|
|
if (text.charAt(i) == ']') {
|
|
balance++;
|
|
} else if (text.charAt(i) == '[') {
|
|
balance--;
|
|
}
|
|
if (balance <= 0) {
|
|
name = text.substring(i + 1, bracket_end);
|
|
break;
|
|
}
|
|
}
|
|
if (!draft.mentions) {
|
|
draft.mentions = {};
|
|
}
|
|
if (!draft.mentions[link]) {
|
|
draft.mentions[link] = {
|
|
link: link,
|
|
};
|
|
}
|
|
draft.mentions[link].name = name.startsWith('@')
|
|
? name.substring(1)
|
|
: name;
|
|
updated = true;
|
|
}
|
|
if (updated) {
|
|
setTimeout(() => this.notify(draft), 0);
|
|
}
|
|
return tfutils.markdown(text);
|
|
}
|
|
|
|
input(event) {
|
|
let edit = this.renderRoot.getElementById('edit');
|
|
let preview = this.renderRoot.getElementById('preview');
|
|
preview.innerHTML = this.process_text(edit.innerText);
|
|
let content_warning = this.renderRoot.getElementById('content_warning');
|
|
let draft = this.get_draft();
|
|
draft.text = edit.innerText;
|
|
draft.content_warning = content_warning?.value;
|
|
setTimeout(() => this.notify(draft), 0);
|
|
}
|
|
|
|
notify(draft) {
|
|
this.dispatchEvent(
|
|
new CustomEvent('tf-draft', {
|
|
bubbles: true,
|
|
composed: true,
|
|
detail: {
|
|
id: this.branch,
|
|
draft: draft,
|
|
},
|
|
})
|
|
);
|
|
}
|
|
|
|
convert_to_format(buffer, type, mime_type) {
|
|
return new Promise(function (resolve, reject) {
|
|
let img = new Image();
|
|
img.onload = function () {
|
|
let canvas = document.createElement('canvas');
|
|
let width_scale = Math.min(img.width, 1024) / img.width;
|
|
let height_scale = Math.min(img.height, 1024) / img.height;
|
|
let scale = Math.min(width_scale, height_scale);
|
|
canvas.width = img.width * scale;
|
|
canvas.height = img.height * scale;
|
|
let context = canvas.getContext('2d');
|
|
context.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
let data_url = canvas.toDataURL(mime_type);
|
|
let result = atob(data_url.split(',')[1])
|
|
.split('')
|
|
.map((x) => x.charCodeAt(0));
|
|
resolve(result);
|
|
};
|
|
img.onerror = function (event) {
|
|
reject(new Error('Failed to load image.'));
|
|
};
|
|
let raw = Array.from(new Uint8Array(buffer))
|
|
.map((b) => String.fromCharCode(b))
|
|
.join('');
|
|
let original = `data:${type};base64,${btoa(raw)}`;
|
|
img.src = original;
|
|
});
|
|
}
|
|
|
|
async add_file(file) {
|
|
try {
|
|
let draft = this.get_draft();
|
|
let self = this;
|
|
let buffer = await file.arrayBuffer();
|
|
let type = file.type;
|
|
if (type.startsWith('image/')) {
|
|
let best_buffer;
|
|
let best_type;
|
|
for (let format of ['image/png', 'image/jpeg', 'image/webp']) {
|
|
let test_buffer = await self.convert_to_format(
|
|
buffer,
|
|
file.type,
|
|
format
|
|
);
|
|
if (!best_buffer || test_buffer.length < best_buffer.length) {
|
|
best_buffer = test_buffer;
|
|
best_type = format;
|
|
}
|
|
}
|
|
buffer = best_buffer;
|
|
type = best_type;
|
|
} else {
|
|
buffer = Array.from(new Uint8Array(buffer));
|
|
}
|
|
let id = await tfrpc.rpc.store_blob(buffer);
|
|
let name = type.split('/')[0] + ':' + file.name;
|
|
if (!draft.mentions) {
|
|
draft.mentions = {};
|
|
}
|
|
draft.mentions[id] = {
|
|
link: id,
|
|
name: name,
|
|
type: type,
|
|
size: buffer.length ?? buffer.byteLength,
|
|
};
|
|
let edit = self.renderRoot.getElementById('edit');
|
|
edit.innerText += `\n![${name}](${id})`;
|
|
self.input();
|
|
} catch (e) {
|
|
alert(e?.message);
|
|
}
|
|
}
|
|
|
|
paste(event) {
|
|
let self = this;
|
|
for (let item of event.clipboardData.items) {
|
|
if (item.type?.startsWith('image/')) {
|
|
let file = item.getAsFile();
|
|
if (!file) {
|
|
continue;
|
|
}
|
|
self.add_file(file);
|
|
break;
|
|
}
|
|
}
|
|
|
|
event.preventDefault();
|
|
document.execCommand(
|
|
'insertText',
|
|
false,
|
|
event.clipboardData.getData('text/plain')
|
|
);
|
|
}
|
|
|
|
async submit() {
|
|
let self = this;
|
|
let draft = this.get_draft();
|
|
let edit = this.renderRoot.getElementById('edit');
|
|
let message = {
|
|
type: 'post',
|
|
text: edit.innerText,
|
|
channel: this.channel,
|
|
};
|
|
if (this.root || this.branch) {
|
|
message.root = this.root;
|
|
message.branch = this.branch;
|
|
}
|
|
if (Object.values(draft.mentions || {}).length) {
|
|
message.mentions = Object.values(draft.mentions);
|
|
}
|
|
if (draft.content_warning !== undefined) {
|
|
message.contentWarning = draft.content_warning;
|
|
}
|
|
console.log('Would post:', message);
|
|
if (draft.encrypt_to) {
|
|
let to = new Set(draft.encrypt_to);
|
|
to.add(this.whoami);
|
|
to = [...to];
|
|
message.recps = to;
|
|
console.log('message is now', message);
|
|
message = await tfrpc.rpc.encrypt(
|
|
this.whoami,
|
|
to,
|
|
JSON.stringify(message)
|
|
);
|
|
console.log('encrypted as', message);
|
|
}
|
|
try {
|
|
await tfrpc.rpc.appendMessage(this.whoami, message);
|
|
self.notify(undefined);
|
|
} catch (error) {
|
|
alert(error.message);
|
|
}
|
|
}
|
|
|
|
discard() {
|
|
this.notify(undefined);
|
|
}
|
|
|
|
attach() {
|
|
let self = this;
|
|
let input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.onchange = function (event) {
|
|
let file = event.target.files[0];
|
|
self.add_file(file);
|
|
};
|
|
input.click();
|
|
}
|
|
|
|
async autocomplete(text, callback) {
|
|
this.last_autocomplete = text;
|
|
let results = [];
|
|
try {
|
|
let rows = await tfrpc.rpc.query(
|
|
`
|
|
SELECT json(messages.content) AS content FROM messages_fts(?)
|
|
JOIN messages ON messages.rowid = messages_fts.rowid
|
|
WHERE json(messages.content) LIKE ?
|
|
ORDER BY timestamp DESC LIMIT 10
|
|
`,
|
|
['"' + text.replace('"', '""') + '"', `%![%${text}%](%)%`]
|
|
);
|
|
for (let row of rows) {
|
|
for (let match of row.content.matchAll(/!\[([^\]]*)\]\((&.*?)\)/g)) {
|
|
if (match[1].toLowerCase().indexOf(text.toLowerCase()) != -1) {
|
|
results.push({key: match[1], value: match[2]});
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
if (this.last_autocomplete === text) {
|
|
callback(results);
|
|
}
|
|
}
|
|
}
|
|
|
|
firstUpdated() {
|
|
let values = Object.entries(this.users).map((x) => ({
|
|
key: x[1].name ?? x[0],
|
|
value: x[0],
|
|
}));
|
|
if (this.author) {
|
|
values = [].concat(
|
|
[
|
|
{
|
|
key: this.users[this.author]?.name,
|
|
value: this.author,
|
|
},
|
|
],
|
|
values
|
|
);
|
|
}
|
|
let tribute = new Tribute({
|
|
iframe: this.shadowRoot,
|
|
collection: [
|
|
{
|
|
values: values,
|
|
selectTemplate: function (item) {
|
|
return item
|
|
? `[@${item.original.key}](${item.original.value})`
|
|
: undefined;
|
|
},
|
|
},
|
|
{
|
|
trigger: '&',
|
|
values: this.autocomplete,
|
|
selectTemplate: function (item) {
|
|
return item
|
|
? `![${item.original.key}](${item.original.value})`
|
|
: undefined;
|
|
},
|
|
},
|
|
],
|
|
});
|
|
tribute.attach(this.renderRoot.getElementById('edit'));
|
|
}
|
|
|
|
updated() {
|
|
super.updated();
|
|
let edit = this.renderRoot.getElementById('edit');
|
|
if (this.last_updated_text !== edit.innerText) {
|
|
let preview = this.renderRoot.getElementById('preview');
|
|
preview.innerHTML = this.process_text(edit.innerText);
|
|
this.last_updated_text = edit.innerText;
|
|
}
|
|
let encrypt = this.renderRoot.getElementById('encrypt_to');
|
|
if (encrypt) {
|
|
let tribute = new Tribute({
|
|
iframe: this.shadowRoot,
|
|
values: Object.entries(this.users).map((x) => ({
|
|
key: x[1].name,
|
|
value: x[0],
|
|
})),
|
|
selectTemplate: function (item) {
|
|
return item.original.value;
|
|
},
|
|
});
|
|
tribute.attach(encrypt);
|
|
}
|
|
}
|
|
|
|
remove_mention(id) {
|
|
let draft = this.get_draft();
|
|
delete draft.mentions[id];
|
|
setTimeout(() => this.notify(), 0);
|
|
}
|
|
|
|
render_mention(mention) {
|
|
let self = this;
|
|
return html` <div style="display: flex; flex-direction: row">
|
|
<div style="align-self: center; margin: 0.5em">
|
|
<button
|
|
class="w3-button w3-theme-d1"
|
|
title="Remove ${mention.name} mention"
|
|
@click=${() => self.remove_mention(mention.link)}
|
|
>
|
|
🚮
|
|
</button>
|
|
</div>
|
|
<div style="display: flex; flex-direction: column">
|
|
<h3>${mention.name}</h3>
|
|
<div style="padding-left: 1em">
|
|
${Object.entries(mention)
|
|
.filter((x) => x[0] != 'name')
|
|
.map(
|
|
(x) =>
|
|
html`<div>
|
|
<span style="font-weight: bold">${x[0]}</span>: ${x[1]}
|
|
</div>`
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
render_attach_app() {
|
|
let self = this;
|
|
|
|
async function attach_selected_app() {
|
|
let name = self.renderRoot.getElementById('select').value;
|
|
let id = self.apps[name];
|
|
let mentions = {};
|
|
mentions[id] = {
|
|
name: name,
|
|
link: id,
|
|
type: 'application/tildefriends',
|
|
};
|
|
if (name && id) {
|
|
let app = JSON.parse(await tfrpc.rpc.get_blob(id));
|
|
for (let entry of Object.entries(app.files)) {
|
|
mentions[entry[1]] = {
|
|
name: entry[0],
|
|
link: entry[1],
|
|
};
|
|
}
|
|
}
|
|
let draft = self.get_draft();
|
|
draft.mentions = Object.assign(draft.mentions || {}, mentions);
|
|
self.requestUpdate();
|
|
self.notify(draft);
|
|
self.apps = null;
|
|
}
|
|
|
|
if (this.apps) {
|
|
return html`
|
|
<div class="w3-card-4 w3-margin w3-padding">
|
|
<select id="select" class="w3-select w3-theme-d1">
|
|
${Object.keys(self.apps).map(
|
|
(app) => html`<option value=${app}>${app}</option>`
|
|
)}
|
|
</select>
|
|
<button class="w3-button w3-theme-d1" @click=${attach_selected_app}>
|
|
Attach
|
|
</button>
|
|
<button
|
|
class="w3-button w3-theme-d1"
|
|
@click=${() => (this.apps = null)}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
render_attach_app_button() {
|
|
let self = this;
|
|
async function attach_app() {
|
|
self.apps = await tfrpc.rpc.apps();
|
|
}
|
|
if (!this.apps) {
|
|
return html`<button class="w3-button w3-theme-d1" @click=${attach_app}>
|
|
Attach App
|
|
</button>`;
|
|
} else {
|
|
return html`<button
|
|
class="w3-button w3-theme-d1"
|
|
@click=${() => (this.apps = null)}
|
|
>
|
|
Discard App
|
|
</button>`;
|
|
}
|
|
}
|
|
|
|
set_content_warning(value) {
|
|
let draft = this.get_draft();
|
|
draft.content_warning = value;
|
|
this.notify(draft);
|
|
this.requestUpdate();
|
|
}
|
|
|
|
render_content_warning() {
|
|
let self = this;
|
|
let draft = this.get_draft();
|
|
if (draft.content_warning !== undefined) {
|
|
return html`
|
|
<div class="w3-container w3-padding">
|
|
<p>
|
|
<input type="checkbox" class="w3-check w3-theme-d1" id="cw" @change=${() => self.set_content_warning(undefined)} checked="checked"></input>
|
|
<label for="cw">CW</label>
|
|
</p>
|
|
<input type="text" class="w3-input w3-border w3-theme-d1" id="content_warning" placeholder="Enter a content warning here." @input=${self.input} value=${draft.content_warning}></input>
|
|
</div>
|
|
`;
|
|
} else {
|
|
return html`
|
|
<input type="checkbox" class="w3-check w3-theme-d1" id="cw" @change=${() => self.set_content_warning('')}></input>
|
|
<label for="cw">CW</label>
|
|
`;
|
|
}
|
|
}
|
|
|
|
get_draft() {
|
|
return this.drafts[this.branch || ''] || {};
|
|
}
|
|
|
|
update_encrypt(event) {
|
|
let input = event.srcElement;
|
|
let matches = input.value.match(/@.*?\.ed25519/g);
|
|
if (matches) {
|
|
let draft = this.get_draft();
|
|
let to = [...new Set(matches.concat(draft.encrypt_to))];
|
|
this.set_encrypt(to);
|
|
input.value = '';
|
|
}
|
|
}
|
|
|
|
render_encrypt() {
|
|
let draft = this.get_draft();
|
|
if (draft.encrypt_to === undefined) {
|
|
return;
|
|
}
|
|
return html`
|
|
<div style="display: flex; flex-direction: row; width: 100%">
|
|
<label for="encrypt_to">🔐 To:</label>
|
|
<input type="text" id="encrypt_to" style="display: flex; flex: 1 1" @input=${this.update_encrypt}></input>
|
|
<button class="w3-button w3-theme-d1" @click=${() => this.set_encrypt(undefined)}>🚮</button>
|
|
</div>
|
|
<ul>
|
|
${draft.encrypt_to.map(
|
|
(x) => html`
|
|
<li>
|
|
<tf-user id=${x} .users=${this.users}></tf-user>
|
|
<input type="button" class="w3-button w3-theme-d1" value="🚮" @click=${() => this.set_encrypt(draft.encrypt_to.filter((id) => id != x))}></input>
|
|
</li>`
|
|
)}
|
|
</ul>
|
|
`;
|
|
}
|
|
|
|
set_encrypt(encrypt) {
|
|
let draft = this.get_draft();
|
|
draft.encrypt_to = encrypt;
|
|
this.notify(draft);
|
|
this.requestUpdate();
|
|
}
|
|
|
|
render() {
|
|
let self = this;
|
|
let draft = self.get_draft();
|
|
let content_warning =
|
|
draft.content_warning !== undefined
|
|
? html`<div class="w3-panel w3-round-xlarge w3-theme-d2">
|
|
<p id="content_warning_preview">${draft.content_warning}</p>
|
|
</div>`
|
|
: undefined;
|
|
let encrypt =
|
|
draft.encrypt_to !== undefined
|
|
? undefined
|
|
: html`<button
|
|
class="w3-button w3-theme-d1"
|
|
@click=${() => this.set_encrypt([])}
|
|
>
|
|
🔐
|
|
</button>`;
|
|
let result = html`
|
|
<div
|
|
class="w3-card-4 w3-theme-d4 w3-padding-small"
|
|
style="box-sizing: border-box"
|
|
>
|
|
${this.channel !== undefined ?
|
|
html`<p>To #${this.channel}:</p>` :
|
|
undefined}
|
|
${this.render_encrypt()}
|
|
<div class="w3-container w3-padding-small">
|
|
<div class="w3-half">
|
|
<span
|
|
class="w3-input w3-theme-d1 w3-border"
|
|
style="resize: vertical; width: 100%; overflow: hidden; white-space: pre-wrap"
|
|
placeholder="Write a post here."
|
|
id="edit"
|
|
@input=${this.input}
|
|
@paste=${this.paste}
|
|
contenteditable="plaintext-only"
|
|
.innerText=${live(draft.text ?? '')}
|
|
></span>
|
|
</div>
|
|
<div class="w3-half w3-padding">
|
|
${content_warning}
|
|
<div id="preview"></div>
|
|
</div>
|
|
</div>
|
|
${Object.values(draft.mentions || {}).map((x) =>
|
|
self.render_mention(x)
|
|
)}
|
|
${this.render_attach_app()} ${this.render_content_warning()}
|
|
<button class="w3-button w3-theme-d1" id="submit" @click=${this.submit}>
|
|
Submit
|
|
</button>
|
|
<button class="w3-button w3-theme-d1" @click=${this.attach}>
|
|
Attach
|
|
</button>
|
|
${this.render_attach_app_button()} ${encrypt}
|
|
<button class="w3-button w3-theme-d1" @click=${this.discard}>
|
|
Discard
|
|
</button>
|
|
</div>
|
|
`;
|
|
return result;
|
|
}
|
|
}
|
|
|
|
customElements.define('tf-compose', TfComposeElement);
|