Add ssblit to version control. It's coming along too well to risk losing it.

git-svn-id: https://www.unprompted.com/svn/projects/tildefriends/trunk@3972 ed5197a5-7fde-0310-b194-c3ffbd925b24
This commit is contained in:
Cory McWilliams 2022-09-06 23:26:43 +00:00
parent 97b7643049
commit 3b676d967e
20 changed files with 16259 additions and 2 deletions

View File

@ -1 +1 @@
{"type":"tildefriends-app","files":{"app.js":"&+LbIl429+UZeS9Nh8zO6n7pzRfWOfFF2K/Hg7Kq2HQo=.sha256"}}
{"type":"tildefriends-app","files":{"app.js":"&3d9ABFgRwQvWsYbFv/rzimtnLDnVrWlGtdw7serFIGw=.sha256"}}

1
apps/cory/ssblit.json Normal file
View File

@ -0,0 +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"}}

56
apps/cory/ssblit/app.js Normal file
View File

@ -0,0 +1,56 @@
import * as tfrpc from '/tfrpc.js';
let g_database;
let g_hash;
tfrpc.register(async function localStorageGet(key) {
return app.localStorageGet(key);
});
tfrpc.register(async function localStorageSet(key, value) {
return app.localStorageSet(key, value);
});
tfrpc.register(async function databaseGet(key) {
return g_database ? g_database.get(key) : undefined;
});
tfrpc.register(async function databaseSet(key, value) {
return g_database ? g_database.set(key, value) : undefined;
});
tfrpc.register(async function getIdentities() {
return ssb.getIdentities();
});
tfrpc.register(async function query(sql, args) {
let result = [];
await ssb.sqlStream(sql, args, function callback(row) {
result.push(row);
});
return result;
});
tfrpc.register(async function appendMessage(id, message) {
return ssb.appendMessageWithIdentity(id, message);
});
core.register('message', async function message_handler(message) {
if (message.event == 'hashChange') {
g_hash = message.hash;
await tfrpc.rpc.hashChanged(message.hash);
}
});
tfrpc.register(function getHash(id, message) {
return g_hash;
});
ssb.addEventListener('message', async function(id) {
await tfrpc.rpc.notifyNewMessage(id);
});
tfrpc.register(async function store_blob(blob) {
if (Array.isArray(blob)) {
blob = Uint8Array.from(blob);
}
return await ssb.blobStore(blob);
});
async function main() {
if (typeof(database) !== 'undefined') {
g_database = await database('ssb');
}
await app.setDocument(utf8Decode(await getFile('index.html')));
}
main();

1
apps/cory/ssblit/commonmark.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,86 @@
let g_emojis;
function get_emojis() {
if (g_emojis) {
return Promise.resolve(g_emojis);
}
return fetch('emojis.json').then(function(result) {
g_emojis = result.json();
return g_emojis;
});
}
export function picker(callback, anchor) {
get_emojis().then(function(json) {
let existing = document.getElementById('emoji_picker');
if (existing) {
existing.parentElement.removeChild(existing);
return;
}
let div = document.createElement('div');
div.id = 'emoji_picker';
div.style.color = '#000';
div.style.background = '#fff';
div.style.border = '1px solid #000';
div.style.display = 'block';
div.style.position = 'absolute';
div.style.maxWidth = '16em';
div.style.maxHeight = '16em';
div.style.overflow = 'scroll';
div.style.fontWeight = 'bold';
let input = document.createElement('input');
input.type = 'text';
div.appendChild(input);
let list = document.createElement('div');
div.appendChild(list);
function refresh() {
while (list.firstChild) {
list.removeChild(list.firstChild);
}
let search = input.value;
console.log('refresh', search);
Object.entries(json).forEach(function(row) {
let header = document.createElement('div');
header.appendChild(document.createTextNode(row[0]));
list.appendChild(header);
let any = false;
for (let entry of row[1]) {
if (search &&
search.length &&
entry.name.indexOf(search) == -1) {
continue;
}
let emoji = document.createElement('span');
const k_size = '1.25em';
emoji.style.width = k_size;
emoji.style.maxWidth = k_size;
emoji.style.minWidth = k_size;
emoji.style.height = k_size;
emoji.style.maxHeight = k_size;
emoji.style.minHeight = k_size;
emoji.style.display = 'inline-block';
emoji.style.overflow = 'hidden';
emoji.style.cursor = 'pointer';
emoji.onclick = function() {
callback(entry);
div.parentElement.removeChild(div);
}
emoji.title = entry.name;
emoji.appendChild(document.createTextNode(entry.emoji));
list.appendChild(emoji);
any = true;
}
if (!any) {
list.removeChild(header);
}
});
}
refresh();
input.oninput = refresh;
document.body.appendChild(div);
div.style.position = 'fixed';
div.style.top = '50%';
div.style.left = '50%';
div.style.transform = 'translate(-50%, -50%)';
});
}

