2022-09-06 19:26:43 -04:00
|
|
|
import {LitElement, html, css} from './lit-all.min.js';
|
|
|
|
import * as tfrpc from '/static/tfrpc.js';
|
2022-09-09 22:56:15 -04:00
|
|
|
import {styles} from './tf-styles.js';
|
2022-09-06 19:26:43 -04:00
|
|
|
|
|
|
|
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},
|
2022-09-09 22:56:15 -04:00
|
|
|
tab: {type: String},
|
|
|
|
broadcasts: {type: Array},
|
|
|
|
connections: {type: Array},
|
2022-09-06 19:26:43 -04:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2022-09-09 22:56:15 -04:00
|
|
|
static styles = styles;
|
|
|
|
|
2022-09-06 19:26:43 -04:00
|
|
|
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 = [];
|
2022-09-09 22:56:15 -04:00
|
|
|
this.tab = 'news';
|
|
|
|
this.broadcasts = [];
|
|
|
|
this.connections = [];
|
2022-09-06 19:26:43 -04:00
|
|
|
tfrpc.rpc.getIdentities().then(ids => { self.ids = ids || [] });
|
2022-09-09 22:56:15 -04:00
|
|
|
tfrpc.rpc.getBroadcasts().then(b => { self.broadcasts = b || [] });
|
|
|
|
tfrpc.rpc.getConnections().then(c => { self.connections = c || [] });
|
2022-09-06 19:26:43 -04:00
|
|
|
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);
|
|
|
|
});
|
2022-09-09 22:56:15 -04:00
|
|
|
tfrpc.register(function set(name, value) {
|
|
|
|
if (name === 'broadcasts') {
|
|
|
|
self.broadcasts = value;
|
|
|
|
} else if (name === 'connections') {
|
|
|
|
self.connections = value;
|
|
|
|
}
|
|
|
|
});
|
2022-09-06 19:26:43 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
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);
|
2022-09-09 22:56:15 -04:00
|
|
|
if (!parent.votes) {
|
|
|
|
parent.votes = [];
|
|
|
|
}
|
2022-09-06 19:26:43 -04:00
|
|
|
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;
|
|
|
|
}
|
2022-09-09 22:56:15 -04:00
|
|
|
let load_button = this.renderRoot.getElementById('load_button');
|
2022-09-06 19:26:43 -04:00
|
|
|
this.loading = true;
|
2022-09-09 22:56:15 -04:00
|
|
|
if (load_button) {
|
|
|
|
load_button.disabled = true;
|
|
|
|
}
|
2022-09-06 19:26:43 -04:00
|
|
|
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 = [];
|
2022-09-09 22:56:15 -04:00
|
|
|
if (load_button) {
|
|
|
|
load_button.disabled = false;
|
|
|
|
}
|
2022-09-06 19:26:43 -04:00
|
|
|
this.loading = false;
|
|
|
|
}
|
|
|
|
|
|
|
|
_handle_whoami_changed(event) {
|
|
|
|
this.whoami = event.srcElement.selected;
|
|
|
|
this.load();
|
|
|
|
}
|
|
|
|
|
2022-09-10 14:23:58 -04:00
|
|
|
async search(event) {
|
|
|
|
this.messages = [];
|
|
|
|
this.messages_by_id = {};
|
|
|
|
let query = this.renderRoot.getElementById('search').value;
|
|
|
|
console.log('Searching...');
|
|
|
|
let results = await tfrpc.rpc.query(`
|
|
|
|
SELECT messages.*
|
|
|
|
FROM messages_fts(?)
|
|
|
|
JOIN messages ON messages.rowid = messages_fts.rowid
|
|
|
|
JOIN json_each(?) AS following ON messages.author = following.value
|
|
|
|
ORDER BY timestamp DESC limit 100
|
|
|
|
`,
|
|
|
|
[query, JSON.stringify(this.allFollowing)]);
|
|
|
|
console.log('Done.');
|
|
|
|
this.process_messages(results);
|
|
|
|
await this.finalize_messages();
|
|
|
|
this.renderRoot.getElementById('search').value = '';
|
|
|
|
}
|
|
|
|
|
|
|
|
search_keydown(event) {
|
|
|
|
if (event.keyCode == 13) {
|
|
|
|
this.search();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-09-06 19:26:43 -04:00
|
|
|
render() {
|
2022-09-09 22:56:15 -04:00
|
|
|
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>
|
2022-09-10 14:23:58 -04:00
|
|
|
<input type="button" value="Search" ?disabled=${self.tab == 'search'} @click=${event => self.tab = 'search'}></input>
|
2022-09-09 22:56:15 -04:00
|
|
|
</div>
|
|
|
|
`;
|
2022-09-06 19:26:43 -04:00
|
|
|
let profile = this.hash.startsWith('#@') ?
|
2022-09-09 22:56:15 -04:00
|
|
|
html`<tf-profile id=${this.hash.substring(1)} whoami=${this.whoami} .users=${this.users}></tf-profile>` : undefined;
|
|
|
|
let news = html`
|
2022-09-06 19:26:43 -04:00
|
|
|
<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>`)}
|
|
|
|
`;
|
2022-09-09 22:56:15 -04:00
|
|
|
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>
|
|
|
|
`;
|
2022-09-10 14:23:58 -04:00
|
|
|
} else if (this.tab === 'search') {
|
|
|
|
let search = html`
|
|
|
|
<input type="text" id="search" @keydown=${this.search_keydown}></input>
|
|
|
|
<input type="button" value="Search" @click=${this.search}></input>
|
|
|
|
${this.messages?.map(x => html`<tf-message .message=${x} whoami=${this.whoami} .users=${this.users}></tf-message>`)}
|
|
|
|
`;
|
|
|
|
return html`${tabs}${search}`;
|
2022-09-09 22:56:15 -04:00
|
|
|
}
|
2022-09-06 19:26:43 -04:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
customElements.define('tf-app', TfElement);
|