I think this added following and blocking.

git-svn-id: https://www.unprompted.com/svn/projects/tildefriends/trunk@3975 ed5197a5-7fde-0310-b194-c3ffbd925b24
This commit is contained in:
Cory McWilliams 2022-09-10 02:56:15 +00:00
parent ee1e1b11af
commit 52f5bb408f
13 changed files with 391 additions and 14 deletions

View File

@ -1 +1 @@
{"type":"tildefriends-app","files":{"app.js":"&rLwYqurncmnUyGeWY+FLEGS2EIJmqw2cutl1gyGiVSk=.sha256","index.md":"&082vPjenwI6mL2vXwQDVEFquyl2jW9t767sGuCFvVNA=.sha256","todo.md":"&u4lmFlYFB5zQNfVXVB8t8NMT2jFAeE8ivWfwIiiTKxQ=.sha256","structure.md":"&T+CBfT9XP6ooKFvD1ZCI9hsutqsNIamfBxtAho0HtlU=.sha256","guide.md":"&SgnGL0+rjetY2o9A2+lVRbNvHIkqKwMnZr9gXWneIlc=.sha256","id_refactor.md":"&8yoYd14gX2Z3ppktVrPYf4qR78fuwAlvrtsWkSCkWUA=.sha256","ssb.md":"&WMvYIpp4CMZACwXJlX8HMDplJ+XeJB04BYf8zasrL4g=.sha256"}} {"type":"tildefriends-app","files":{"app.js":"&rLwYqurncmnUyGeWY+FLEGS2EIJmqw2cutl1gyGiVSk=.sha256","index.md":"&082vPjenwI6mL2vXwQDVEFquyl2jW9t767sGuCFvVNA=.sha256","todo.md":"&+z52vxpbZs5+HoLnQoDNkYt4objcPwF7F1PIwvZ3E3k=.sha256","structure.md":"&T+CBfT9XP6ooKFvD1ZCI9hsutqsNIamfBxtAho0HtlU=.sha256","guide.md":"&SgnGL0+rjetY2o9A2+lVRbNvHIkqKwMnZr9gXWneIlc=.sha256","id_refactor.md":"&8yoYd14gX2Z3ppktVrPYf4qR78fuwAlvrtsWkSCkWUA=.sha256","ssb.md":"&WMvYIpp4CMZACwXJlX8HMDplJ+XeJB04BYf8zasrL4g=.sha256"}}

View File

@ -9,7 +9,6 @@
- update README - update README
- update docs - update docs
- audit + document API exposed to apps - audit + document API exposed to apps
- emoji reaction picker
- fix weird HTTP warnings - fix weird HTTP warnings
- ssb from child process? - ssb from child process?
- channels - channels
@ -26,6 +25,8 @@
- jwt for session tokens - jwt for session tokens
## Maybe Done ## Maybe Done
- linkify https://...
- emoji reaction picker
- expose loads of stats - expose loads of stats
- confirm posting all new messages - confirm posting all new messages
- multiple identities per user, in database - multiple identities per user, in database

View File

