tildefriends/apps/ssb/tf-compose.js

485 lines
13 KiB
JavaScript

import {LitElement, html, unsafeHTML} 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},
};
}
static styles = styles;
constructor() {
super();
this.users = {};
this.root = undefined;
this.branch = undefined;
this.apps = undefined;
this.drafts = {};
}
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) {
this.requestUpdate();
}
return tfutils.markdown(text);
}
input(event) {
let edit = this.renderRoot.getElementById('edit');
let preview = this.renderRoot.getElementById('preview');
preview.innerHTML = this.process_text(edit.value);
let content_warning = this.renderRoot.getElementById('content_warning');
let content_warning_preview = this.renderRoot.getElementById('content_warning_preview');
if (content_warning && content_warning_preview) {
content_warning_preview.innerText = content_warning.value;
}
}
notify(draft) {
this.dispatchEvent(new CustomEvent('tf-draft', {
bubbles: true,
composed: true,
detail: {
id: this.branch,
draft: draft
},
}));
}
change() {
let draft = this.get_draft();
draft.text = this.renderRoot.getElementById('edit')?.value;
draft.content_warning = this.renderRoot.getElementById('content_warning')?.value;
this.notify(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.value += `\n![${name}](${id})`;
self.change();
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;
}
}
}
async submit() {
let self = this;
let draft = this.get_draft();
let edit = this.renderRoot.getElementById('edit');
let message = {
type: 'post',
text: edit.value,
};
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).then(function() {
edit.value = '';
self.change();
self.notify(undefined);
self.requestUpdate();
});
} catch (error) {
alert(error.message);
}
}
discard() {
let edit = this.renderRoot.getElementById('edit');
edit.value = '';
this.change();
let preview = this.renderRoot.getElementById('preview');
preview.innerHTML = '';
this.notify(undefined);
}
attach() {
let self = this;
let edit = this.renderRoot.getElementById('edit');
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 messages.content FROM messages_fts(?)
JOIN messages ON messages.rowid = messages_fts.rowid
WHERE 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) != -1) {
results.push({key: match[1], value: match[2]});
}
}
}
} finally {
if (this.last_autocomplete === text) {
callback(results);
}
}
}
firstUpdated() {
let tribute = new Tribute({
collection: [
{
values: Object.entries(this.users).map(x => ({key: x[1].name, value: x[0]})),
selectTemplate: function(item) {
return `[@${item.original.key}](${item.original.value})`;
},
},
{
trigger: '&',
values: this.autocomplete,
selectTemplate: function(item) {
return `![${item.original.key}](${item.original.value})`;
},
},
],
});
tribute.attach(this.renderRoot.getElementById('edit'));
}
updated() {
super.updated();
let edit = this.renderRoot.getElementById('edit');
if (this.last_updated_text !== edit.value) {
let preview = this.renderRoot.getElementById('preview');
preview.innerHTML = this.process_text(edit.value);
this.last_updated_text = edit.value;
}
let encrypt = this.renderRoot.getElementById('encrypt_to');
if (encrypt) {
let tribute = new Tribute({
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];
this.notify(draft);
this.requestUpdate();
}
render_mention(mention) {
let self = this;
return html`
<div style="display: flex; flex-direction: row">
<div style="align-self: center; margin: 0.5em">
<input type="button" value="🚮" title="Remove ${mention.name} mention" @click=${() => self.remove_mention(mention.link)}></input>
</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>
<select id="select">
${Object.keys(self.apps).map(app => html`<option value=${app}>${app}</option>`)}
</select>
<input type="button" value="Attach" @click=${attach_selected_app}></input>
<input type="button" value="Cancel" @click=${() => this.apps = null}></input>
</div>
`;
}
}
render_attach_app_button() {
let self = this;
async function attach_app() {
self.apps = await tfrpc.rpc.apps();
}
if (!this.apps) {
return html`<input type="button" value="Attach App" @click=${attach_app}></input>`;
} else {
return html`<input type="button" value="Discard App" @click=${() => this.apps = null}></input>`;
}
}
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>
<input type="checkbox" id="cw" @change=${() => self.set_content_warning(undefined)} checked></input>
<label for="cw">CW</label>
<input type="text" id="content_warning" @input=${this.input} @change=${this.change} value=${draft.content_warning}></input>
</div>
`;
} else {
return html`
<input type="checkbox" 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>
<input type="button" value="🚮" @click=${() => this.set_encrypt(undefined)}></input>
</div>
<ul>
${draft.encrypt_to.map(x => html`
<li>
<tf-user id=${x} .users=${this.users}></tf-user>
<input type="button" 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 id="content_warning_preview" class="content_warning">${draft.content_warning}</div>` :
undefined;
let encrypt = draft.encrypt_to !== undefined ?
undefined :
html`<input type="button" value="🔐" @click=${() => this.set_encrypt([])}></input>`;
let result = html`
${this.render_encrypt()}
<div style="display: flex; flex-direction: row; width: 100%">
<textarea id="edit" @input=${this.input} @change=${this.change} @paste=${this.paste} style="flex: 1 0 50%">${draft.text}</textarea>
<div style="flex: 1 0 50%">
${content_warning}
<div id="preview"></div>
</div>
</div>
${Object.values(draft.mentions || {}).map(x => self.render_mention(x))}
${this.render_content_warning()}
${this.render_attach_app()}
<input type="button" id="submit" value="Submit" @click=${this.submit}></input>
<input type="button" value="Attach" @click=${this.attach}></input>
${this.render_attach_app_button()}
${encrypt}
<input type="button" value="Discard" @click=${this.discard}></input>
`;
return result;
}
}
customElements.define('tf-compose', TfComposeElement);