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}, }; } 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; } } } async submit() { let self = this; let draft = this.get_draft(); let edit = this.renderRoot.getElementById('edit'); let message = { type: 'post', text: edit.innerText, }; 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`

${mention.name}

${Object.entries(mention) .filter((x) => x[0] != 'name') .map( (x) => html`
${x[0]}: ${x[1]}
` )}
`; } 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`
`; } } render_attach_app_button() { let self = this; async function attach_app() { self.apps = await tfrpc.rpc.apps(); } if (!this.apps) { return html``; } else { return html``; } } 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`

self.set_content_warning(undefined)} checked="checked">

`; } else { return html` self.set_content_warning('')}> `; } } 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`
`; } 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`

${draft.content_warning}

` : undefined; let encrypt = draft.encrypt_to !== undefined ? undefined : html``; let result = html`
${this.render_encrypt()}
${content_warning}
${Object.values(draft.mentions || {}).map((x) => self.render_mention(x) )} ${this.render_attach_app()} ${this.render_content_warning()} ${this.render_attach_app_button()} ${encrypt}
`; return result; } } customElements.define('tf-compose', TfComposeElement);