@ -1 +1 @@
{"type":"tildefriends-app","files":{"app.js":"&Y01AAZJWUjOXzzcIPHTzeEWvgrBsBgcL34QcNdOtLpA=.sha256","lit-all.min.js":"&N4A12AsifdQgwdpII0SFtG513BfoLpmPjdJ9VTDftpg=.sha256","index.html":"&NQfp81Ve+FpMPRzPS1UcoXEkn7BW+yz/XArGQbLSmPg=.sha256","script.js":"&vnCSRIvjb0kS+QOmkJP+ISB6wJdXDp/lOn6FJn2esKk=.sha256","lit-all.min.js.map":"&oFY9wO4MnujgfGNGv4VggHc5V5JwX4C8csqKZ6KJYbE=.sha256","tf-id-picker.js":"&ewIlLZNhaHm2dztxqj2Ft38WZkNPQxYfOGBrwTDUhds=.sha256","tf-app.js":"&HOqvQvHjzGv94YSqPQWVOr9fTNMVRZk+vO7Dd+/LcEA=.sha256","tf-message.js":"&E98rTMtN1Ok3gBVbe54uqv6P45wHoMicdA/+gHVP7BM=.sha256","tf-user.js":"&hsIveVMRVMRNJfrTN1hkVQgO4VdRurMATfV2EXnIk/0=.sha256","tf-utils.js":"&MPINm55jkpz2rrNbwsYl09PKGvbgL3nwgBy6CMQkSnw=.sha256","commonmark.min.js":"&bfBaMLU19d1p/vPBF9hlARqDX002KXG/UOfxOahZhe4=.sha256","tf-compose.js":"&oo0iWvT+c2rU91zWpBIfPePRzmU8qmSnVOm+QCQqG/I=.sha256","emojis.json":"&h3P4pez+AI4aYdsN0dJ3pbUEFR0276t9AM20caj/W/s=.sha256","emojis.js":"&htPMi2z6Bmgi3f9jCnECCDZRCHACnDRjOl1kgPm+W80=.sha256","tf-styles.js":"&BkvFkMpGyL0DYP6FISFKR4pe6ZBOp8t6tQEzWZ4IQYs=.sha256","tf-profile.js":"&OmDTn4Bhu6kV4PzJ0wfaExyuLOO/7bPmbRNHD5yp02w=.sha256"}} {"type":"tildefriends-app","files":{"app.js":"&b8IFBOMDtcvY5XNtUQIUeoE+++/TO8LDp86xNFIaux8=.sha256","lit-all.min.js":"&N4A12AsifdQgwdpII0SFtG513BfoLpmPjdJ9VTDftpg=.sha256","index.html":"&WH8A5tF25xlfPDGei2TCQc2/HJFJf5DuRN1GRSYQhhk=.sha256","script.js":"&diQfpbxjgd/jSPnIoAoWT75B8Pll1I5JYXhu+/phj9k=.sha256","lit-all.min.js.map":"&oFY9wO4MnujgfGNGv4VggHc5V5JwX4C8csqKZ6KJYbE=.sha256","tf-id-picker.js":"&ewIlLZNhaHm2dztxqj2Ft38WZkNPQxYfOGBrwTDUhds=.sha256","tf-app.js":"&4Z6k1bR9LUPUZGyJTEKOqPkNKqHtnvG8ScgkFoSTf1Y=.sha256","tf-message.js":"&KE1fWTqPMZR0yIRXPBGy8u1chR6LTguSK6swo+lFgE4=.sha256","tf-user.js":"&L6+7BnBq+UOoTMO6o8+u5JFTl0UBtCPDw8bb8ppDrkA=.sha256","tf-utils.js":"&N2yKZwFnb2GbPeipgQtu6xFvezENNOgud9G7EhCQ/K0=.sha256","commonmark.min.js":"&bfBaMLU19d1p/vPBF9hlARqDX002KXG/UOfxOahZhe4=.sha256","tf-compose.js":"&oo0iWvT+c2rU91zWpBIfPePRzmU8qmSnVOm+QCQqG/I=.sha256","emojis.json":"&h3P4pez+AI4aYdsN0dJ3pbUEFR0276t9AM20caj/W/s=.sha256","emojis.js":"&htPMi2z6Bmgi3f9jCnECCDZRCHACnDRjOl1kgPm+W80=.sha256","tf-styles.js":"&BkvFkMpGyL0DYP6FISFKR4pe6ZBOp8t6tQEzWZ4IQYs=.sha256","tf-profile.js":"&vRKjsnYvOiHCQahzEfznCvP5YDwUPtltlpWf+pxwZ1Y=.sha256","commonmark-linkify.js":"&X+hNNkmSRvKY86khyAun+cXksquXbMakZdINbGbx30g=.sha256","tf-connections.js":"&YUD4n/r95AwD2fA63HHE2eQt4E/27gF+4/MYrdvoasw=.sha256"}}

