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`; 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; } } } 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); tfrpc.rpc.appendMessage(this.whoami, message).then(function() { edit.value = ''; self.change(); self.notify(undefined); self.requestUpdate(); }).catch(function(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(); } firstUpdated() { let tribute = new Tribute({ values: Object.entries(this.users).map(x => ({key: x[1].name, value: x[0]})), 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; } } 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 || ''] || {}; } 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 result = html` <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()} <input type="button" value="Discard" @click=${this.discard}></input> `; return result; } } customElements.define('tf-compose', TfComposeElement);