All checks were successful
		
		
	
	Build Tilde Friends / Build-All (push) Successful in 18m26s
				
			
		
			
				
	
	
		
			893 lines
		
	
	
		
			23 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			893 lines
		
	
	
		
			23 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
import {LitElement, html, css, guard, until} from './lit-all.min.js';
 | 
						|
import * as tfrpc from '/static/tfrpc.js';
 | 
						|
import {styles, generate_theme} from './tf-styles.js';
 | 
						|
 | 
						|
class TfElement extends LitElement {
 | 
						|
	static get properties() {
 | 
						|
		return {
 | 
						|
			whoami: {type: String},
 | 
						|
			hash: {type: String},
 | 
						|
			tab: {type: String},
 | 
						|
			broadcasts: {type: Array},
 | 
						|
			connections: {type: Array},
 | 
						|
			loading: {type: Boolean},
 | 
						|
			loading_about: {type: Number},
 | 
						|
			loaded: {type: Boolean},
 | 
						|
			following: {type: Array},
 | 
						|
			users: {type: Object},
 | 
						|
			ids: {type: Array},
 | 
						|
			channels: {type: Array},
 | 
						|
			channels_unread: {type: Object},
 | 
						|
			channels_latest: {type: Object},
 | 
						|
			guest: {type: Boolean},
 | 
						|
			url: {type: String},
 | 
						|
			private_closed: {type: Object},
 | 
						|
			private_messages: {type: Array},
 | 
						|
			grouped_private_messages: {type: Object},
 | 
						|
			recent_reactions: {type: Array},
 | 
						|
			is_administrator: {type: Boolean},
 | 
						|
			stay_connected: {type: Boolean},
 | 
						|
			progress: {type: Number},
 | 
						|
		};
 | 
						|
	}
 | 
						|
 | 
						|
	static styles = styles;
 | 
						|
 | 
						|
	constructor() {
 | 
						|
		super();
 | 
						|
		let self = this;
 | 
						|
		this.hash = '#';
 | 
						|
		this.tab = 'news';
 | 
						|
		this.broadcasts = [];
 | 
						|
		this.connections = [];
 | 
						|
		this.following = [];
 | 
						|
		this.users = {};
 | 
						|
		this.loaded = false;
 | 
						|
		this.loading_about = 0;
 | 
						|
		this.channels = [];
 | 
						|
		this.channels_unread = {};
 | 
						|
		this.channels_latest = {};
 | 
						|
		this.loading_latest = 0;
 | 
						|
		this.loading_latest_scheduled = 0;
 | 
						|
		this.recent_reactions = [];
 | 
						|
		this.private_closed = {};
 | 
						|
		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);
 | 
						|
			self.reset_progress();
 | 
						|
		});
 | 
						|
		tfrpc.register(async function notifyNewMessage(id) {
 | 
						|
			await self.fetch_new_message(id);
 | 
						|
		});
 | 
						|
		tfrpc.register(async function notifyNewBlob(id) {
 | 
						|
			window.dispatchEvent(
 | 
						|
				new CustomEvent('blob-stored', {
 | 
						|
					bubbles: true,
 | 
						|
					composed: true,
 | 
						|
					detail: {
 | 
						|
						id: 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.is_administrator = await tfrpc.rpc.isAdministrator();
 | 
						|
		this.stay_connected =
 | 
						|
			this.is_administrator &&
 | 
						|
			(await tfrpc.rpc.globalSettingsGet('stay_connected'));
 | 
						|
		this.url = await tfrpc.rpc.url();
 | 
						|
		this.whoami = whoami ?? (ids.length ? ids[0] : undefined);
 | 
						|
		this.guest = !this.whoami?.length;
 | 
						|
		this.ids = ids;
 | 
						|
		let private_closed =
 | 
						|
			(await tfrpc.rpc.databaseGet('private_closed')) ?? '{}';
 | 
						|
		this.private_closed = JSON.parse(private_closed);
 | 
						|
		await this.load_channels();
 | 
						|
	}
 | 
						|
 | 
						|
	async close_private_chat(event) {
 | 
						|
		let update = {};
 | 
						|
		update[event.detail.key] = true;
 | 
						|
		this.private_closed = Object.assign(update, this.private_closed);
 | 
						|
		await tfrpc.rpc.databaseSet(
 | 
						|
			'private_closed',
 | 
						|
			JSON.stringify(this.private_closed)
 | 
						|
		);
 | 
						|
	}
 | 
						|
 | 
						|
	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();
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	visible_private() {
 | 
						|
		if (!this.grouped_private_messages || !this.private_closed) {
 | 
						|
			return [];
 | 
						|
		}
 | 
						|
		let self = this;
 | 
						|
		return Object.fromEntries(
 | 
						|
			Object.entries(this.grouped_private_messages).filter(([key, value]) => {
 | 
						|
				let channel = '🔐' + [...new Set(JSON.parse(key))].sort().join(',');
 | 
						|
				let grouped_latest = Math.max(...value.map((x) => x.rowid));
 | 
						|
				return (
 | 
						|
					!self.private_closed[key] ||
 | 
						|
					self.channels_unread[channel] === undefined ||
 | 
						|
					grouped_latest > self.channels_unread[channel]
 | 
						|
				);
 | 
						|
			})
 | 
						|
		);
 | 
						|
	}
 | 
						|
 | 
						|
	next_channel(delta) {
 | 
						|
		let channel_names = [
 | 
						|
			'',
 | 
						|
			'@',
 | 
						|
			'👍',
 | 
						|
			...Object.keys(this.visible_private())
 | 
						|
				.sort()
 | 
						|
				.map((x) => '🔐' + JSON.parse(x).join(',')),
 | 
						|
			...this.channels.map((x) => '#' + x),
 | 
						|
		];
 | 
						|
		let index = channel_names.indexOf(this.hash.substring(1));
 | 
						|
		index = index != -1 ? index + delta : 0;
 | 
						|
		tfrpc.rpc.setHash(
 | 
						|
			'#' +
 | 
						|
				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 {
 | 
						|
			this.tab = 'news';
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	async fetch_about(following, users) {
 | 
						|
		this.loading_about++;
 | 
						|
		let ids = Object.keys(following).sort();
 | 
						|
		const k_cache_version = 3;
 | 
						|
		let cache = await tfrpc.rpc.databaseGet('about');
 | 
						|
		let original_cache = cache;
 | 
						|
		cache = cache ? JSON.parse(cache) : {};
 | 
						|
		if (cache.version !== k_cache_version) {
 | 
						|
			cache = {
 | 
						|
				version: k_cache_version,
 | 
						|
				about: {},
 | 
						|
			};
 | 
						|
		}
 | 
						|
 | 
						|
		let ids_out_of_date = ids.filter(
 | 
						|
			(x) =>
 | 
						|
				(users[x]?.seq && !cache.about[x]?.seq) ||
 | 
						|
				(users[x]?.seq && users[x]?.seq > cache.about[x].seq)
 | 
						|
		);
 | 
						|
 | 
						|
		for (let id of Object.keys(cache.about)) {
 | 
						|
			if (ids.indexOf(id) == -1) {
 | 
						|
				delete cache.about[id];
 | 
						|
			} else {
 | 
						|
				users[id] = Object.assign(cache.about[id], users[id] || {});
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		console.log(
 | 
						|
			'loading about for',
 | 
						|
			ids.length,
 | 
						|
			'accounts',
 | 
						|
			ids_out_of_date.length,
 | 
						|
			'out of date'
 | 
						|
		);
 | 
						|
		if (ids_out_of_date.length) {
 | 
						|
			try {
 | 
						|
				let rows = await tfrpc.rpc.query(
 | 
						|
					`
 | 
						|
						SELECT all_abouts.author, json(json_group_object(all_abouts.key, all_abouts.value)) AS about
 | 
						|
						FROM (
 | 
						|
							SELECT
 | 
						|
								messages.author,
 | 
						|
								fields.key,
 | 
						|
								RANK() OVER (PARTITION BY messages.author, fields.key ORDER BY messages.sequence DESC) AS rank,
 | 
						|
								fields.value
 | 
						|
							FROM messages JOIN json_each(messages.content) AS fields
 | 
						|
							WHERE
 | 
						|
								messages.content ->> '$.type' = 'about' AND
 | 
						|
								messages.content ->> '$.about' = messages.author AND
 | 
						|
								NOT fields.key IN ('about', 'type')) all_abouts
 | 
						|
						JOIN json_each(?) AS following ON all_abouts.author = following.value
 | 
						|
						WHERE rank = 1
 | 
						|
						GROUP BY all_abouts.author
 | 
						|
					`,
 | 
						|
					[JSON.stringify(ids_out_of_date)]
 | 
						|
				);
 | 
						|
				users = users || {};
 | 
						|
				for (let row of rows) {
 | 
						|
					users[row.author] = Object.assign(
 | 
						|
						users[row.author] || {},
 | 
						|
						JSON.parse(row.about)
 | 
						|
					);
 | 
						|
					cache.about[row.author] = Object.assign(
 | 
						|
						{seq: users[row.author].seq},
 | 
						|
						JSON.parse(row.about)
 | 
						|
					);
 | 
						|
				}
 | 
						|
			} catch (e) {
 | 
						|
				console.log(e);
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		for (let id of ids_out_of_date) {
 | 
						|
			if (!cache.about[id]?.seq) {
 | 
						|
				cache.about[id] = Object.assign(cache.about[id] ?? {}, {
 | 
						|
					seq: users[id]?.seq ?? 0,
 | 
						|
				});
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		this.loading_about--;
 | 
						|
 | 
						|
		let new_cache = JSON.stringify(cache);
 | 
						|
		if (new_cache != original_cache) {
 | 
						|
			let start_time = new Date();
 | 
						|
			tfrpc.rpc.databaseSet('about', new_cache).then(function () {
 | 
						|
				console.log('saving about took', (new Date() - start_time) / 1000);
 | 
						|
			});
 | 
						|
		}
 | 
						|
 | 
						|
		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 &&
 | 
						|
				JSON.parse(message.content)?.type == 'channel'
 | 
						|
			) {
 | 
						|
				this.load_channels();
 | 
						|
			}
 | 
						|
		}
 | 
						|
		this.schedule_load_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 get_latest_private(following) {
 | 
						|
		const k_version = 1;
 | 
						|
		// { "version": 1, "range": [1234, 5678], messages: [ "%1.sha256", "%2.sha256", ... ], latest: rowid }
 | 
						|
		let cache = JSON.parse(
 | 
						|
			(await tfrpc.rpc.databaseGet(`private:${this.whoami}`)) ?? '{}'
 | 
						|
		);
 | 
						|
		if (cache.version !== k_version) {
 | 
						|
			cache = {
 | 
						|
				version: k_version,
 | 
						|
				messages: [],
 | 
						|
				range: [],
 | 
						|
			};
 | 
						|
		}
 | 
						|
		let latest = (
 | 
						|
			await tfrpc.rpc.query('SELECT MAX(rowid) AS latest FROM messages')
 | 
						|
		)[0].latest;
 | 
						|
		let ranges = [];
 | 
						|
		const k_chunk_size = 512;
 | 
						|
		if (cache.range.length) {
 | 
						|
			for (let i = cache.range[1]; i < latest; i += k_chunk_size) {
 | 
						|
				ranges.push([i, Math.min(i + k_chunk_size, latest), true]);
 | 
						|
			}
 | 
						|
			for (let i = cache.range[0]; i >= 0; i -= k_chunk_size) {
 | 
						|
				ranges.push([Math.max(i - k_chunk_size, 0), i, false]);
 | 
						|
			}
 | 
						|
		} else {
 | 
						|
			for (let i = 0; i < latest; i += k_chunk_size) {
 | 
						|
				ranges.push([i, Math.min(i + k_chunk_size, latest), true]);
 | 
						|
			}
 | 
						|
		}
 | 
						|
		for (let range of ranges) {
 | 
						|
			let messages = await tfrpc.rpc.query(
 | 
						|
				`
 | 
						|
					SELECT messages.rowid, messages.id, json(content) AS content
 | 
						|
						FROM messages
 | 
						|
						WHERE
 | 
						|
							messages.rowid > ?1 AND
 | 
						|
							messages.rowid <= ?2 AND
 | 
						|
							json(messages.content) LIKE '"%'
 | 
						|
						ORDER BY messages.rowid DESC
 | 
						|
					`,
 | 
						|
				[range[0], range[1]]
 | 
						|
			);
 | 
						|
			messages = (await this.decrypt(messages)).filter((x) => x.decrypted);
 | 
						|
			if (messages.length) {
 | 
						|
				cache.latest = Math.max(
 | 
						|
					cache.latest ?? 0,
 | 
						|
					...messages.map((x) => x.rowid)
 | 
						|
				);
 | 
						|
				if (range[2]) {
 | 
						|
					cache.messages = [...cache.messages, ...messages.map((x) => x.id)];
 | 
						|
				} else {
 | 
						|
					cache.messages = [...messages.map((x) => x.id), ...cache.messages];
 | 
						|
				}
 | 
						|
			}
 | 
						|
			cache.range[0] = Math.min(cache.range[0] ?? range[0], range[0]);
 | 
						|
			cache.range[1] = Math.max(cache.range[1] ?? range[1], range[1]);
 | 
						|
			await tfrpc.rpc.databaseSet(
 | 
						|
				`private:${this.whoami}`,
 | 
						|
				JSON.stringify(cache)
 | 
						|
			);
 | 
						|
		}
 | 
						|
		return [cache.latest, cache.messages];
 | 
						|
	}
 | 
						|
 | 
						|
	async query_timed(sql, args) {
 | 
						|
		let start = new Date();
 | 
						|
		let result = await tfrpc.rpc.query(sql, args);
 | 
						|
		let end = new Date();
 | 
						|
		console.log((end - start) / 1000, sql.replaceAll(/\s+/g, ' ').trim());
 | 
						|
		return result;
 | 
						|
	}
 | 
						|
 | 
						|
	async group_private_messages(messages) {
 | 
						|
		let groups = {};
 | 
						|
		let result = await this.decrypt(
 | 
						|
			await tfrpc.rpc.query(
 | 
						|
				`
 | 
						|
				SELECT messages.rowid, messages.id, author, timestamp, json(content) AS content
 | 
						|
				FROM messages
 | 
						|
				JOIN json_each(?) AS ids
 | 
						|
				WHERE messages.id = ids.value
 | 
						|
				ORDER BY timestamp DESC
 | 
						|
			`,
 | 
						|
				[JSON.stringify(messages)]
 | 
						|
			)
 | 
						|
		);
 | 
						|
		for (let message of result) {
 | 
						|
			let key = JSON.stringify(
 | 
						|
				[
 | 
						|
					...new Set(
 | 
						|
						message?.decrypted?.recps?.filter((x) => x != this.whoami)
 | 
						|
					),
 | 
						|
				].sort() ?? []
 | 
						|
			);
 | 
						|
			if (!groups[key]) {
 | 
						|
				groups[key] = [];
 | 
						|
			}
 | 
						|
			groups[key].push(message);
 | 
						|
		}
 | 
						|
		return groups;
 | 
						|
	}
 | 
						|
 | 
						|
	async load_channels_latest(following) {
 | 
						|
		let start_time = new Date();
 | 
						|
		let latest_private = this.get_latest_private(following);
 | 
						|
		const k_args = [
 | 
						|
			JSON.stringify(this.channels),
 | 
						|
			JSON.stringify(following),
 | 
						|
			'"' + this.whoami.replace('"', '""') + '"',
 | 
						|
			this.whoami,
 | 
						|
		];
 | 
						|
		let channels = (
 | 
						|
			await Promise.all([
 | 
						|
				this.query_timed(
 | 
						|
					`
 | 
						|
					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 AND
 | 
						|
						messages.author != ?4
 | 
						|
					GROUP by channel
 | 
						|
				`,
 | 
						|
					k_args
 | 
						|
				),
 | 
						|
				this.query_timed(
 | 
						|
					`
 | 
						|
					SELECT channels.value AS channel, MAX(messages.rowid) AS rowid FROM messages
 | 
						|
					JOIN messages_refs ON messages.id = messages_refs.message
 | 
						|
					JOIN json_each(?1) AS channels ON messages_refs.ref = '#' || channels.value
 | 
						|
					JOIN json_each(?2) AS following ON messages.author = following.value
 | 
						|
					WHERE
 | 
						|
						messages.content ->> 'type' = 'post' AND
 | 
						|
						messages.content ->> 'root' IS NULL AND
 | 
						|
						messages.author != ?4
 | 
						|
					GROUP by channel
 | 
						|
				`,
 | 
						|
					k_args
 | 
						|
				),
 | 
						|
				this.query_timed(
 | 
						|
					`
 | 
						|
					SELECT '' AS channel, MAX(messages.rowid) AS rowid FROM messages
 | 
						|
					JOIN json_each(?2) AS following ON messages.author = following.value
 | 
						|
					WHERE
 | 
						|
						messages.content ->> 'type' = 'post' AND
 | 
						|
						messages.content ->> 'root' IS NULL AND
 | 
						|
						messages.author != ?4
 | 
						|
				`,
 | 
						|
					k_args
 | 
						|
				),
 | 
						|
				this.query_timed(
 | 
						|
					`
 | 
						|
					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
 | 
						|
					WHERE messages.author != ?4
 | 
						|
				`,
 | 
						|
					k_args
 | 
						|
				),
 | 
						|
			])
 | 
						|
		).flat();
 | 
						|
		let latest = {};
 | 
						|
		for (let row of channels) {
 | 
						|
			if (!latest[row.channel]) {
 | 
						|
				latest[row.channel] = row.rowid;
 | 
						|
			} else {
 | 
						|
				latest[row.channel] = Math.max(row.rowid, latest[row.channel]);
 | 
						|
			}
 | 
						|
		}
 | 
						|
		this.channels_latest = latest;
 | 
						|
		console.log('channels took', (new Date() - start_time) / 1000.0);
 | 
						|
		let self = this;
 | 
						|
		start_time = new Date();
 | 
						|
		latest_private.then(async function (latest) {
 | 
						|
			self.channels_latest = Object.assign({}, self.channels_latest, {
 | 
						|
				'🔐': latest[0],
 | 
						|
			});
 | 
						|
			console.log('private took', (new Date() - start_time) / 1000.0);
 | 
						|
			self.private_messages = latest[1];
 | 
						|
			self.grouped_private_messages = await self.group_private_messages(
 | 
						|
				latest[1]
 | 
						|
			);
 | 
						|
		});
 | 
						|
	}
 | 
						|
 | 
						|
	_schedule_load_latest_timer() {
 | 
						|
		--this.loading_latest_scheduled;
 | 
						|
		this.schedule_load_latest();
 | 
						|
	}
 | 
						|
 | 
						|
	reset_progress() {
 | 
						|
		if (this.progress === undefined) {
 | 
						|
			this._progress_start = new Date();
 | 
						|
			requestAnimationFrame(this.update_progress.bind(this));
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	update_progress() {
 | 
						|
		if (
 | 
						|
			!this.loading_latest &&
 | 
						|
			!this.loading_latest_scheduled &&
 | 
						|
			!this.shadowRoot.getElementById('tf-tab-news')?.is_loading()
 | 
						|
		) {
 | 
						|
			this.progress = undefined;
 | 
						|
			return;
 | 
						|
		}
 | 
						|
		this.progress = (new Date() - this._progress_start).valueOf();
 | 
						|
		requestAnimationFrame(this.update_progress.bind(this));
 | 
						|
	}
 | 
						|
 | 
						|
	schedule_load_latest() {
 | 
						|
		this.reset_progress();
 | 
						|
		if (!this.loading_latest) {
 | 
						|
			this.shadowRoot.getElementById('tf-tab-news')?.load_latest();
 | 
						|
			this.load();
 | 
						|
		} else if (!this.loading_latest_scheduled) {
 | 
						|
			this.loading_latest_scheduled++;
 | 
						|
			setTimeout(this._schedule_load_latest_timer.bind(this), 5000);
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	async fetch_user_info(users) {
 | 
						|
		let info = await tfrpc.rpc.query(
 | 
						|
			`
 | 
						|
				SELECT messages_stats.author, messages_stats.max_sequence, messages_stats.max_timestamp AS max_ts FROM messages_stats
 | 
						|
				JOIN json_each(?) AS following
 | 
						|
				ON messages_stats.author = following.value
 | 
						|
			`,
 | 
						|
			[JSON.stringify(Object.keys(users))]
 | 
						|
		);
 | 
						|
		for (let row of info) {
 | 
						|
			users[row.author] = Object.assign(users[row.author], {
 | 
						|
				seq: row.max_sequence,
 | 
						|
				ts: row.max_ts,
 | 
						|
			});
 | 
						|
		}
 | 
						|
		return users;
 | 
						|
	}
 | 
						|
 | 
						|
	async load_recent_reactions() {
 | 
						|
		this.recent_reactions = (
 | 
						|
			await tfrpc.rpc.query(
 | 
						|
				`
 | 
						|
			SELECT DISTINCT content ->> '$.vote.expression' AS value
 | 
						|
			FROM messages
 | 
						|
			WHERE author = ? AND
 | 
						|
			content ->> '$.type' = 'vote'
 | 
						|
			ORDER BY timestamp DESC LIMIT 10
 | 
						|
		`,
 | 
						|
				[this.whoami]
 | 
						|
			)
 | 
						|
		).map((x) => x.value);
 | 
						|
	}
 | 
						|
 | 
						|
	async load() {
 | 
						|
		this.loading_latest = true;
 | 
						|
		this.reset_progress();
 | 
						|
		try {
 | 
						|
			let start_time = new Date();
 | 
						|
			let whoami = this.whoami;
 | 
						|
			let following = await tfrpc.rpc.following([whoami], 2);
 | 
						|
			let old_users = this.users ?? {};
 | 
						|
			let users = {};
 | 
						|
			let by_count = [];
 | 
						|
			for (let [id, v] of Object.entries(following)) {
 | 
						|
				users[id] = Object.assign(
 | 
						|
					{
 | 
						|
						following: v.of,
 | 
						|
						blocking: v.ob,
 | 
						|
						followed: v.if,
 | 
						|
						blocked: v.ib,
 | 
						|
						follow_depth: following[id]?.d,
 | 
						|
					},
 | 
						|
					old_users[id]
 | 
						|
				);
 | 
						|
				by_count.push({count: v.of, id: id});
 | 
						|
			}
 | 
						|
			let reactions = this.load_recent_reactions();
 | 
						|
			this.load_channels_latest(Object.keys(following));
 | 
						|
			this.channels_unread = JSON.parse(
 | 
						|
				(await tfrpc.rpc.databaseGet('unread')) ?? '{}'
 | 
						|
			);
 | 
						|
			this.following = Object.keys(following);
 | 
						|
			let about_start_time = new Date();
 | 
						|
			start_time = new Date();
 | 
						|
			users = await this.fetch_user_info(users);
 | 
						|
			console.log(
 | 
						|
				'user info took',
 | 
						|
				(new Date() - start_time) / 1000.0,
 | 
						|
				'seconds'
 | 
						|
			);
 | 
						|
			this.users = users;
 | 
						|
 | 
						|
			let self = this;
 | 
						|
			this.fetch_about(following, users).then(function (result) {
 | 
						|
				self.users = result;
 | 
						|
				console.log(
 | 
						|
					'about took',
 | 
						|
					(new Date() - about_start_time) / 1000.0,
 | 
						|
					'seconds for',
 | 
						|
					Object.keys(users).length,
 | 
						|
					'users'
 | 
						|
				);
 | 
						|
			});
 | 
						|
			console.log(
 | 
						|
				`load finished ${whoami} => ${this.whoami} in ${(new Date() - start_time) / 1000}`
 | 
						|
			);
 | 
						|
			await reactions;
 | 
						|
			this.whoami = whoami;
 | 
						|
			this.loaded = whoami;
 | 
						|
		} finally {
 | 
						|
			this.loading_latest = false;
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	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`
 | 
						|
				<tf-tab-news
 | 
						|
					id="tf-tab-news"
 | 
						|
					.following=${this.following}
 | 
						|
					whoami=${this.whoami}
 | 
						|
					.users=${this.users}
 | 
						|
					hash=${this.hash}
 | 
						|
					?loading=${this.loading || this.loading_about != 0}
 | 
						|
					.channels=${this.channels}
 | 
						|
					.channels_latest=${this.channels_latest}
 | 
						|
					.channels_unread=${this.channels_unread}
 | 
						|
					@channelsetunread=${this.channel_set_unread}
 | 
						|
					@refresh=${this.refresh}
 | 
						|
					@toggle_stay_connected=${this.toggle_stay_connected}
 | 
						|
					@loadmessages=${this.reset_progress}
 | 
						|
					@closeprivatechat=${this.close_private_chat}
 | 
						|
					.connections=${this.connections}
 | 
						|
					.private_messages=${this.private_messages}
 | 
						|
					.visible_private_messages=${this.visible_private()}
 | 
						|
					.grouped_private_messages=${this.grouped_private_messages}
 | 
						|
					.recent_reactions=${this.recent_reactions}
 | 
						|
					?is_administrator=${this.is_administrator}
 | 
						|
					?stay_connected=${this.stay_connected}
 | 
						|
				></tf-tab-news>
 | 
						|
			`;
 | 
						|
		} else if (this.tab === 'connections') {
 | 
						|
			return html`
 | 
						|
				<tf-tab-connections
 | 
						|
					.users=${this.users}
 | 
						|
					.connections=${this.connections}
 | 
						|
					.broadcasts=${this.broadcasts}
 | 
						|
				></tf-tab-connections>
 | 
						|
			`;
 | 
						|
		} else if (this.tab === 'search') {
 | 
						|
			return html`
 | 
						|
				<tf-tab-search
 | 
						|
					.following=${this.following}
 | 
						|
					whoami=${this.whoami}
 | 
						|
					.users=${this.users}
 | 
						|
					query=${this.hash?.startsWith('#q=')
 | 
						|
						? decodeURIComponent(this.hash.substring(3))
 | 
						|
						: null}
 | 
						|
				></tf-tab-search>
 | 
						|
			`;
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	async set_tab(tab) {
 | 
						|
		this.tab = tab;
 | 
						|
		if (tab === 'news') {
 | 
						|
			this.schedule_load_latest();
 | 
						|
			await tfrpc.rpc.setHash('#');
 | 
						|
		} else if (tab === 'connections') {
 | 
						|
			await tfrpc.rpc.setHash('#connections');
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	refresh() {
 | 
						|
		tfrpc.rpc.sync();
 | 
						|
	}
 | 
						|
 | 
						|
	async toggle_stay_connected() {
 | 
						|
		let stay_connected = await tfrpc.rpc.globalSettingsGet('stay_connected');
 | 
						|
		let new_stay_connected = !this.stay_connected;
 | 
						|
		try {
 | 
						|
			if (new_stay_connected != stay_connected) {
 | 
						|
				await tfrpc.rpc.globalSettingsSet('stay_connected', new_stay_connected);
 | 
						|
			}
 | 
						|
		} finally {
 | 
						|
			this.stay_connected = await tfrpc.rpc.globalSettingsGet('stay_connected');
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	async pick_color() {
 | 
						|
		let input = document.createElement('input');
 | 
						|
		input.type = 'color';
 | 
						|
		input.value = (await tfrpc.rpc.localStorageGet('color')) ?? '#ff0000';
 | 
						|
		input.addEventListener('change', async function () {
 | 
						|
			await tfrpc.rpc.localStorageSet('color', input.value);
 | 
						|
			window.location.reload();
 | 
						|
		});
 | 
						|
		input.click();
 | 
						|
	}
 | 
						|
 | 
						|
	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',
 | 
						|
		};
 | 
						|
 | 
						|
		let tabs = html`
 | 
						|
			<div
 | 
						|
				class="w3-bar w3-theme-l1"
 | 
						|
				style="position: static; top: 0; z-index: 10"
 | 
						|
			>
 | 
						|
				${this.is_administrator
 | 
						|
					? html`
 | 
						|
							<button
 | 
						|
								class=${'w3-bar-item w3-button w3-circle w3-ripple' +
 | 
						|
								(this.connections?.some((x) => x.flags.one_shot)
 | 
						|
									? ' w3-spin'
 | 
						|
									: '')}
 | 
						|
								@click=${this.refresh}
 | 
						|
							>
 | 
						|
								↻
 | 
						|
							</button>
 | 
						|
							<button
 | 
						|
								class="w3-bar-item w3-button w3-ripple"
 | 
						|
								@click=${this.toggle_stay_connected}
 | 
						|
							>
 | 
						|
								${this.stay_connected ? '🔗' : '⛓️💥'}
 | 
						|
							</button>
 | 
						|
						`
 | 
						|
					: undefined}
 | 
						|
				${Object.entries(k_tabs).map(
 | 
						|
					([k, v]) => html`
 | 
						|
						<button
 | 
						|
							title=${v}
 | 
						|
							class="w3-bar-item w3-padding w3-hover-theme tab ${self.tab == v
 | 
						|
								? 'w3-theme-l2'
 | 
						|
								: 'w3-theme-l1'}"
 | 
						|
							@click=${() => self.set_tab(v)}
 | 
						|
						>
 | 
						|
							${k}
 | 
						|
							<span class=${self.tab == v ? '' : 'w3-hide-small'}
 | 
						|
								>${v.charAt(0).toUpperCase() + v.substring(1)}</span
 | 
						|
							>
 | 
						|
						</button>
 | 
						|
					`
 | 
						|
				)}
 | 
						|
				<button
 | 
						|
					class="w3-bar-item w3-button w3-right"
 | 
						|
					@click=${this.pick_color}
 | 
						|
				>
 | 
						|
					🎨<span class="w3-hide-small">Color</span>
 | 
						|
				</button>
 | 
						|
			</div>
 | 
						|
		`;
 | 
						|
		let contents = this.guest
 | 
						|
			? html`<div
 | 
						|
					class="w3-display-middle w3-panel w3-theme-l5 w3-card-4 w3-padding-large w3-round-xlarge w3-xlarge w3-container"
 | 
						|
				>
 | 
						|
					<p>⚠️🦀 Must be logged in to Tilde Friends to scuttle here. 🦀⚠️</p>
 | 
						|
					<footer class="w3-center">
 | 
						|
						<a
 | 
						|
							class="w3-button w3-theme-d1"
 | 
						|
							href=${`/login?return=${encodeURIComponent(this.url)}`}
 | 
						|
							>Login</a
 | 
						|
						>
 | 
						|
					</footer>
 | 
						|
				</div>`
 | 
						|
			: !this.loaded || this.loading
 | 
						|
				? html`<div
 | 
						|
						class="w3-display-middle w3-panel w3-theme-l5 w3-card-4 w3-padding-large w3-round-xlarge w3-xlarge"
 | 
						|
					>
 | 
						|
						<span class="w3-spin" style="display: inline-block">🦀</span>
 | 
						|
						Loading...
 | 
						|
					</div>`
 | 
						|
				: this.render_tab();
 | 
						|
		let progress =
 | 
						|
			this.progress !== undefined
 | 
						|
				? html`
 | 
						|
						<div style="position: absolute; width: 100%" id="progress">
 | 
						|
							<div
 | 
						|
								class="w3-theme-l3"
 | 
						|
								style=${`height: 4px; position: absolute; right: ${Math.cos(this.progress / 250) > 0 ? 'auto' : '0'}; width: ${50 * Math.sin(this.progress / 250) + 50}%`}
 | 
						|
							></div>
 | 
						|
						</div>
 | 
						|
					`
 | 
						|
				: undefined;
 | 
						|
		return html`
 | 
						|
			<style>
 | 
						|
				${generate_theme()}
 | 
						|
			</style>
 | 
						|
			<div
 | 
						|
				style="width: 100vw; min-height: 100vh; height: 100vh; display: flex; flex-direction: column"
 | 
						|
				class="w3-theme-dark"
 | 
						|
			>
 | 
						|
				${progress}
 | 
						|
				<div style="flex: 0 0">${tabs}</div>
 | 
						|
				<div style="flex: 1 1; overflow: auto; contain: layout">
 | 
						|
					${contents}
 | 
						|
				</div>
 | 
						|
			</div>
 | 
						|
		`;
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
customElements.define('tf-app', TfElement);
 |