import {LitElement, html, render, unsafeHTML} from './lit-all.min.js'; import * as tfrpc from '/static/tfrpc.js'; import * as tfutils from './tf-utils.js'; import * as emojis from './emojis.js'; import {styles} from './tf-styles.js'; class TfMessageElement extends LitElement { static get properties() { return { whoami: {type: String}, message: {type: Object}, users: {type: Object}, drafts: {type: Object}, format: {type: String}, blog_data: {type: String}, expanded: {type: Object}, }; } static styles = styles; constructor() { super(); let self = this; this.whoami = null; this.message = {}; this.users = {}; this.drafts = {}; this.format = 'message'; this.expanded = {}; } show_reply() { let event = new CustomEvent('tf-draft', { bubbles: true, composed: true, detail: { id: this.message?.id, draft: { encrypt_to: this.message?.decrypted?.recps, }, }, }); this.dispatchEvent(event); } discard_reply() { this.dispatchEvent( new CustomEvent('tf-draft', { bubbles: true, composed: true, detail: {id: this.id, draft: undefined}, }) ); } show_reactions() { let modal = document.getElementById('reactions_modal'); modal.users = this.users; modal.votes = this.message?.votes || []; } render_votes() { function normalize_expression(expression) { if (expression === 'Like' || !expression) { return '👍'; } else if (expression === 'Unlike') { return '👎'; } else if (expression === 'heart') { return '❤️'; } else { return expression; } } if (this.message?.votes?.length) { return html`<div class="w3-button" @click=${this.show_reactions}> ${(this.message.votes || []).map( (vote) => html` <span title="${this.users[vote.author]?.name ?? vote.author} ${new Date( vote.timestamp )}" > ${normalize_expression(vote.content.vote.expression)} </span> ` )} </div>`; } } render_raw() { let raw = { id: this.message?.id, previous: this.message?.previous, author: this.message?.author, sequence: this.message?.sequence, timestamp: this.message?.timestamp, hash: this.message?.hash, content: this.message?.content, signature: this.message?.signature, }; return html`<div style="white-space: pre-wrap"> ${JSON.stringify(raw, null, 2)} </div>`; } vote(emoji) { let reaction = emoji; let message = this.message.id; if ( confirm( 'Are you sure you want to react with ' + reaction + ' to ' + message + '?' ) ) { tfrpc.rpc .appendMessage(this.whoami, { type: 'vote', vote: { link: message, value: 1, expression: reaction, }, }) .catch(function (error) { alert(error?.message); }); } } react(event) { emojis.picker((x) => this.vote(x), null, this.whoami); } show_image(link) { let div = document.createElement('div'); div.style.left = 0; div.style.top = 0; div.style.width = '100%'; div.style.height = '100%'; div.style.position = 'fixed'; div.style.background = '#000'; div.style.zIndex = 100; div.style.display = 'grid'; let img = document.createElement('img'); img.src = link; img.style.maxWidth = '100%'; img.style.maxHeight = '100%'; img.style.display = 'block'; img.style.margin = 'auto'; img.style.objectFit = 'contain'; img.style.width = '100%'; div.appendChild(img); function image_close(event) { document.body.removeChild(div); window.removeEventListener('keydown', image_close); } div.onclick = image_close; window.addEventListener('keydown', image_close); document.body.appendChild(div); } body_click(event) { if (event.srcElement.tagName == 'IMG') { this.show_image(event.srcElement.src); } else if ( event.srcElement.tagName == 'DIV' && event.srcElement.classList.contains('img_caption') ) { let next = event.srcElement.nextSibling; if (next.style.display == 'block') { next.style.display = 'none'; } else { next.style.display = 'block'; } } } render_mention(mention) { if (!mention?.link || typeof mention.link != 'string') { return html` <pre>${JSON.stringify(mention)}</pre>`; } else if ( mention?.link?.startsWith('&') && mention?.type?.startsWith('image/') ) { return html` <img src=${'/' + mention.link + '/view'} style="max-width: 128px; max-height: 128px" title=${mention.name} @click=${() => this.show_image('/' + mention.link + '/view')} /> `; } else if ( mention.link?.startsWith('&') && mention.name?.startsWith('audio:') ) { return html` <audio controls style="height: 32px"> <source src=${'/' + mention.link + '/view'}></source> </audio> `; } else if ( mention.link?.startsWith('&') && mention.name?.startsWith('video:') ) { return html` <video controls style="max-height: 240px; max-width: 128px"> <source src=${'/' + mention.link + '/view'}></source> </video> `; } else if ( mention.link?.startsWith('&') && mention?.type === 'application/tildefriends' ) { return html` <a href=${`/${mention.link}/`}>😎 ${mention.name}</a>`; } else if (mention.link?.startsWith('%') || mention.link?.startsWith('@')) { return html` <a href=${'#' + encodeURIComponent(mention.link)} >${mention.name}</a >`; } else if (mention.link?.startsWith('#')) { return html` <a href=${'#q=' + encodeURIComponent(mention.link)} >${mention.link}</a >`; } else if ( Object.keys(mention).length == 2 && mention.link && mention.name ) { return html` <a href=${`/${mention.link}/view`}>${mention.name}</a>`; } else { return html` <pre style="white-space: pre-wrap"> ${JSON.stringify(mention, null, 2)}</pre >`; } } render_mentions() { let mentions = this.message?.content?.mentions || []; mentions = mentions.filter( (x) => this.message?.content?.text?.indexOf(x.link) === -1 ); if (mentions.length) { let self = this; return html` <fieldset style="padding: 0.5em; border: 1px solid black"> <legend>Mentions</legend> ${mentions.map((x) => self.render_mention(x))} </fieldset> `; } } total_child_messages(message) { if (!message.child_messages) { return 0; } let total = message.child_messages.length; for (let m of message.child_messages) { total += this.total_child_messages(m); } return total; } set_expanded(expanded, tag) { this.dispatchEvent( new CustomEvent('tf-expand', { bubbles: true, composed: true, detail: {id: (this.message.id || '') + (tag || ''), expanded: expanded}, }) ); } toggle_expanded(tag) { this.set_expanded( !this.expanded[(this.message.id || '') + (tag || '')], tag ); } render_children() { let self = this; if (this.message.child_messages?.length) { if (!this.expanded[this.message.id]) { return html`<button class="w3-button w3-theme-d1" @click=${() => self.set_expanded(true)} > + ${this.total_child_messages(this.message) + ' More'} </button>`; } else { return html`<button class="w3-button w3-theme-d1" @click=${() => self.set_expanded(false)} > Collapse</button >${(this.message.child_messages || []).map( (x) => html`<tf-message .message=${x} whoami=${this.whoami} .users=${this.users} .drafts=${this.drafts} .expanded=${this.expanded} ></tf-message>` )}`; } } } render_channels() { let content = this.message?.content; if (this?.messsage?.decrypted?.type == 'post') { content = this.message.decrypted; } let channels = []; if (typeof content.channel === 'string') { channels.push(`#${content.channel}`); } if (Array.isArray(content.mentions)) { for (let mention of content.mentions) { if (typeof mention?.link === 'string' && mention.link.startsWith('#')) { channels.push(mention.link); } } } return channels.map((x) => html`<tf-tag tag=${x}></tf-tag>`); } render() { let content = this.message?.content; if (this.message?.decrypted?.type == 'post') { content = this.message.decrypted; } let class_background = this.message?.decrypted ? 'w3-pale-red' : 'w3-theme-d4'; let self = this; let raw_button; switch (this.format) { case 'raw': if (content?.type == 'post' || content?.type == 'blog') { raw_button = html`<button class="w3-button w3-theme-d1" @click=${() => (self.format = 'md')} > Markdown </button>`; } else { raw_button = html`<button class="w3-button w3-theme-d1" @click=${() => (self.format = 'message')} > Message </button>`; } break; case 'md': raw_button = html`<button class="w3-button w3-theme-d1" @click=${() => (self.format = 'message')} > Message </button>`; break; case 'decrypted': raw_button = html`<button class="w3-button w3-theme-d1" @click=${() => (self.format = 'raw')} > Raw </button>`; break; default: if (this.message.decrypted) { raw_button = html`<button class="w3-button w3-theme-d1" @click=${() => (self.format = 'decrypted')} > Decrypted </button>`; } else { raw_button = html`<button class="w3-button w3-theme-d1" @click=${() => (self.format = 'raw')} > Raw </button>`; } break; } function small_frame(inner) { let body; return html` <div class="w3-card-4 w3-theme-d4 w3-border-theme" style="margin-top: 8px; padding: 16px; display: inline-block; overflow-wrap: anywhere" > <tf-user id=${self.message.author} .users=${self.users}></tf-user> <span style="padding-right: 8px" ><a tfarget="_top" href=${'#' + self.message.id}>%</a> ${new Date( self.message.timestamp ).toLocaleString()}</span > ${raw_button} ${self.format == 'raw' ? self.render_raw() : inner} ${self.render_votes()} ${(self.message.child_messages || []).map( (x) => html` <tf-message .message=${x} whoami=${self.whoami} .users=${self.users} .drafts=${self.drafts} .expanded=${self.expanded} ></tf-message> ` )} </div> `; } if (this.message?.type === 'contact_group') { return html` <div class="w3-card-4 w3-theme-d4 w3-border-theme" style="margin-top: 8px; padding: 16px; overflow-wrap: anywhere" > ${this.message.messages.map( (x) => html`<tf-message .message=${x} whoami=${this.whoami} .users=${this.users} .drafts=${this.drafts} .expanded=${this.expanded} ></tf-message>` )} </div>`; } else if (this.message.placeholder) { return html` <div class="w3-card-4 w3-theme-d4 w3-border-theme" style="margin-top: 8px; padding: 16px; overflow-wrap: anywhere" > <a target="_top" href=${'#' + this.message.id}>${this.message.id}</a> (placeholder) <div>${this.render_votes()}</div> ${(this.message.child_messages || []).map( (x) => html` <tf-message .message=${x} whoami=${this.whoami} .users=${this.users} .drafts=${this.drafts} .expanded=${this.expanded} ></tf-message> ` )} </div>`; } else if (typeof (content?.type === 'string')) { if (content.type == 'about') { let name; let image; let description; if (content.name !== undefined) { name = html`<div><b>Name:</b> ${content.name}</div>`; } if (content.image !== undefined) { image = html` <div><img src=${'/' + (typeof content.image?.link == 'string' ? content.image.link : content.image) + '/view'} style="width: 256px; height: auto"></img></div> `; } if (content.description !== undefined) { description = html` <div style="flex: 1 0 50%; overflow-wrap: anywhere"> <div>${unsafeHTML(tfutils.markdown(content.description))}</div> </div> `; } let update = content.about == this.message.author ? html`<div style="font-weight: bold">Updated profile.</div>` : html`<div style="font-weight: bold"> Updated profile for <tf-user id=${content.about} .users=${this.users}></tf-user>. </div>`; return small_frame(html` ${update} ${name} ${image} ${description} `); } else if (content.type == 'contact') { return html` <div> <tf-user id=${this.message.author} .users=${this.users}></tf-user> is ${content.blocking === true ? 'blocking' : content.blocking === false ? 'no longer blocking' : content.following === true ? 'following' : content.following === false ? 'no longer following' : '?'} <tf-user id=${this.message.content.contact} .users=${this.users} ></tf-user> </div> `; } else if (content.type == 'post') { let reply = this.drafts[this.message?.id] !== undefined ? html` <tf-compose whoami=${this.whoami} .users=${this.users} root=${content.root || this.message.id} branch=${this.message.id} .drafts=${this.drafts} @tf-discard=${this.discard_reply} author=${this.message.author} ></tf-compose> ` : html` <button class="w3-button w3-theme-d1" @click=${this.show_reply}> Reply </button> `; let self = this; let body; switch (this.format) { case 'raw': body = this.render_raw(); break; case 'md': body = html`<code style="white-space: pre-wrap; overflow-wrap: anywhere" >${content.text}</code >`; break; case 'message': body = unsafeHTML(tfutils.markdown(content.text)); break; case 'decrypted': body = html`<pre style="white-space: pre-wrap; overflow-wrap: anywhere" > ${JSON.stringify(content, null, 2)}</pre >`; break; } let content_warning = html` <div class="w3-panel w3-round-xlarge w3-theme-l4" style="cursor: pointer" @click=${(x) => this.toggle_expanded(':cw')} > <p>${content.contentWarning}</p> </div> `; let content_html = html` ${this.render_channels()} <div @click=${this.body_click}>${body}</div> ${this.render_mentions()} `; let payload = content.contentWarning ? self.expanded[(this.message.id || '') + ':cw'] ? html` ${content_warning} ${content_html} ` : content_warning : content_html; let is_encrypted = this.message?.decrypted ? html`<span style="align-self: center">🔓</span>` : undefined; return html` <style> code { white-space: pre-wrap; overflow-wrap: break-word; } div { overflow-wrap: anywhere; } img { max-width: 100%; height: auto; display: block; } </style> <div class="w3-card-4 ${class_background} w3-border-theme" style="margin-top: 8px; padding: 16px" > <div style="display: flex; flex-direction: row"> <tf-user id=${this.message.author} .users=${this.users}></tf-user> ${is_encrypted} <span style="flex: 1"></span> <span style="padding-right: 8px" ><a target="_top" href=${'#' + self.message.id}>%</a> ${new Date(this.message.timestamp).toLocaleString()}</span > <span>${raw_button}</span> </div> ${payload} ${this.render_votes()} <p> ${reply} <button class="w3-button w3-theme-d1" @click=${this.react}> React </button> </p> ${this.render_children()} </div> `; } else if (content.type === 'issue') { let is_encrypted = this.message?.decrypted ? html`<span style="align-self: center">🔓</span>` : undefined; return html` <style> code { white-space: pre-wrap; overflow-wrap: break-word; } div { overflow-wrap: anywhere; } img { max-width: 100%; height: auto; display: block; } </style> <div class="w3-card-4 ${class_background} w3-border-theme" style="margin-top: 8px; padding: 16px" > <div style="display: flex; flex-direction: row"> <tf-user id=${this.message.author} .users=${this.users}></tf-user> ${is_encrypted} <span style="flex: 1"></span> <span style="padding-right: 8px" ><a target="_top" href=${'#' + self.message.id}>%</a> ${new Date(this.message.timestamp).toLocaleString()}</span > <span>${raw_button}</span> </div> ${content.text} ${this.render_votes()} <p> <button class="w3-button w3-theme-d1" @click=${this.react}> React </button> </p> ${this.render_children()} </div> `; } else if (content.type === 'blog') { let self = this; tfrpc.rpc.get_blob(content.blog).then(function (data) { self.blog_data = data; }); let payload = this.expanded[(this.message.id || '') + ':blog'] ? html`<div> ${this.blog_data ? unsafeHTML(tfutils.markdown(this.blog_data)) : 'Loading...'} </div>` : undefined; let body; switch (this.format) { case 'raw': body = this.render_raw(); break; case 'md': body = content.summary; break; case 'message': body = html` <div style="border: 1px solid #fff; border-radius: 1em; padding: 8px; margin: 4px; cursor: pointer" @click=${(x) => self.toggle_expanded(':blog')}> <h2>${content.title}</h2> <div style="display: flex; flex-direction: row"> <img src=/${content.thumbnail}/view></img> <span>${content.summary}</span> </div> </div> ${payload} `; break; } let reply = this.drafts[this.message?.id] !== undefined ? html` <tf-compose whoami=${this.whoami} .users=${this.users} root=${content.root || this.message.id} branch=${this.message.id} .drafts=${this.drafts} @tf-discard=${this.discard_reply} author=${this.message.author} ></tf-compose> ` : html` <button class="w3-button w3-theme-d1" @click=${this.show_reply}> Reply </button> `; return html` <style> code { white-space: pre-wrap; overflow-wrap: break-word; } div { overflow-wrap: anywhere; } img { max-width: 100%; height: auto; display: block; } </style> <div class="w3-card-4 w3-theme-d4 w3-border-theme" style="margin-top: 8px; padding: 16px" > <div style="display: flex; flex-direction: row"> <tf-user id=${this.message.author} .users=${this.users}></tf-user> <span style="flex: 1"></span> <span style="padding-right: 8px" ><a target="_top" href=${'#' + self.message.id}>%</a> ${new Date(this.message.timestamp).toLocaleString()}</span > <span>${raw_button}</span> </div> <div>${body}</div> ${this.render_mentions()} <div> ${reply} <button class="w3-button w3-theme-d1" @click=${this.react}> React </button> </div> ${this.render_votes()} ${this.render_children()} </div> `; } else if (content.type === 'pub') { return small_frame( html` <style> span { overflow-wrap: anywhere; } </style> <span> <div> 🍻 <tf-user .users=${this.users} id=${content.address.key} ></tf-user> </div> <pre>${content.address.host}:${content.address.port}</pre> </span>` ); } else if (content.type === 'channel') { return small_frame(html` <div> ${content.subscribed ? 'subscribed to' : 'unsubscribed from'} <a href=${'#q=' + encodeURIComponent('#' + content.channel)} >#${content.channel}</a > </div> `); } else if (typeof this.message.content == 'string') { if (this.message?.decrypted) { if (this.format == 'decrypted') { return small_frame( html`<span>🔓</span> <pre>${JSON.stringify(this.message.decrypted, null, 2)}</pre>` ); } else { return small_frame( html`<span>🔓</span> <div>${this.message.decrypted.type}</div>` ); } } else { return small_frame(html`<span>🔒</span>`); } } else { return small_frame(html`<div><b>type</b>: ${content.type}</div>`); } } else { return small_frame(this.render_raw()); } } } customElements.define('tf-message', TfMessageElement);