forked from cory/tildefriends
		
	
		
			
				
	
	
		
			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);
 |