15115
apps/cory/ssblit/emojis.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html style="color: #fff">
<head>
<title>Tilde Friends</title>
</head>
<body>
<h1>Tilde Friends</h1>
<tf-app/>
<script>window.litDisableBundleWarning = true;</script>
<script src="commonmark.min.js"></script>
<script src="script.js" type="module"></script>
</body>
</html>

133
apps/cory/ssblit/lit-all.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,9 @@
import {LitElement, html} from './lit-all.min.js';
import * as tfrpc from '/static/tfrpc.js';
import * as tf_id_picker from './tf-id-picker.js';
import * as tf_app from './tf-app.js';
import * as tf_message from './tf-message.js';
import * as tf_user from './tf-user.js';
import * as tf_compose from './tf-compose.js';
import * as tf_profile from './tf-profile.js';

421
apps/cory/ssblit/tf-app.js Normal file
View File

@ -0,0 +1,421 @@
import {LitElement, html, css} from './lit-all.min.js';
import * as tfrpc from '/static/tfrpc.js';
class TfElement extends LitElement {
static get properties() {
return {
whoami: {type: String},
ids: {type: Array},
messages: {type: Array},
users: {type: Object},
allFollowing: {type: Array},
status: {type: Array},
hash: {type: String},
unread: {type: Array},
};
}
constructor() {
super();
let self = this;
this.ids = [];
this.users = {};
this.messages = [];
this.allFollowing = [];
this.status = [];
this.messages_by_id = {};
this.hash = '#';
this.loading = false;
this.unread = [];
tfrpc.rpc.getIdentities().then(ids => { self.ids = ids || [] });
tfrpc.rpc.getHash().then(hash => self.hash = hash || '#');
tfrpc.register(function hashChanged(hash) {
self.hash = hash;
self.load();
});
tfrpc.register(async function notifyNewMessage(id) {
await self.fetch_new_message(id);
});
}
async contacts_internal(id, last_row_id, following, max_row_id) {
let result = Object.assign({}, following[id] || {});
result.following = result.following || {};
result.blocking = result.blocking || {};
let contacts = await tfrpc.rpc.query(
`
SELECT content FROM messages
WHERE author = ? AND
rowid > ? AND
rowid <= ? AND
json_extract(content, "$.type") = "contact"
ORDER BY sequence
`,
[id, last_row_id, max_row_id]);
for (let row of contacts) {
let contact = JSON.parse(row.content);
if (contact.following === true) {
result.following[contact.contact] = true;
} else if (contact.following === false) {
delete result.following[contact.contact];
} else if (contact.blocking === true) {
result.blocking[contact.contact] = true;
} else if (contact.blocking === false) {
delete result.blocking[contact.contact];
}
}
return result;
}
async contact(id, last_row_id, following, max_row_id) {
if (this.users[id]?.following) {
return this.users[id];
}
let result = await this.contacts_internal(id, last_row_id, following, max_row_id);
let users = this.users;
users[id] = Object.assign(users[id] || {}, result);
following[id] = users[id];
this.users = users;
return result;
}
async following_deep_internal(ids, depth, blocking, last_row_id, following, max_row_id) {
let contacts = await Promise.all([...new Set(ids)].map(x => this.contact(x, last_row_id, following, max_row_id)));
let result = {};
for (let i = 0; i < ids.length; i++) {
let id = ids[i];
let contact = contacts[i];
let found = Object.keys(contact.following).filter(y => !contact.blocking[y]);
let deeper = depth > 1 ? await this.following_deep_internal(found, depth - 1, Object.assign({}, contact.blocking, blocking), last_row_id, following, max_row_id) : [];
result[id] = [id, ...found, ...deeper];
}
return [...new Set(Object.values(result).flat())];
}
async following_deep(ids, depth, blocking) {
const k_cache_version = 4;
let cache = await tfrpc.rpc.databaseGet('following');
cache = cache ? JSON.parse(cache) : {};
if (cache.version !== k_cache_version) {
cache = {
version: k_cache_version,
following: {},
last_row_id: 0,
};
}
let max_row_id = (await tfrpc.rpc.query(`
SELECT MAX(rowid) AS max_row_id FROM messages
`, []))[0].max_row_id;
let result = await this.following_deep_internal(ids, depth, blocking, cache.last_row_id, cache.following, max_row_id);
cache.last_row_id = max_row_id;
await tfrpc.rpc.databaseSet('following', JSON.stringify(cache));
return result;
}
async fetch_about(ids) {
const k_cache_version = 1;
let cache = await tfrpc.rpc.databaseGet('about');
cache = cache ? JSON.parse(cache) : {};
if (cache.version !== k_cache_version) {
cache = {
version: k_cache_version,
about: {},
last_row_id: 0,
};
}
let max_row_id = (await tfrpc.rpc.query(`
SELECT MAX(rowid) AS max_row_id FROM messages
`, []))[0].max_row_id;
for (let id of Object.keys(cache.about)) {
if (ids.indexOf(id) == -1) {
delete cache.about[id];
}
}
let abouts = await tfrpc.rpc.query(
`
SELECT
messages.*
FROM
messages,
json_each(?1) AS following
WHERE
messages.author = following.value AND
messages.rowid > ?3 AND
messages.rowid <= ?4 AND
json_extract(messages.content, '$.type') = 'about'
UNION
SELECT
messages.*
FROM
messages,
json_each(?2) AS following
WHERE
messages.author = following.value AND
messages.rowid <= ?4 AND
json_extract(messages.content, '$.type') = 'about'
ORDER BY messages.author, messages.sequence
`,
[
JSON.stringify(ids.filter(id => cache.about[id])),
JSON.stringify(ids.filter(id => !cache.about[id])),
cache.last_row_id,
max_row_id,
]);
for (let about of abouts) {
let content = JSON.parse(about.content);
if (content.about === about.author) {
delete content.type;
delete content.about;
cache.about[about.author] = Object.assign(cache.about[about.author] || {}, content);
}
}
cache.last_row_id = max_row_id;
await tfrpc.rpc.databaseSet('about', JSON.stringify(cache));
let users = this.users || {};
for (let id of Object.keys(cache.about)) {
users[id] = Object.assign(users[id] || {}, cache.about[id]);
}
this.users = Object.assign({}, users);
}
async fetch_messages() {
if (this.hash.startsWith('#@')) {
return await tfrpc.rpc.query(
`
SELECT messages.*
FROM messages
WHERE messages.author = ?
ORDER BY sequence DESC
LIMIT 20
`,
[
this.hash.substring(1),
]);
} else if (this.hash.startsWith('#%')) {
return await tfrpc.rpc.query(
`
SELECT messages.*
FROM messages
WHERE id = ?
`,
[
this.hash.substring(1),
]);
} else {
return await tfrpc.rpc.query(
`
SELECT messages.*
FROM messages
JOIN json_each(?) AS following ON messages.author = following.value
WHERE messages.timestamp > ?
ORDER BY messages.timestamp DESC
`,
[
JSON.stringify(this.allFollowing),
new Date().valueOf() - 24 * 60 * 60 * 1000,
]);
}
}
async fetch_new_message(id) {
let messages = await tfrpc.rpc.query(
`
SELECT messages.*
FROM messages
JOIN json_each(?) AS following ON messages.author = following.value
WHERE messages.id = ?
`,
[
JSON.stringify(this.allFollowing),
id,
]);
let self = this;
let mine = messages.filter(m => m.author === self.whoami);
if (mine.length) {
this.process_messages(mine);
await this.finalize_messages();
}
let other = messages.filter(m => m.author !== self.whoami);
if (other.length) {
this.unread = [...this.unread, ...other];
}
}
async show_more() {
let unread = this.unread;
this.unread = [];
this.process_messages(unread);
await this.finalize_messages();
}
record_status(text) {
let now = new Date();
if (this.status.length) {
this.status[this.status.length - 1].end_time = now;
console.log(
this.status[this.status.length - 1].text,
(now - this.status[this.status.length - 1].start_time).valueOf());
}
this.status.push({
text: text,
start_time: now,
});
}
ensure_message(id) {
let found = this.messages_by_id[id];
if (found) {
return found;
} else {
let added = {
id: id,
placeholder: true,
content: '"placeholder"',
parent_message: undefined,
child_messages: [],
votes: [],
};
this.messages_by_id[id] = added;
return added;
}
}
process_messages(messages) {
let self = this;
function link_message(message) {
if (message.content.type === 'vote') {
let parent = self.ensure_message(message.content.vote.link);
parent.votes.push(message);
message.parent_message = message.content.vote.link;
} else if (message.content.type == 'post') {
if (message.content.root) {
if (typeof(message.content.root) === 'string') {
let m = self.ensure_message(message.content.root);
if (!m.child_messages) {
m.child_messages = [];
}
m.child_messages.push(message);
message.parent_message = message.content.root;
} else {
let m = self.ensure_message(message.content.root[0]);
if (!m.child_messages) {
m.child_messages = [];
}
m.child_messages.push(message);
message.parent_message = message.content.root[0];
}
}
}
}
for (let message of messages) {
message.content = JSON.parse(message.content);
if (!this.messages_by_id[message.id]) {
this.messages_by_id[message.id] = message;
link_message(message);
} else if (this.messages_by_id[message.id].placeholder) {
let placeholder = this.messages_by_id[message.id];
this.messages_by_id[message.id] = message;
message.parent_message = placeholder.parent_message;
message.child_messages = placeholder.child_messages;
message.votes = placeholder.votes;
if (placeholder.parent_message && this.messages_by_id[placeholder.parent_message]) {
let children = this.messages_by_id[placeholder.parent_message].child_messages;
children.splice(children.indexOf(placeholder), 1);
children.push(message);
}
link_message(message);
}
}
}
async load_placeholders() {
let placeholders = Object.values(this.messages_by_id).filter(x => x.placeholder).map(x => x.id);
return await tfrpc.rpc.query(
`
SELECT messages.* FROM messages
JOIN json_each(?) AS placeholder ON messages.id = placeholder.value
JOIN json_each(?) AS following ON messages.author = following.value
ORDER BY messages.timestamp DESC
`,
[
JSON.stringify(placeholders),
JSON.stringify(this.allFollowing),
]);
}
async finalize_messages() {
this.process_messages(await this.load_placeholders());
function recursive_sort(messages, top) {
if (messages) {
if (top) {
messages.sort((a, b) => b.timestamp - a.timestamp);
} else {
messages.sort((a, b) => a.timestamp - b.timestamp);
}
for (let message of messages) {
recursive_sort(message.child_messages, false);
}
return messages.map(x => Object.assign({}, x));
}
}
this.messages =
recursive_sort(
Object.values(this.messages_by_id)
.filter(x => !x.parent_message),
true);
}
async load() {
if (this.loading || (!this.whoami && this.ids.length)) {
return;
}
this.loading = true;
this.renderRoot.getElementById('load_button').disabled = true;
this.status = [];
this.messages = [];
this.messages_by_id = {};
this.users = {};
this.allFollowing = [];
console.log('loading...', this.hash);
this.record_status('loading');
this.record_status('getting following');
this.allFollowing = await this.following_deep([this.whoami], 2, {});
console.log('following', this.allFollowing.length, 'identities');
this.record_status('getting about');
await this.fetch_about(this.allFollowing.sort());
this.record_status('getting messages');
this.process_messages(await this.fetch_messages());
await this.finalize_messages();
this.record_status('done');
this.status = [];
this.renderRoot.getElementById('load_button').disabled = false;
this.loading = false;
}
_handle_whoami_changed(event) {
this.whoami = event.srcElement.selected;
this.load();
}
render() {
let profile = this.hash.startsWith('#@') ?
html`<tf-profile id=${this.hash.substring(1)} .users=${this.users}></tf-profile>` : undefined;
return html`
<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>
<a target="_top" href="#" ?hidden=${this.hash.length <= 1}>🏠Home</a>
<div><input type="button" value=${'Show ' + this.unread.length + ' New Messages'} @click=${this.show_more}></input></div>
<div>Welcome, <tf-user id=${this.whoami} .users=${this.users}></tf-user>!</div>
<div><tf-compose whoami=${this.whoami} .users=${this.users}></tf-compose></div>
<div style="font-family: monospace">${this.status.map(x => html`<div>${x.text}...${x.start_time && x.end_time ? 'took ' + Math.round(10 * (x.end_time - x.start_time) / 1000) / 10 + 's' : undefined}</div>`)}</div>
${profile}
${this.messages?.map(x => html`<tf-message .message=${x} whoami=${this.whoami} .users=${this.users}></tf-message>`)}
`;
}
}
customElements.define('tf-app', TfElement);