View File

@ -18,6 +18,15 @@ tfrpc.register(async function databaseSet(key, value) {
tfrpc.register(async function getIdentities() { tfrpc.register(async function getIdentities() {
return ssb.getIdentities(); return ssb.getIdentities();
}); });
tfrpc.register(async function getAllIdentities() {
return ssb.getAllIdentities();
});
tfrpc.register(async function getBroadcasts() {
return ssb.getBroadcasts();
});
tfrpc.register(async function getConnections() {
return ssb.connections();
});
tfrpc.register(async function query(sql, args) { tfrpc.register(async function query(sql, args) {
let result = []; let result = [];
await ssb.sqlStream(sql, args, function callback(row) { await ssb.sqlStream(sql, args, function callback(row) {
@ -46,6 +55,13 @@ tfrpc.register(async function store_blob(blob) {
} }
return await ssb.blobStore(blob); return await ssb.blobStore(blob);
}); });
ssb.addEventListener('broadcasts', async function() {
await tfrpc.rpc.set('broadcasts', await ssb.getBroadcasts());
});
core.register('onConnectionsChanged', async function() {
await tfrpc.rpc.set('connections', await ssb.connections());
});
async function main() { async function main() {
if (typeof(database) !== 'undefined') { if (typeof(database) !== 'undefined') {

View File

@ -0,0 +1,91 @@
function textNode(text) {
const node = new commonmark.Node("text", undefined);
node.literal = text;
return node;
}
function linkNode(text, url) {
const urlNode = new commonmark.Node("link", undefined);
urlNode.destination = url;
urlNode.appendChild(textNode(text));
return urlNode;
}
function splitMatches(text, regexp) {
// Regexp must be sticky.
regexp = new RegExp(regexp, "gm");
let i = 0;
const result = [];
let match = regexp.exec(text);
while (match) {
const matchText = match[0];
if (match.index > i) {
result.push([text.substring(i, match.index), false]);
}
result.push([matchText, true]);
i = match.index + matchText.length;
match = regexp.exec(text);
}
if (i < text.length) {
result.push([text.substring(i, text.length), false]);
}
return result;
}
const urlRegexp = new RegExp("https?://[^ ]+[^ .,]");
function splitURLs(textNodes) {
const text = textNodes.map(n => n.literal).join("");
const parts = splitMatches(text, urlRegexp);
return parts.map(part => {
if (part[1]) {
return linkNode(part[0], part[0]);
} else {
return textNode(part[0]);
}
});
}
export function transform(parsed) {
const walker = parsed.walker();
let event;
let nodes = [];
while ((event = walker.next())) {
const node = event.node;
if (event.entering && node.type === "text") {
nodes.push(node);
} else {
if (nodes.length > 0) {
splitURLs(nodes)
.reverse()
.forEach(newNode => {
nodes[0].insertAfter(newNode);
});
nodes.forEach(n => n.unlink());
nodes = [];
}
}
}
if (nodes.length > 0) {
splitURLs(nodes)
.reverse()
.forEach(newNode => {
nodes[0].insertAfter(newNode);
});
nodes.forEach(n => n.unlink());
}
return parsed;
}

View File

@ -2,12 +2,14 @@
<html style="color: #fff"> <html style="color: #fff">
<head> <head>
<title>Tilde Friends</title> <title>Tilde Friends</title>
<base target="_top">
</head> </head>
<body> <body>
<h1>Tilde Friends</h1> <h1>Tilde Friends</h1>
<tf-app/> <tf-app/>
<script>window.litDisableBundleWarning = true;</script> <script>window.litDisableBundleWarning = true;</script>
<script src="commonmark.min.js"></script> <script src="commonmark.min.js"></script>
<script src="commonmark-linkify.js" type="module"></script>
<script src="script.js" type="module"></script> <script src="script.js" type="module"></script>
</body> </body>
</html> </html>

View File

@ -7,3 +7,4 @@ import * as tf_message from './tf-message.js';
import * as tf_user from './tf-user.js'; import * as tf_user from './tf-user.js';
import * as tf_compose from './tf-compose.js'; import * as tf_compose from './tf-compose.js';
import * as tf_profile from './tf-profile.js'; import * as tf_profile from './tf-profile.js';
import * as tf_connections from './tf-connections.js';

View File

@ -1,5 +1,6 @@
import {LitElement, html, css} from './lit-all.min.js'; import {LitElement, html, css} from './lit-all.min.js';
import * as tfrpc from '/static/tfrpc.js'; import * as tfrpc from '/static/tfrpc.js';
import {styles} from './tf-styles.js';
class TfElement extends LitElement { class TfElement extends LitElement {
static get properties() { static get properties() {
@ -12,9 +13,14 @@ class TfElement extends LitElement {
status: {type: Array}, status: {type: Array},
hash: {type: String}, hash: {type: String},
unread: {type: Array}, unread: {type: Array},
tab: {type: String},
broadcasts: {type: Array},
connections: {type: Array},
}; };
} }
static styles = styles;
constructor() { constructor() {
super(); super();
let self = this; let self = this;
@ -27,7 +33,12 @@ class TfElement extends LitElement {
this.hash = '#'; this.hash = '#';
this.loading = false; this.loading = false;
this.unread = []; this.unread = [];
this.tab = 'news';
this.broadcasts = [];
this.connections = [];
tfrpc.rpc.getIdentities().then(ids => { self.ids = ids || [] }); tfrpc.rpc.getIdentities().then(ids => { self.ids = ids || [] });
tfrpc.rpc.getBroadcasts().then(b => { self.broadcasts = b || [] });
tfrpc.rpc.getConnections().then(c => { self.connections = c || [] });
tfrpc.rpc.getHash().then(hash => self.hash = hash || '#'); tfrpc.rpc.getHash().then(hash => self.hash = hash || '#');
tfrpc.register(function hashChanged(hash) { tfrpc.register(function hashChanged(hash) {
self.hash = hash; self.hash = hash;
@ -36,6 +47,13 @@ class TfElement extends LitElement {
tfrpc.register(async function notifyNewMessage(id) { tfrpc.register(async function notifyNewMessage(id) {
await self.fetch_new_message(id); await self.fetch_new_message(id);
}); });
tfrpc.register(function set(name, value) {
if (name === 'broadcasts') {
self.broadcasts = value;
} else if (name === 'connections') {
self.connections = value;
}
});
} }
async contacts_internal(id, last_row_id, following, max_row_id) { async contacts_internal(id, last_row_id, following, max_row_id) {
@ -288,6 +306,9 @@ class TfElement extends LitElement {
function link_message(message) { function link_message(message) {
if (message.content.type === 'vote') { if (message.content.type === 'vote') {
let parent = self.ensure_message(message.content.vote.link); let parent = self.ensure_message(message.content.vote.link);
if (!parent.votes) {
parent.votes = [];
}
parent.votes.push(message); parent.votes.push(message);
message.parent_message = message.content.vote.link; message.parent_message = message.content.vote.link;
} else if (message.content.type == 'post') { } else if (message.content.type == 'post') {
@ -373,8 +394,11 @@ class TfElement extends LitElement {
if (this.loading || (!this.whoami && this.ids.length)) { if (this.loading || (!this.whoami && this.ids.length)) {
return; return;
} }
let load_button = this.renderRoot.getElementById('load_button');
this.loading = true; this.loading = true;
this.renderRoot.getElementById('load_button').disabled = true; if (load_button) {
load_button.disabled = true;
}
this.status = []; this.status = [];
this.messages = []; this.messages = [];
this.messages_by_id = {}; this.messages_by_id = {};
@ -392,7 +416,9 @@ class TfElement extends LitElement {
await this.finalize_messages(); await this.finalize_messages();
this.record_status('done'); this.record_status('done');
this.status = []; this.status = [];
this.renderRoot.getElementById('load_button').disabled = false; if (load_button) {
load_button.disabled = false;
}
this.loading = false; this.loading = false;
} }
@ -402,9 +428,16 @@ class TfElement extends LitElement {
} }
render() { render() {
let self = this;
let tabs = html`
<div>
<input type="button" value="News" ?disabled=${self.tab == 'news'} @click=${event => self.tab = 'news'}></input>
<input type="button" value="Connections" ?disabled=${self.tab == 'connections'} @click=${event => self.tab = 'connections'}></input>
</div>
`;
let profile = this.hash.startsWith('#@') ? let profile = this.hash.startsWith('#@') ?
html`<tf-profile id=${this.hash.substring(1)} .users=${this.users}></tf-profile>` : undefined; html`<tf-profile id=${this.hash.substring(1)} whoami=${this.whoami} .users=${this.users}></tf-profile>` : undefined;
return html` let news = html`
<tf-id-picker id="picker" .ids=${this.ids} @change=${this._handle_whoami_changed}></tf-id-picker> <tf-id-picker id="picker" .ids=${this.ids} @change=${this._handle_whoami_changed}></tf-id-picker>
<button id="load_button" @click=${this.load}>Load</button> <button id="load_button" @click=${this.load}>Load</button>
<a target="_top" href="#" ?hidden=${this.hash.length <= 1}>🏠Home</a> <a target="_top" href="#" ?hidden=${this.hash.length <= 1}>🏠Home</a>
@ -415,6 +448,14 @@ class TfElement extends LitElement {
${profile} ${profile}
${this.messages?.map(x => html`<tf-message .message=${x} whoami=${this.whoami} .users=${this.users}></tf-message>`)} ${this.messages?.map(x => html`<tf-message .message=${x} whoami=${this.whoami} .users=${this.users}></tf-message>`)}
`; `;
if (this.tab === 'news') {
return html`${tabs}${news}`;
} else if (this.tab === 'connections') {
return html`
${tabs}
<tf-connections .users=${this.users} .connections=${this.connections} .broadcasts=${this.broadcasts}></tf-connections>
`;
}
} }
} }

View File

@ -0,0 +1,57 @@
import {LitElement, html} from './lit-all.min.js';
import * as tfrpc from '/static/tfrpc.js';
class TfConnectionsElement extends LitElement {
static get properties() {
return {
broadcasts: {type: Array},
identities: {type: Array},
connections: {type: Array},
users: {type: Object},
}
}
constructor() {
super();
let self = this;
this.broadcasts = [];
this.identities = [];
this.connections = [];
this.users = {};
tfrpc.rpc.getAllIdentities().then(function(identities) {
self.identities = identities || [];
});
}
_emit_change() {
let changed_event = new Event('change', {
srcElement: this,
});
this.dispatchEvent(changed_event);
}
changed(event) {
this.selected = event.srcElement.value;
tfrpc.rpc.localStorageSet('whoami', this.selected);
this._emit_change();
}
render() {
return html`
<h2>Broadcasts</h2>
<ul>
${this.broadcasts.map(x => html`<li><tf-user id=${x.pubkey} .users=${this.users}></tf-user></li>`)}
</ul>
<h2>Connections</h2>
<ul>
${this.connections.map(x => html`<li><tf-user id=${x} .users=${this.users}></tf-user></li>`)}
</ul>
<h2>Local Accounts</h2>
<ul>
${this.identities.map(x => html`<li><tf-user id=${x} .users=${this.users}></tf-user></li>`)}
</ul>
`;
}
}
customElements.define('tf-connections', TfConnectionsElement);

View File

@ -35,6 +35,10 @@ class TfMessageElement extends LitElement {
function normalize_expression(expression) { function normalize_expression(expression) {
if (expression === 'Like' || !expression) { if (expression === 'Like' || !expression) {
return '👍'; return '👍';
} else if (expression === 'Unlike') {
return '👎';
} else if (expression === 'heart') {
return '❤️';
} else { } else {
return expression; return expression;
} }
@ -132,6 +136,10 @@ class TfMessageElement extends LitElement {
unsafeHTML(tfutils.markdown(content.text)); unsafeHTML(tfutils.markdown(content.text));
return html` return html`
<style> <style>
code {
white-space: pre-wrap;
overflow-wrap: break-word;
}
img { img {
max-width: 100%; max-width: 100%;
height: auto; height: auto;

View File

@ -6,8 +6,11 @@ import {styles} from './tf-styles.js';
class TfProfileElement extends LitElement { class TfProfileElement extends LitElement {
static get properties() { static get properties() {
return { return {
editing: {type: Object},
whoami: {type: String},
id: {type: String}, id: {type: String},
users: {type: Object}, users: {type: Object},
size: {type: Number},
} }
} }
@ -16,20 +19,160 @@ class TfProfileElement extends LitElement {
constructor() { constructor() {
super(); super();
let self = this; let self = this;
this.editing = null;
this.whoami = null;
this.id = null; this.id = null;
this.users = {}; this.users = {};
this.size = 0;
} }
render_raw() { modify(change) {
return html`<div style="white-space: pre-wrap">${JSON.stringify(this.message, null, 2)}</div>` tfrpc.rpc.appendMessage(this.whoami,
Object.assign({
type: 'contact',
contact: this.id,
}, change)).catch(function(error) {
alert(error?.message);
})
}
follow() {
this.modify({following: true});
}
unfollow() {
this.modify({following: false});
}
block() {
this.modify({blocking: true});
}
unblock() {
this.modify({blocking: false});
}
edit() {
let original = this.users[this.id];
this.editing = {
name: original.name,
description: original.description,
image: original.image,
};
console.log(this.editing);
}
save_edits() {
let self = this;
let message = {
type: 'about',
about: this.whoami,
};
for (let key of Object.keys(this.editing)) {
if (this.editing[key] !== this.users[this.id][key]) {
message[key] = this.editing[key];
}
}
tfrpc.rpc.appendMessage(this.whoami, message).then(function() {
self.editing = null;
}).catch(function(error) {
alert(error?.message);
});
}
discard_edits() {
this.editing = null;
}
attach_image() {
let self = this;
let input = document.createElement('input');
input.type = 'file';
input.onchange = function(event) {
let file = event.target.files[0];
file.arrayBuffer().then(function(buffer) {
let bin = Array.from(new Uint8Array(buffer));
return tfrpc.rpc.store_blob(bin);
}).then(function(id) {
self.editing = Object.assign({}, self.editing, {image: id});
console.log(self.editing);
}).catch(function(e) {
alert(e.message);
});
};
input.click();
} }
render() { render() {
let self = this;
let profile = this.users[this.id] || {}; let profile = this.users[this.id] || {};
tfrpc.rpc.query(
`SELECT SUM(LENGTH(content)) AS size FROM messages WHERE author = ?`,
[this.id]).then(function(result) {
self.size = result[0].size;
});
let edit;
let follow;
let block;
if (this.id === this.whoami) {
if (this.editing) {
edit = html`
<input type="button" value="Save Profile" @click=${this.save_edits}></input>
<input type="button" value="Discard" @click=${this.discard_edits}></input>
`;
} else {
edit = html`<input type="button" value="Edit Profile" @click=${this.edit}></input>`;
}
}
if (this.id !== this.whoami &&
this.users[this.whoami]?.following) {
follow =
this.users[this.whoami].following[this.id] ?
html`<input type="button" value="Unfollow" @click=${this.unfollow}></input>` :
html`<input type="button" value="Follow" @click=${this.follow}></input>`;
}
if (this.id !== this.whoami &&
this.users[this.whoami]?.blocking) {
block =
this.users[this.whoami].blocking[this.id] ?
html`<input type="button" value="Unblock" @click=${this.unblock}></input>` :
html`<input type="button" value="Block" @click=${this.block}></input>`;
}
let edit_profile = this.editing ? html`
<div style="flex: 1 0 50%">
<div>
<label for="name">Name:</label>
<input type="text" id="name" value=${this.editing.name} @input=${event => this.editing = Object.assign({}, this.editing, {name: event.srcElement.value})}></input>
</div>
<div>
<div><label for="description">Description:</label></div>
<textarea id="description" @input=${event => this.editing = Object.assign({}, this.editing, {description: event.srcElement.value})}>${this.editing.description}</textarea>
</div>
<input type="button" value="Attach Image" @click=${this.attach_image}></input>
</div>` : null;
let image = typeof(profile.image) == 'string' ? profile.image : profile.image?.link;
image = this.editing?.image ?? image;
let description = this.editing?.description ?? profile.description;
return html`<div style="border: 2px solid black; background-color: rgba(255, 255, 255, 0.2); padding: 16px"> return html`<div style="border: 2px solid black; background-color: rgba(255, 255, 255, 0.2); padding: 16px">
<tf-user id=${this.id} .users=${this.users}></tf-user> <tf-user id=${this.id} .users=${this.users}></tf-user> (${tfutils.human_readable_size(this.size)})
<div><img src=${'/' + profile.image + '/view'} style="width: 256px; height: auto"></img></div> <div style="display: flex; flex-direction: row">
<div>${unsafeHTML(tfutils.markdown(profile.description))}</div> ${edit_profile}
<div style="flex: 1 0 50%">
<div><img src=${'/' + image + '/view'} style="width: 256px; height: auto"></img></div>
<div>${unsafeHTML(tfutils.markdown(description))}</div>
</div>
</div>
<div>
Following ${Object.keys(profile.following || {}).length} identities.
Followed by ${Object.values(self.users).filter(x => (x.following || {})[self.id]).length} identities.
Blocking ${Object.keys(profile.blocking || {}).length} identities.
Blocked by ${Object.values(self.users).filter(x => (x.blocking || {})[self.id]).length} identities.
</div>
<div>
${edit}
${follow}
${block}
</div>
</div>`; </div>`;
} }
} }

View File

@ -24,8 +24,8 @@ class TfUserElement extends LitElement {
image = typeof(image) == 'string' ? image : image?.link; image = typeof(image) == 'string' ? image : image?.link;
return html` return html`
<div style="display: inline-block; font-weight: bold"> <div style="display: inline-block; font-weight: bold">
<img style="width: 2em; height: 2em; vertical-align: middle; border-radius: 50%" src="${'/' + image + '/view'}"> <img style="width: 2em; height: 2em; vertical-align: middle; border-radius: 50%" ?hidden=${image === undefined} src="${image ? '/' + image + '/view' : undefined}">
<a target="_top" href=${'#' + this.id}>${this.users[this.id].name}</a> <a target="_top" href=${'#' + this.id}>${this.users[this.id].name ?? this.id}</a>
</div>`; </div>`;
} else { } else {
return html` return html`

View File

@ -1,7 +1,10 @@
import * as linkify from './commonmark-linkify.js';
export function markdown(md) { export function markdown(md) {
var reader = new commonmark.Parser({safe: true}); var reader = new commonmark.Parser({safe: true});
var writer = new commonmark.HtmlRenderer(); var writer = new commonmark.HtmlRenderer();
var parsed = reader.parse(md || ''); var parsed = reader.parse(md || '');
parsed = linkify.transform(parsed);
var walker = parsed.walker(); var walker = parsed.walker();
var event, node; var event, node;
while ((event = walker.next())) { while ((event = walker.next())) {
@ -27,3 +30,17 @@ export function markdown(md) {
} }
return writer.render(parsed); return writer.render(parsed);
} }
export function human_readable_size(bytes) {
let v = bytes;
let u = 'B';
for (let unit of ['kB', 'MB', 'GB']) {
if (v > 1024) {
v /= 1024;
u = unit;
} else {
break;
}
}
return `${Math.round(v * 10) / 10} ${u}`;
}