import {LitElement, html, css, guard, until} from './lit-all.min.js'; import * as tfrpc from '/static/tfrpc.js'; import {styles} from './tf-styles.js'; class TfElement extends LitElement { static get properties() { return { whoami: {type: String}, hash: {type: String}, unread: {type: Array}, tab: {type: String}, broadcasts: {type: Array}, connections: {type: Array}, loading: {type: Boolean}, loaded: {type: Boolean}, following: {type: Array}, users: {type: Object}, ids: {type: Array}, channels: {type: Array}, channels_unread: {type: Object}, channels_latest: {type: Object}, }; } static styles = styles; constructor() { super(); let self = this; this.hash = '#'; this.unread = []; this.tab = 'news'; this.broadcasts = []; this.connections = []; this.following = []; this.users = {}; this.loaded = false; this.channels = []; this.channels_unread = {}; this.channels_latest = {}; this.loading_channels_latest = 0; this.loading_channels_latest_scheduled = 0; tfrpc.rpc.getBroadcasts().then((b) => { self.broadcasts = b || []; }); tfrpc.rpc.getConnections().then((c) => { self.connections = c || []; }); tfrpc.rpc.getHash().then((hash) => self.set_hash(hash)); tfrpc.register(function hashChanged(hash) { self.set_hash(hash); }); tfrpc.register(async function notifyNewMessage(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; } else if (name === 'identity') { self.whoami = value; } }); this.initial_load(); } async initial_load() { let whoami = await tfrpc.rpc.getActiveIdentity(); let ids = (await tfrpc.rpc.getIdentities()) || []; this.whoami = whoami ?? (ids.length ? ids[0] : undefined); this.ids = ids; await this.load_channels(); } async load_channels() { let channels = await tfrpc.rpc.query( ` SELECT content ->> 'channel' AS channel, content ->> 'subscribed' AS subscribed FROM messages WHERE author = ? AND content ->> 'type' = 'channel' ORDER BY sequence `, [this.whoami] ); let channel_map = {}; for (let row of channels) { if (row.subscribed) { channel_map[row.channel] = true; } else { delete channel_map[row.channel]; } } this.channels = Object.keys(channel_map).sort(); } connectedCallback() { super.connectedCallback(); this._keydown = this.keydown.bind(this); window.addEventListener('keydown', this._keydown); } disconnectedCallback() { super.disconnectedCallback(); window.removeEventListener('keydown', this._keydown); } keydown(event) { if (event.altKey && event.key == 'ArrowUp') { this.next_channel(1); event.preventDefault(); } else if (event.altKey && event.key == 'ArrowDown') { this.next_channel(-1); event.preventDefault(); } } next_channel(delta) { let channel_names = ['', '@'].concat(this.channels); let index = channel_names.indexOf(this.hash.substring(1)); if (index != -1) { index += delta; this.set_hash( '#' + encodeURIComponent( channel_names[(index + channel_names.length) % channel_names.length] ) ); } } set_hash(hash) { this.hash = decodeURIComponent(hash || '#'); if (this.hash.startsWith('#q=')) { this.tab = 'search'; } else if (this.hash === '#connections') { this.tab = 'connections'; } else if (this.hash.startsWith('#sql=')) { this.tab = 'query'; } else { this.tab = 'news'; } } async fetch_about(ids, users) { 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.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature 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.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature 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)); users = users || {}; for (let id of Object.keys(cache.about)) { users[id] = Object.assign(users[id] || {}, cache.about[id]); } return Object.assign({}, users); } async fetch_new_message(id) { let messages = await tfrpc.rpc.query( ` SELECT messages.id, previous, author, sequence, timestamp, hash, json(content) AS content, signature FROM messages JOIN json_each(?) AS following ON messages.author = following.value WHERE messages.id = ? `, [JSON.stringify(this.following), id] ); for (let message of messages) { if (message.author == this.whoami) { let content = JSON.parse(message.content); if (content?.type == 'channel') { this.load_channels(); } } } if (messages && messages.length) { this.unread = [...this.unread, ...messages]; this.unread = this.unread.slice(this.unread.length - 1024); } this.schedule_load_channels_latest(); } async _handle_whoami_changed(event) { let old_id = this.whoami; let new_id = event.srcElement.selected; console.log('received', new_id); if (this.whoami !== new_id) { console.log(event); this.whoami = new_id; console.log(`whoami ${old_id} => ${new_id}`); await tfrpc.rpc.localStorageSet('whoami', new_id); } } async create_identity() { if (confirm('Are you sure you want to create a new identity?')) { await tfrpc.rpc.createIdentity(); this.ids = (await tfrpc.rpc.getIdentities()) || []; if (this.ids && !this.whoami) { this.whoami = this.ids[0]; } } } async get_latest_private(following) { let latest = (await tfrpc.rpc.query('SELECT MAX(rowid) AS latest FROM messages'))[0].latest; const k_chunk_count = 256; while (latest - k_chunk_count >= 0) { let messages = await tfrpc.rpc.query(` SELECT messages.rowid, messages.id, previous, author, sequence, timestamp, hash, json(content) AS content, signature FROM messages JOIN json_each(?1) AS following ON messages.author = following.value WHERE messages.rowid > ?2 AND messages.rowid <= ?3 AND json(messages.content) LIKE '"%' ORDER BY sequence DESC `, [ JSON.stringify(following), latest - k_chunk_count, latest, ]); messages = (await this.decrypt(messages)).filter(x => x.decrypted); if (messages.length) { return Math.max(...messages.map(x => x.rowid)); } latest -= k_chunk_count; }; return -1; } async load_channels_latest(following) { this.loading_channels_latest++; try { let start_time = new Date(); let latest_private = this.get_latest_private(following); let channels = await tfrpc.rpc.query( ` SELECT channels.value AS channel, MAX(messages.rowid) AS rowid FROM messages JOIN json_each(?1) AS channels ON messages.content ->> 'channel' = channels.value JOIN json_each(?2) AS following ON messages.author = following.value WHERE messages.content ->> 'type' = 'post' AND messages.content ->> 'root' IS NULL GROUP by channel UNION SELECT '' AS channel, MAX(messages.rowid) AS rowid FROM messages JOIN json_each(?2) AS following ON messages.author = following.value UNION SELECT '@' AS channel, MAX(messages.rowid) AS rowid FROM messages_fts(?3) JOIN messages ON messages.rowid = messages_fts.rowid JOIN json_each(?2) AS following ON messages.author = following.value `, [ JSON.stringify(this.channels), JSON.stringify(following), '"' + this.whoami.replace('"', '""') + '"', ] ); this.channels_latest = Object.fromEntries( channels.map((x) => [x.channel, x.rowid]) ); console.log('latest', this.channels_latest); console.log('unread', this.channels_unread); console.log('channels took', (new Date() - start_time) / 1000.0); let self = this; latest_private.then(function(latest) { self.channels_latest = Object.assign({}, self.channels_latest, {'🔐': latest}); console.log('private took', (new Date() - start_time) / 1000.0); }); } finally { this.loading_channels_latest--; } } _schedule_load_channels_latest_timer() { --this.loading_channels_latest_scheduled; this.schedule_load_channels_latest(); } schedule_load_channels_latest() { if (!this.loading_channels_latest) { this.load_channels_latest(this.following); } else if (!this.loading_channels_latest_scheduled) { this.loading_channels_latest_scheduled++; setTimeout(this._schedule_load_channels_latest_timer, 5000); } } async load() { let whoami = this.whoami; let following = await tfrpc.rpc.following([whoami], 2); let users = {}; let by_count = []; for (let [id, v] of Object.entries(following)) { users[id] = { following: v.of, blocking: v.ob, followed: v.if, blocked: v.ib, }; by_count.push({count: v.of, id: id}); } let channels_latest = this.load_channels_latest(Object.keys(following)); this.channels_unread = JSON.parse( (await tfrpc.rpc.databaseGet('unread')) ?? '{}' ); let start_time = new Date(); users = await this.fetch_about(Object.keys(following).sort(), users); console.log( 'about took', (new Date() - start_time) / 1000.0, 'seconds for', Object.keys(users).length, 'users' ); start_time = new Date(); await channels_latest; this.following = Object.keys(following); this.users = users; console.log(`load finished ${whoami} => ${this.whoami}`); this.whoami = whoami; this.loaded = whoami; } channel_set_unread(event) { this.channels_unread[event.detail.channel ?? ''] = event.detail.unread; this.channels_unread = Object.assign({}, this.channels_unread); tfrpc.rpc.databaseSet('unread', JSON.stringify(this.channels_unread)); } async decrypt(messages) { let whoami = this.whoami; return Promise.all(messages.map(async function (message) { let content; try { content = JSON.parse(message?.content); } catch {} if (typeof content === 'string') { let decrypted; try { decrypted = await tfrpc.rpc.try_decrypt(whoami, content); } catch {} if (decrypted) { try { message.decrypted = JSON.parse(decrypted); } catch { message.decrypted = decrypted; } } } return message; })); } render_tab() { let following = this.following; let users = this.users; if (this.tab === 'news') { return html` (this.unread = [])} ?loading=${this.loading} .channels=${this.channels} .channels_latest=${this.channels_latest} .channels_unread=${this.channels_unread} @channelsetunread=${this.channel_set_unread} > `; } else if (this.tab === 'connections') { return html` `; } else if (this.tab === 'search') { return html` `; } else if (this.tab === 'query') { return html` `; } } async set_tab(tab) { this.tab = tab; if (tab === 'news') { await tfrpc.rpc.setHash('#'); } else if (tab === 'connections') { await tfrpc.rpc.setHash('#connections'); } else if (tab === 'query') { await tfrpc.rpc.setHash('#sql='); } } refresh() { tfrpc.rpc.sync(); } render() { let self = this; if (!this.loading && this.whoami && this.loaded !== this.whoami) { this.loading = true; this.load().finally(function () { self.loading = false; }); } const k_tabs = { '📰': 'news', '📡': 'connections', '🔍': 'search', '👩‍💻': 'query', }; let tabs = html`
${Object.entries(k_tabs).map( ([k, v]) => html` ` )}
`; let contents = !this.loaded ? this.loading ? html`
Loading...
${this.render_tab()}` : html`
Select or create an identity.
` : this.render_tab(); return html`
${tabs} ${contents}
`; } } customElements.define('tf-app', TfElement);