View File

@ -0,0 +1,88 @@
import {LitElement, html} from './lit-all.min.js';
import * as tfutils from './tf-utils.js';
import * as tfrpc from '/static/tfrpc.js';
class TfComposeElement extends LitElement {
static get properties() {
return {
whoami: {type: String},
users: {type: Object},
root: {type: String},
branch: {type: String},
}
}
constructor() {
super();
this.users = {};
this.root = undefined;
this.branch = undefined;
}
changed(event) {
let edit = this.renderRoot.getElementById('edit');
let preview = this.renderRoot.getElementById('preview');
preview.innerHTML = tfutils.markdown(edit.value);
}
submit() {
let self = this;
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;
}
console.log('Would post:', message);
tfrpc.rpc.appendMessage(this.whoami, message).then(function() {
edit.value = '';
self.changed();
}).catch(function(error) {
alert(error.message);
});
}
discard() {
let edit = this.renderRoot.getElementById('edit');
edit.value = '';
this.changed();
this.dispatchEvent(new CustomEvent('tf-discard'));
}
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];
file.arrayBuffer().then(function(buffer) {
let bin = Array.from(new Uint8Array(buffer));
return tfrpc.rpc.store_blob(bin);
}).then(function(id) {
edit.value += `\n![${file.name}](${id})`;
self.changed();
}).catch(function(e) {
alert(e.message);
});
};
input.click();
}
render() {
return html`
<div style="display: flex; flex-direction: row; width: 100%">
<textarea id="edit" @input=${this.changed} style="flex: 1 0 50%"></textarea>
<div id="preview" style="flex: 1 0 50%"></div>
</div>
<input type="button" value="Submit" @click=${this.submit}></input>
<input type="button" value="Attach" @click=${this.attach}></input>
<input type="button" value="Discard" @click=${this.discard}></input>
`;
}
}
customElements.define('tf-compose', TfComposeElement);

