
This feature involved a lot of refactoring in order to get working correctly. I wanted to continue using the timeline view for chats thus I used alternative styling & structure for DM event kinds. This worked create since the elements map does not care. There is some queing that has to be done to decrypt message content thus I allow viewing messages even if they haven't been decrypted yet. I think this is good for transparency since you understand what is and is not decrypted. I do think that the UX could improve, because even tho it is fast, it's flashes on new messages. I did not implement saving of latest messages. I will do this later, but this feature is big enough to merge as is: an alpha state that works. I further abstracted profile & name updating to work in a more global manner. Additionally I rewrote code that had attribute scripts to use addEventListener instead. This is needed to happen anyways for security and made the codebase easier to manage.
302 lines
9.1 KiB
JavaScript
302 lines
9.1 KiB
JavaScript
// This file contains all methods related to rendering UI elements. Rendering
|
|
// is done by simple string manipulations & templates. If you need to write
|
|
// loops simply write it in code and return strings.
|
|
|
|
function render_replying_to(model, ev) {
|
|
if (!(ev.refs && ev.refs.reply))
|
|
return "";
|
|
let pubkeys = ev.refs.pubkeys || []
|
|
if (pubkeys.length === 0 && ev.refs.reply) {
|
|
const replying_to = model.all_events[ev.refs.reply]
|
|
// If there is no profile being replied to, it is simply a reply to an
|
|
// event itself, thus render it differently.
|
|
if (!replying_to) {
|
|
return html`<span class="replying-to small-txt">
|
|
replying in thread
|
|
<span class="thread-id clickable"
|
|
onclick="open_thread('${ev.refs.reply}')">
|
|
${fmt_pubkey(ev.refs.reply)}</span></span>`;
|
|
} else {
|
|
pubkeys = [replying_to.pubkey];
|
|
}
|
|
}
|
|
const names = pubkeys.map((pk) => {
|
|
return render_name(pk, model_get_profile(model, pk).data);
|
|
}).join(", ")
|
|
return `
|
|
<span class="replying-to small-txt">
|
|
replying to ${names}
|
|
</span>
|
|
`
|
|
}
|
|
|
|
function render_share(model, ev, opts) {
|
|
const shared_ev = model.all_events[ev.refs && ev.refs.root]
|
|
// If the shared event hasn't been resolved or leads to a circular event
|
|
// kind we will skip out on it.
|
|
if (!shared_ev || shared_ev.kind == KIND_SHARE)
|
|
return "";
|
|
opts.shared = {
|
|
pubkey: ev.pubkey,
|
|
profile: model_get_profile(model, ev.pubkey),
|
|
share_time: ev.created_at,
|
|
share_evid: ev.id,
|
|
}
|
|
return render_event(model, shared_ev, opts)
|
|
}
|
|
|
|
function render_shared_by(ev, opts) {
|
|
if (!opts.shared)
|
|
return "";
|
|
const { profile, pubkey } = opts.shared
|
|
return `<div class="shared-by">Shared by ${render_name(pubkey, profile)}
|
|
</div>`
|
|
}
|
|
|
|
function render_event(model, ev, opts={}) {
|
|
switch(ev.kind) {
|
|
case KIND_SHARE:
|
|
return render_share(model, ev, opts);
|
|
case KIND_DM:
|
|
return render_dm(model, ev, opts);
|
|
}
|
|
|
|
const profile = model_get_profile(model, ev.pubkey);
|
|
const delta = fmt_since_str(new Date().getTime(), ev.created_at*1000)
|
|
let classes = "event"
|
|
if (!opts.is_composing)
|
|
classes += " bottom-border";
|
|
return html`<div id="ev${ev.id}" class="${classes}">
|
|
<div class="userpic">
|
|
$${render_profile_img(profile)}</div>
|
|
<div class="event-content">
|
|
<div class="info">
|
|
$${render_name(ev.pubkey, profile.data)}
|
|
<span class="timestamp" data-timestamp="${ev.created_at}">${delta}</span>
|
|
</div>
|
|
<div class="comment">
|
|
$${render_event_body(model, ev, opts)}
|
|
</div>
|
|
</div>
|
|
</div>`
|
|
}
|
|
|
|
function render_dm(model, ev, opts) {
|
|
let classes = "event"
|
|
if (ev.kind == KIND_DM) {
|
|
classes += " dm";
|
|
if (ev.pubkey == model.pubkey)
|
|
classes += " mine";
|
|
}
|
|
const profile = model_get_profile(model, ev.pubkey);
|
|
const delta = fmt_since_str(new Date().getTime(), ev.created_at*1000)
|
|
let show_media = event_shows_media(model, ev, model.embeds);
|
|
return html`<div id="ev${ev.id}" class="${classes}">
|
|
<div class="wrap">
|
|
<div class="body">
|
|
<p>$${format_content(ev, show_media)}</p>
|
|
</div>
|
|
<div class="timestamp" data-timestamp="${ev.created_at}">${delta}</div>
|
|
</div>
|
|
</div>`
|
|
}
|
|
|
|
function event_shows_media(model, ev, mode) {
|
|
if (mode == "friends")
|
|
return model.contacts.friends.has(ev.pubkey);
|
|
return true;
|
|
}
|
|
|
|
function rerender_dm(model, ev, el) {
|
|
let show_media = event_shows_media(model, ev, model.embeds);
|
|
find_node(".body > p", el).innerHTML = format_content(ev, show_media);
|
|
}
|
|
|
|
function render_event_nointeract(model, ev, opts={}) {
|
|
const profile = model_get_profile(model, ev.pubkey);
|
|
const delta = fmt_since_str(new Date().getTime(), ev.created_at*1000)
|
|
return html`<div class="event border-bottom">
|
|
<div class="userpic">
|
|
$${render_profile_img(profile)}
|
|
</div>
|
|
<div class="event-content">
|
|
<div class="info">
|
|
$${render_name(ev.pubkey, profile.data)}
|
|
<span class="timestamp" data-timestamp="${ev.created_at}">${delta}</span>
|
|
</div>
|
|
<div class="comment">
|
|
$${render_event_body(model, ev, opts)}
|
|
</div>
|
|
</div>
|
|
</div>`
|
|
}
|
|
|
|
function render_event_body(model, ev, opts) {
|
|
const { shared } = opts;
|
|
const can_delete = model.pubkey === ev.pubkey ||
|
|
(opts.shared && model.pubkey == opts.shared.pubkey);
|
|
// Only show media for content that is by friends.
|
|
let show_media = true;
|
|
if (opts.is_composing) {
|
|
show_media = false;
|
|
} else if (model.embeds == "friends") {
|
|
show_media = model.contacts.friends.has(ev.pubkey);
|
|
}
|
|
let str = "<div>";
|
|
str += shared ? render_shared_by(ev, opts) : render_replying_to(model, ev);
|
|
str += `</div><p>
|
|
${format_content(ev, show_media)}
|
|
</p>`;
|
|
str += render_reactions(model, ev);
|
|
str += opts.nobar || ev.kind == KIND_DM ? "" :
|
|
render_action_bar(model, ev, {can_delete, shared});
|
|
return str;
|
|
}
|
|
|
|
function render_react_onclick(our_pubkey, reacting_to, emoji, reactions) {
|
|
const reaction = reactions[our_pubkey]
|
|
if (!reaction) {
|
|
return html`onclick="send_reply('${emoji}', '${reacting_to}')"`
|
|
} else {
|
|
return html`onclick="delete_post('${reaction.id}')"`
|
|
}
|
|
}
|
|
|
|
function render_reaction_group(model, emoji, reactions, reacting_to) {
|
|
let count = 0;
|
|
let str = "";
|
|
for (const k in reactions) {
|
|
count++;
|
|
if (count > 5)
|
|
continue;
|
|
const pubkey = reactions[k].pubkey;
|
|
str += render_pfp(pubkey, model_get_profile(model, pubkey).data,
|
|
{noclick:true});
|
|
}
|
|
if (count > 5)
|
|
str = `${count}`;
|
|
let onclick = render_react_onclick(model.pubkey,
|
|
reacting_to.id, emoji, reactions);
|
|
return html`
|
|
<span $${onclick} class="reaction-group clickable">
|
|
<span class="reaction-emoji">
|
|
${emoji}
|
|
</span>
|
|
$${str}
|
|
</span>`;
|
|
}
|
|
|
|
function render_action_bar(model, ev, opts={}) {
|
|
const { pubkey } = model;
|
|
let { can_delete, shared } = opts;
|
|
// TODO rewrite all of the toggle heart code. It's mine & I hate it.
|
|
const thread_root = (ev.refs && ev.refs.root) || ev.id;
|
|
const reaction = model_get_reacts_to(model, pubkey, ev.id, R_HEART);
|
|
const liked = !!reaction;
|
|
const reaction_id = reaction ? reaction.id : "";
|
|
let str = html`<div class="action-bar">`;
|
|
if (!shared && event_can_reply(ev)) {
|
|
str += html`<button class="icon" title="Reply" onclick="reply_author('${ev.id}')">
|
|
<img class="icon svg small" src="icon/event-reply.svg"/>
|
|
</button>
|
|
<button class="icon" title="Reply All" onclick="reply_all('${ev.id}')">
|
|
<img class="icon svg small" src="icon/event-reply-all.svg"/>
|
|
</button>
|
|
<button class="icon react heart ${ab(liked, 'liked', '')}"
|
|
onclick="click_toggle_like(this)"
|
|
data-reaction-id="${reaction_id}"
|
|
data-reacting-to="${ev.id}"
|
|
title="$${ab(liked, 'Unlike', 'Like')}">
|
|
<img class="icon svg small ${ab(liked, 'dark-noinvert', '')}"
|
|
src="$${ab(liked, IMG_EVENT_LIKED, IMG_EVENT_LIKE)}"/>
|
|
</button>`;
|
|
}
|
|
if (!shared) {
|
|
str += html`<button class="icon" title="Share" data-evid="${ev.id}" onclick="click_share(this)">
|
|
<img class="icon svg small" src="icon/event-share.svg"/>
|
|
</button>`;
|
|
}
|
|
str += `
|
|
<button class="icon" title="View Thread" role="view-thread"
|
|
onclick="open_thread('${thread_root}')">
|
|
<img class="icon svg small" src="icon/open-thread.svg"/>
|
|
</button>
|
|
<button class="icon" title="View Replies" role="view-replies"
|
|
onclick="open_thread('${ev.id}')">
|
|
<img class="icon svg small" src="icon/open-thread-here.svg"/>
|
|
</button>
|
|
<button class="icon" title="View Event JSON" role="view-event-json"
|
|
onclick="on_click_show_event_details('${ev.id}')">
|
|
<img class="icon svg small" src="icon/event-details.svg"/>
|
|
</button>`;
|
|
if (can_delete) {
|
|
const delete_id = shared ? shared.share_evid : ev.id;
|
|
str += html`
|
|
<button class="icon" title="Delete" onclick="delete_post_confirm('${delete_id}')">
|
|
<img class="icon svg small" src="icon/event-delete.svg"/>
|
|
</button>`
|
|
}
|
|
return str + "</div>";
|
|
}
|
|
|
|
function render_reactions_inner(model, ev) {
|
|
const groups = get_reactions(model, ev.id)
|
|
let str = ""
|
|
for (const emoji of Object.keys(groups)) {
|
|
str += render_reaction_group(model, emoji, groups[emoji], ev)
|
|
}
|
|
return str;
|
|
}
|
|
|
|
function render_reactions(model, ev) {
|
|
return html`<div class="reactions">$${render_reactions_inner(model, ev)}</div>`
|
|
}
|
|
|
|
// Utility Methods
|
|
|
|
function render_pubkey(pk) {
|
|
return fmt_pubkey(pk);
|
|
}
|
|
|
|
function render_username(pk, profile)
|
|
{
|
|
return (profile && profile.name) || render_pubkey(pk)
|
|
}
|
|
|
|
function render_mentioned_name(pk, profile) {
|
|
return render_name(pk, profile, "");
|
|
}
|
|
|
|
function render_name(pk, profile, prefix="") {
|
|
// Beware of whitespace.
|
|
return html`<span>${prefix}<span class="username clickable" data-pubkey="${pk}"
|
|
onclick="open_profile('${pk}')"
|
|
> ${fmt_profile_name(profile, fmt_pubkey(pk))}</span></span>`
|
|
}
|
|
|
|
function render_profile_img(profile, noclick=false) {
|
|
const name = fmt_name(profile);
|
|
let str = html`class="pfp clickable" onclick="open_profile('${profile.pubkey}')"`;
|
|
if (noclick)
|
|
str = "class='pfp'";
|
|
return html`<img
|
|
$${str}
|
|
data-pubkey="${profile.pubkey}"
|
|
title="${name}"
|
|
onerror="this.onerror=null;this.src='${IMG_NO_USER}';"
|
|
src="${get_profile_pic(profile)}"/>`
|
|
}
|
|
|
|
function render_pfp(pk, profile, opts={}) {
|
|
const name = fmt_profile_name(profile, fmt_pubkey(pk));
|
|
let str = html`class="pfp clickable" onclick="open_profile('${pk}')"`;
|
|
if (opts.noclick)
|
|
str = "class='pfp'";
|
|
return html`<img
|
|
$${str}
|
|
data-pubkey="${pk}"
|
|
title="${name}"
|
|
onerror="this.onerror=null;this.src='${IMG_NO_USER}';"
|
|
src="${get_picture(pk, profile)}"/>`
|
|
}
|
|
|