View File

@ -0,0 +1,48 @@
import {LitElement, html} from './lit-all.min.js';
import * as tfrpc from '/static/tfrpc.js';
/*
** Provide a list of IDs, and this lets the user pick one
** and updates local storage remembering the active identity.
*/
class TfIdentityPickerElement extends LitElement {
static get properties() {
return {
ids: {type: Array},
selected: {type: String},
}
}
constructor() {
super();
let self = this;
this.ids = [];
tfrpc.rpc.localStorageGet('whoami').then(function(selected) {
self.selected = selected;
self._emit_change();
});
}
_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`
<select @change=${this.changed}>
${this.ids.map(id => html`<option ?selected=${id == this.selected}>${id}</option>`)}
</select>
`;
}
}
customElements.define('tf-id-picker', TfIdentityPickerElement);

View File

@ -0,0 +1,164 @@
import {LitElement, html, 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},
reply: {type: Boolean},
raw: {type: Boolean},
}
}
static styles = styles;
constructor() {
super();
let self = this;
this.whoami = null;
this.message = {};
this.users = {};
this.reply = false;
this.raw = false;
}
show_reply() {
this.reply = true;
}
render_votes() {
function normalize_expression(expression) {
if (expression === 'Like' || !expression) {
return '👍';
} else {
return expression;
}
}
return html`<div>${(this.message.votes || []).map(vote => html`<span title="${this.users[vote.author]?.name ?? vote.author}">${normalize_expression(vote.content.vote.expression)}</span>`)}</div>`;
}
render_raw() {
return html`<div style="white-space: pre-wrap">${JSON.stringify(this.message, null, 2)}</div>`
}
vote(emoji) {
let reaction = emoji.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));
}
render() {
let content = this.message?.content;
let self = this;
let raw_button = this.raw ?
html`<input type="button" value="Message" @click=${() => self.raw = false}></input>` :
html`<input type="button" value="Raw" @click=${() => self.raw = true}></input>`;
function small_frame(inner) {
return html`
<div style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px; display: inline-block">
<tf-user id=${self.message.author} .users=${self.users}></tf-user>
<span style="padding-right: 8px"><a target="_top" href=${'#' + self.message.id}>%</a> ${new Date(self.message.timestamp).toLocaleString()}</span>
${raw_button}
${self.raw ? self.render_raw() : inner}
${self.render_votes()}
</div>
`
}
if (this.message.placeholder) {
return html`
<div style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px">
${this.message.id} (placeholder)
<div>${this.render_votes()}</div>
${(this.message.child_messages || []).map(x => html`
<tf-message .message=${x} whoami=${this.whoami} .users=${this.users}></tf-message>
`)}
</div>`;
} else if (content.type == 'about') {
return small_frame(html`
<div style="font-weight: bold">Updated profile:</div>
<pre style="white-space: pre-wrap">${JSON.stringify(content, null, 2)}</pre>
`);
} else if (content.type == 'contact') {
return small_frame(html`
<div>
is now
${
content.blocking === true ? 'blocking' :
content.blocking === false ? 'unblocking' :
content.following === true ? 'following' :
content.following === false ? 'unfollowing' :
'?'
}
<tf-user id=${this.message.content.contact} .users=${this.users}></tf-user>
</div>
`);
} else if (content.type == 'post') {
let reply = this.reply ? html`
<tf-compose
?enabled=${this.reply}
whoami=${this.whoami}
.users=${this.users}
root=${this.message.content.root || this.message.id}
branch=${this.message.id}
@tf-discard=${() => this.reply = false}></tf-compose>
` : html`
<input type="button" value="Reply" @click=${this.show_reply}></input>
`;
let self = this;
let body = this.raw ?
this.render_raw() :
unsafeHTML(tfutils.markdown(content.text));
return html`
<style>
img {
max-width: 100%;
height: auto;
}
</style>
<div style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); 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_votes()}
<div>
${reply}
<input type="button" value="React" @click=${this.react}></input>
</div>
${(this.message.child_messages || []).map(x => html`<tf-message .message=${x} whoami=${this.whoami} .users=${this.users}></tf-message>`)}
</div>
`;
} else if (typeof(this.message.content) == 'string') {
return small_frame(html`<span>🔒</span>`);
} else {
return small_frame(this.render_raw());
}
}
}
customElements.define('tf-message', TfMessageElement);

View File

@ -0,0 +1,37 @@
import {LitElement, html, unsafeHTML} from './lit-all.min.js';
import * as tfrpc from '/static/tfrpc.js';
import * as tfutils from './tf-utils.js';
import {styles} from './tf-styles.js';
class TfProfileElement extends LitElement {
static get properties() {
return {
id: {type: String},
users: {type: Object},
}
}
static styles = styles;
constructor() {
super();
let self = this;
this.id = null;
this.users = {};
}
render_raw() {
return html`<div style="white-space: pre-wrap">${JSON.stringify(this.message, null, 2)}</div>`
}
render() {
let profile = this.users[this.id] || {};
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>
<div><img src=${'/' + profile.image + '/view'} style="width: 256px; height: auto"></img></div>
<div>${unsafeHTML(tfutils.markdown(profile.description))}</div>
</div>`;
}
}
customElements.define('tf-profile', TfProfileElement);

View File

@ -0,0 +1,15 @@
import {css} from './lit-all.min.js';
export let styles = css`
a:link {
color: #bbf;
}
a:visited {
color: #ddd;
}
a:hover {
color: #ddf;
}
`;

View File

@ -0,0 +1,39 @@
import {LitElement, html} from './lit-all.min.js';
import * as tfrpc from '/static/tfrpc.js';
import {styles} from './tf-styles.js';
class TfUserElement extends LitElement {
static get properties() {
return {
id: {type: String},
users: {type: Object},
}
}
static styles = styles;
constructor() {
super();
this.id = null;
this.users = {};
}
render() {
if (this.users[this.id]) {
let image = this.users[this.id].image;
image = typeof(image) == 'string' ? image : image?.link;
return html`
<div style="display: inline-block; font-weight: bold">
<img style="width: 2em; height: 2em; vertical-align: middle; border-radius: 50%" src="${'/' + image + '/view'}">
<a target="_top" href=${'#' + this.id}>${this.users[this.id].name}</a>
</div>`;
} else {
return html`
<div style="display: inline-block; font-weight: bold">
<a target="_top" href=${'#' + this.id}>${this.id}</a>
</div>`;
}
}
}
customElements.define('tf-user', TfUserElement);

View File

@ -0,0 +1,29 @@
export function markdown(md) {
var reader = new commonmark.Parser({safe: true});
var writer = new commonmark.HtmlRenderer();
var parsed = reader.parse(md || '');
var walker = parsed.walker();
var event, node;
while ((event = walker.next())) {
node = event.node;
if (event.entering) {
if (node.type == 'link') {
if (node.destination.startsWith('@') &&
node.destination.endsWith('.ed25519')) {
node.destination = '#' + node.destination;
} else if (node.destination.startsWith('%') &&
node.destination.endsWith('.sha256')) {
node.destination = '#' + node.destination;
} else if (node.destination.startsWith('&') &&
node.destination.endsWith('.sha256')) {
node.destination = '/' + node.destination + '/view';
}
} else if (node.type == 'image') {
if (node.destination.startsWith('&')) {
node.destination = '/' + node.destination + '/view';
}
}
}
}
return writer.render(parsed);
}

View File

@ -296,6 +296,7 @@ static int _tf_command_export(const char* file, int argc, char* argv[])
"/~cory/docs",
"/~cory/follow",
"/~cory/ssb",
"/~cory/ssblit",
};
for (int i = 0; i < (int)_countof(k_export); i++)
{