Run prettier.
This commit is contained in:
		| @@ -3,8 +3,3 @@ useTabs: true | |||||||
| semi: true | semi: true | ||||||
| singleQuote: true | singleQuote: true | ||||||
| bracketSpacing: false | bracketSpacing: false | ||||||
| # overrides: |  | ||||||
| #   - files: '**/*.json' |  | ||||||
| #     options: |  | ||||||
| #       useTabs: false |  | ||||||
| #       tabWidth: 2 |  | ||||||
|   | |||||||
							
								
								
									
										20
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										20
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,4 +1,5 @@ | |||||||
| # Tilde Friends | # Tilde Friends | ||||||
|  |  | ||||||
| Tilde Friends is a tool for making and sharing. | Tilde Friends is a tool for making and sharing. | ||||||
|  |  | ||||||
| A public instance lives at https://www.tildefriends.net/. | A public instance lives at https://www.tildefriends.net/. | ||||||
| @@ -7,37 +8,42 @@ It is both a peer-to-peer social network client, participating in Secure | |||||||
| Scuttlebutt, as well as a platform for writing and running web applications. | Scuttlebutt, as well as a platform for writing and running web applications. | ||||||
|  |  | ||||||
| ## Goals | ## Goals | ||||||
|  |  | ||||||
| 1. Make it easy and fun to run all sorts of web applications. | 1. Make it easy and fun to run all sorts of web applications. | ||||||
| 2. Provide security that is easy to understand and protects your data. | 2. Provide security that is easy to understand and protects your data. | ||||||
| 3. Make creating and sharing web applications accessible to anyone with a | 3. Make creating and sharing web applications accessible to anyone with a | ||||||
|    browser. |    browser. | ||||||
|  |  | ||||||
| ## Building | ## Building | ||||||
| Builds on Linux (x86_64 and aarch64), MacOS, OpenBSD, and Haiku.  Builds for |  | ||||||
|  | Builds on Linux (x86_64 and aarch64), MacOS, OpenBSD, and Haiku. Builds for | ||||||
| all of those host platforms plus mingw64, iOS, and android. | all of those host platforms plus mingw64, iOS, and android. | ||||||
|  |  | ||||||
| 1. Requires openssl (`libssl-dev`, in debian-speak).  All other dependencies | 1. Requires openssl (`libssl-dev`, in debian-speak). All other dependencies | ||||||
|    are kept up to date in the tree. |    are kept up to date in the tree. | ||||||
| 2. To build, run `make debug` or `make release`.  An executable will be | 2. To build, run `make debug` or `make release`. An executable will be | ||||||
|    generated in a subdirectory of `out/`. |    generated in a subdirectory of `out/`. | ||||||
| 3. It's possible to build for Android, iOS, and Windows on Linux, if you have | 3. It's possible to build for Android, iOS, and Windows on Linux, if you have | ||||||
|    the right dependencies in the right places.  `make windebug winrelease |    the right dependencies in the right places. `make windebug winrelease | ||||||
|    iosdebug-ipa iosrelease-ipa release-apk`. | iosdebug-ipa iosrelease-ipa release-apk`. | ||||||
| 4. To build in docker, `docker build .`. | 4. To build in docker, `docker build .`. | ||||||
| 5. `make format` will normalize formatting to the coding standard. | 5. `make format` will normalize formatting to the coding standard. | ||||||
|  |  | ||||||
| ## Running | ## Running | ||||||
|  |  | ||||||
| By default, running the built `tildefriends` executable will start a web server | By default, running the built `tildefriends` executable will start a web server | ||||||
| at <http://localhost:12345/>.  `tildefriends -h` lists further options. | at <http://localhost:12345/>. `tildefriends -h` lists further options. | ||||||
|  |  | ||||||
| The first user to create an account and log in will be granted administrative | The first user to create an account and log in will be granted administrative | ||||||
| privileges.  Further administration can be done at | privileges. Further administration can be done at | ||||||
| <http://localhost:12345/~core/admin/>. | <http://localhost:12345/~core/admin/>. | ||||||
|  |  | ||||||
| ## Documentation | ## Documentation | ||||||
|  |  | ||||||
| Docs are a work in progress: | Docs are a work in progress: | ||||||
| <https://www.tildefriends.net/~cory/wiki/#test-wiki/tf-app-quick-reference>. | <https://www.tildefriends.net/~cory/wiki/#test-wiki/tf-app-quick-reference>. | ||||||
|  |  | ||||||
| ## License | ## License | ||||||
|  |  | ||||||
| All code unless otherwise noted in is provided under the | All code unless otherwise noted in is provided under the | ||||||
| [MIT](https://opensource.org/licenses/MIT) license. | [MIT](https://opensource.org/licenses/MIT) license. | ||||||
|   | |||||||
| @@ -18,9 +18,13 @@ async function main() { | |||||||
| 		for (let user of await core.users()) { | 		for (let user of await core.users()) { | ||||||
| 			data.users[user] = await core.permissionsForUser(user); | 			data.users[user] = await core.permissionsForUser(user); | ||||||
| 		} | 		} | ||||||
| 		await app.setDocument(utf8Decode(getFile('index.html')).replace('$data', JSON.stringify(data))); | 		await app.setDocument( | ||||||
|  | 			utf8Decode(getFile('index.html')).replace('$data', JSON.stringify(data)) | ||||||
|  | 		); | ||||||
| 	} catch { | 	} catch { | ||||||
| 		await app.setDocument('<span style="color: #f00">Only an administrator can modify these settings.</span>'); | 		await app.setDocument( | ||||||
|  | 			'<span style="color: #f00">Only an administrator can modify these settings.</span>' | ||||||
|  | 		); | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| main(); | main(); | ||||||
| @@ -1,7 +1,9 @@ | |||||||
| <!DOCTYPE html> | <!doctype html> | ||||||
| <html style="width: 100%"> | <html style="width: 100%"> | ||||||
| 	<head> | 	<head> | ||||||
| 		<script>const g_data = $data;</script> | 		<script> | ||||||
|  | 			const g_data = $data; | ||||||
|  | 		</script> | ||||||
| 	</head> | 	</head> | ||||||
| 	<body style="color: #fff; width: 100%"> | 	<body style="color: #fff; width: 100%"> | ||||||
| 		<h1>Tilde Friends Administration</h1> | 		<h1>Tilde Friends Administration</h1> | ||||||
|   | |||||||
| @@ -3,25 +3,32 @@ import * as tfrpc from '/static/tfrpc.js'; | |||||||
|  |  | ||||||
| function delete_user(user) { | function delete_user(user) { | ||||||
| 	if (confirm(`Are you sure you want to delete the user "${user}"?`)) { | 	if (confirm(`Are you sure you want to delete the user "${user}"?`)) { | ||||||
| 		tfrpc.rpc.delete_user(user).then(function() { | 		tfrpc.rpc | ||||||
| 			alert(`User "${user}" deleted successfully.`); | 			.delete_user(user) | ||||||
| 		}).catch(function(error) { | 			.then(function () { | ||||||
| 			alert(`Failed to delete user "${user}": ${JSON.stringify(error, null, 2)}.`); | 				alert(`User "${user}" deleted successfully.`); | ||||||
| 		}); | 			}) | ||||||
|  | 			.catch(function (error) { | ||||||
|  | 				alert( | ||||||
|  | 					`Failed to delete user "${user}": ${JSON.stringify(error, null, 2)}.` | ||||||
|  | 				); | ||||||
|  | 			}); | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| function global_settings_set(key, value) { | function global_settings_set(key, value) { | ||||||
| 	tfrpc.rpc.global_settings_set(key, value).then(function() { | 	tfrpc.rpc | ||||||
| 		alert(`Set "${key}" to "${value}".`); | 		.global_settings_set(key, value) | ||||||
| 	}).catch(function(error) { | 		.then(function () { | ||||||
| 		alert(`Failed to set "${key}": ${JSON.stringify(error, null, 2)}.`); | 			alert(`Set "${key}" to "${value}".`); | ||||||
| 	}); | 		}) | ||||||
|  | 		.catch(function (error) { | ||||||
|  | 			alert(`Failed to set "${key}": ${JSON.stringify(error, null, 2)}.`); | ||||||
|  | 		}); | ||||||
| } | } | ||||||
|  |  | ||||||
| window.addEventListener('load', function() { | window.addEventListener('load', function () { | ||||||
| 	const permission_template = (permission) => | 	const permission_template = (permission) => html` <code>${permission}</code>`; | ||||||
| 		html` <code>${permission}</code>`; |  | ||||||
| 	function input_template(key, description) { | 	function input_template(key, description) { | ||||||
| 		if (description.type === 'boolean') { | 		if (description.type === 'boolean') { | ||||||
| 			return html` | 			return html` | ||||||
| @@ -62,26 +69,24 @@ window.addEventListener('load', function() { | |||||||
| 	} | 	} | ||||||
| 	const user_template = (user, permissions) => html` | 	const user_template = (user, permissions) => html` | ||||||
| 		<li> | 		<li> | ||||||
| 			<button @click=${(e) => delete_user(user)}> | 			<button @click=${(e) => delete_user(user)}>Delete</button> | ||||||
| 				Delete | 			${user}: ${permissions.map((x) => permission_template(x))} | ||||||
| 			</button> |  | ||||||
| 			${user}: |  | ||||||
| 			${permissions.map(x => permission_template(x))} |  | ||||||
| 		</li> | 		</li> | ||||||
| 	`; | 	`; | ||||||
| 	const users_template = (users) => | 	const users_template = (users) => | ||||||
| 		html`<h2>Users</h2> | 		html`<h2>Users</h2> | ||||||
| 		<ul> | 			<ul> | ||||||
| 			${Object.entries(users).map(u => user_template(u[0], u[1]))} | 				${Object.entries(users).map((u) => user_template(u[0], u[1]))} | ||||||
| 		</ul>`; | 			</ul>`; | ||||||
| 	const page_template = (data) => | 	const page_template = (data) => | ||||||
| 		html`<div style="padding: 0; margin: 0; width: 100%; max-width: 100%"> | 		html`<div style="padding: 0; margin: 0; width: 100%; max-width: 100%"> | ||||||
| 			<h2>Global Settings</h2> | 			<h2>Global Settings</h2> | ||||||
| 			<div> | 			<div> | ||||||
| 			${Object.keys(data.settings).sort().map(x => html`${input_template(x, data.settings[x])}`)} | 				${Object.keys(data.settings) | ||||||
|  | 					.sort() | ||||||
|  | 					.map((x) => html`${input_template(x, data.settings[x])}`)} | ||||||
| 			</div> | 			</div> | ||||||
| 			${users_template(data.users)} | 			${users_template(data.users)} | ||||||
| 		</div> | 		</div> `; | ||||||
| 		`; |  | ||||||
| 	render(page_template(g_data), document.body); | 	render(page_template(g_data), document.body); | ||||||
| }); | }); | ||||||
| @@ -219,7 +219,7 @@ Parses an HTTP response. | |||||||
|  * *Object* An object with **bytes_parsed**, **minor_version**, **status**, **message**, and **headers** fields on successful parse. |  * *Object* An object with **bytes_parsed**, **minor_version**, **status**, **message**, and **headers** fields on successful parse. | ||||||
| `; | `; | ||||||
|  |  | ||||||
| docs['sha1Digest()'] =` | docs['sha1Digest()'] = ` | ||||||
| Calculates a SHA1 digest. | Calculates a SHA1 digest. | ||||||
|  |  | ||||||
| Completes synchronously. | Completes synchronously. | ||||||
|   | |||||||
| @@ -26,14 +26,15 @@ async function fetch_info(apps) { | |||||||
| async function fetch_shared_apps() { | async function fetch_shared_apps() { | ||||||
| 	let messages = {}; | 	let messages = {}; | ||||||
|  |  | ||||||
| 	await ssb.sqlAsync(` | 	await ssb.sqlAsync( | ||||||
|  | 		` | ||||||
| 				SELECT messages.* | 				SELECT messages.* | ||||||
| 				FROM messages_fts('"application/tildefriends"') | 				FROM messages_fts('"application/tildefriends"') | ||||||
| 				JOIN messages ON messages.rowid = messages_fts.rowid | 				JOIN messages ON messages.rowid = messages_fts.rowid | ||||||
| 				ORDER BY timestamp | 				ORDER BY timestamp | ||||||
| 		`, | 		`, | ||||||
| 		[], | 		[], | ||||||
| 		function(row) { | 		function (row) { | ||||||
| 			let content = JSON.parse(row.content); | 			let content = JSON.parse(row.content); | ||||||
| 			for (let mention of content.mentions) { | 			for (let mention of content.mentions) { | ||||||
| 				if (mention?.type === 'application/tildefriends') { | 				if (mention?.type === 'application/tildefriends') { | ||||||
| @@ -44,10 +45,13 @@ async function fetch_shared_apps() { | |||||||
| 					}; | 					}; | ||||||
| 				} | 				} | ||||||
| 			} | 			} | ||||||
| 		}); | 		} | ||||||
|  | 	); | ||||||
|  |  | ||||||
| 	let result = {}; | 	let result = {}; | ||||||
| 	for (let app of Object.values(messages).sort((x, y) => y.message.timestamp - x.message.timestamp)) { | 	for (let app of Object.values(messages).sort( | ||||||
|  | 		(x, y) => y.message.timestamp - x.message.timestamp | ||||||
|  | 	)) { | ||||||
| 		let app_object = JSON.parse(utf8Decode(await ssb.blobGet(app.blob))); | 		let app_object = JSON.parse(utf8Decode(await ssb.blobGet(app.blob))); | ||||||
| 		if (app_object) { | 		if (app_object) { | ||||||
| 			app_object.blob_id = app.blob; | 			app_object.blob_id = app.blob; | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| { | { | ||||||
|   "type": "tildefriends-app", | 	"type": "tildefriends-app", | ||||||
|   "emoji": "🪵", | 	"emoji": "🪵", | ||||||
|   "previous": "&TIrBnpN3iz3O9L9MCCteAcVJZjA83EKdcfu4SCM76VE=.sha256" | 	"previous": "&TIrBnpN3iz3O9L9MCCteAcVJZjA83EKdcfu4SCM76VE=.sha256" | ||||||
| } | } | ||||||
| @@ -1,11 +1,19 @@ | |||||||
| import * as commonmark from './commonmark.min.js'; | import * as commonmark from './commonmark.min.js'; | ||||||
|  |  | ||||||
| function escape(text) { | function escape(text) { | ||||||
| 	return (text ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); | 	return (text ?? '') | ||||||
|  | 		.replaceAll('&', '&') | ||||||
|  | 		.replaceAll('<', '<') | ||||||
|  | 		.replaceAll('>', '>'); | ||||||
| } | } | ||||||
|  |  | ||||||
| function escapeAttribute(text) { | function escapeAttribute(text) { | ||||||
| 	return (text ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); | 	return (text ?? '') | ||||||
|  | 		.replaceAll('&', '&') | ||||||
|  | 		.replaceAll('<', '<') | ||||||
|  | 		.replaceAll('>', '>') | ||||||
|  | 		.replaceAll('"', '"') | ||||||
|  | 		.replaceAll("'", '''); | ||||||
| } | } | ||||||
|  |  | ||||||
| export async function get_blog_message(id) { | export async function get_blog_message(id) { | ||||||
| @@ -13,7 +21,7 @@ export async function get_blog_message(id) { | |||||||
| 	await ssb.sqlAsync( | 	await ssb.sqlAsync( | ||||||
| 		'SELECT author, timestamp, content FROM messages WHERE id = ?', | 		'SELECT author, timestamp, content FROM messages WHERE id = ?', | ||||||
| 		[id], | 		[id], | ||||||
| 		function(row) { | 		function (row) { | ||||||
| 			let content = JSON.parse(row.content); | 			let content = JSON.parse(row.content); | ||||||
| 			message = { | 			message = { | ||||||
| 				author: row.author, | 				author: row.author, | ||||||
| @@ -21,7 +29,8 @@ export async function get_blog_message(id) { | |||||||
| 				blog: content?.blog, | 				blog: content?.blog, | ||||||
| 				title: content?.title, | 				title: content?.title, | ||||||
| 			}; | 			}; | ||||||
| 		}); | 		} | ||||||
|  | 	); | ||||||
| 	if (message) { | 	if (message) { | ||||||
| 		await ssb.sqlAsync( | 		await ssb.sqlAsync( | ||||||
| 			` | 			` | ||||||
| @@ -34,9 +43,10 @@ export async function get_blog_message(id) { | |||||||
| 				ORDER BY sequence DESC LIMIT 1 | 				ORDER BY sequence DESC LIMIT 1 | ||||||
| 			`, | 			`, | ||||||
| 			[message.author], | 			[message.author], | ||||||
| 			function(row) { | 			function (row) { | ||||||
| 				message.name = row.name; | 				message.name = row.name; | ||||||
| 			}); | 			} | ||||||
|  | 		); | ||||||
| 	} | 	} | ||||||
| 	return message; | 	return message; | ||||||
| } | } | ||||||
| @@ -51,8 +61,12 @@ export function markdown(md) { | |||||||
| 		node = event.node; | 		node = event.node; | ||||||
| 		if (event.entering) { | 		if (event.entering) { | ||||||
| 			if (node.destination?.startsWith('&')) { | 			if (node.destination?.startsWith('&')) { | ||||||
| 				node.destination = '/' + node.destination + '/view?filename=' + node.firstChild?.literal; | 				node.destination = | ||||||
| 			} else if (node.destination?.startsWith('@') || node.destination?.startsWith('%')) { | 					'/' + node.destination + '/view?filename=' + node.firstChild?.literal; | ||||||
|  | 			} else if ( | ||||||
|  | 				node.destination?.startsWith('@') || | ||||||
|  | 				node.destination?.startsWith('%') | ||||||
|  | 			) { | ||||||
| 				node.destination = '/~core/ssb/#' + escape(node.destination); | 				node.destination = '/~core/ssb/#' + escape(node.destination); | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| @@ -107,7 +121,7 @@ export function render_html(blogs) { | |||||||
| 					<h1>🪵Tilde Friends Blog</h1> | 					<h1>🪵Tilde Friends Blog</h1> | ||||||
| 					<div style="font-size: xx-small; vertical-align: middle"><a href="/~cory/blog/atom">atom feed</a></div> | 					<div style="font-size: xx-small; vertical-align: middle"><a href="/~cory/blog/atom">atom feed</a></div> | ||||||
| 				</div> | 				</div> | ||||||
| 				${blogs.map(blog_post => render_blog_post(blog_post)).join('\n')} | 				${blogs.map((blog_post) => render_blog_post(blog_post)).join('\n')} | ||||||
| 			</body> | 			</body> | ||||||
| 		</html>`; | 		</html>`; | ||||||
| } | } | ||||||
| @@ -135,14 +149,15 @@ export function render_atom(blogs) { | |||||||
| 	<link href="${core.url}"/> | 	<link href="${core.url}"/> | ||||||
| 	<id>${core.url}</id> | 	<id>${core.url}</id> | ||||||
| 	<updated>${new Date().toString()}</updated> | 	<updated>${new Date().toString()}</updated> | ||||||
| 	${blogs.map(blog_post => render_blog_post_atom(blog_post)).join('\n')} | 	${blogs.map((blog_post) => render_blog_post_atom(blog_post)).join('\n')} | ||||||
| </feed>`; | </feed>`; | ||||||
| } | } | ||||||
|  |  | ||||||
| export async function get_posts() { | export async function get_posts() { | ||||||
| 	let blogs = []; | 	let blogs = []; | ||||||
| 	let ids = await ssb.getIdentities(); | 	let ids = await ssb.getIdentities(); | ||||||
| 	await ssb.sqlAsync(` | 	await ssb.sqlAsync( | ||||||
|  | 		` | ||||||
| 		WITH | 		WITH | ||||||
| 			blogs AS ( | 			blogs AS ( | ||||||
| 				SELECT | 				SELECT | ||||||
| @@ -182,8 +197,11 @@ export async function get_posts() { | |||||||
| 		JOIN public ON public.author = blogs.author | 		JOIN public ON public.author = blogs.author | ||||||
| 		LEFT OUTER JOIN names ON names.author = blogs.author | 		LEFT OUTER JOIN names ON names.author = blogs.author | ||||||
| 		ORDER BY blogs.timestamp DESC LIMIT 20 | 		ORDER BY blogs.timestamp DESC LIMIT 20 | ||||||
| 	`, [JSON.stringify(ids)], function(row) { | 	`, | ||||||
| 		blogs.push(row); | 		[JSON.stringify(ids)], | ||||||
| 	}); | 		function (row) { | ||||||
|  | 			blogs.push(row); | ||||||
|  | 		} | ||||||
|  | 	); | ||||||
| 	return blogs; | 	return blogs; | ||||||
| } | } | ||||||
| @@ -2,30 +2,50 @@ import * as blog from './blog.js'; | |||||||
|  |  | ||||||
| async function main() { | async function main() { | ||||||
| 	if (request.path.startsWith('%') && request.path.endsWith('.sha256')) { | 	if (request.path.startsWith('%') && request.path.endsWith('.sha256')) { | ||||||
| 		let id = request.path.startsWith('%25') ? '%' + request.path.substring(3) : request.path; | 		let id = request.path.startsWith('%25') | ||||||
|  | 			? '%' + request.path.substring(3) | ||||||
|  | 			: request.path; | ||||||
| 		let message = await blog.get_blog_message(id); | 		let message = await blog.get_blog_message(id); | ||||||
| 		if (message) { | 		if (message) { | ||||||
| 			respond({data: await blog.render_blog_post_html(message), content_type: 'text/html; charset=utf-8'}); | 			respond({ | ||||||
|  | 				data: await blog.render_blog_post_html(message), | ||||||
|  | 				content_type: 'text/html; charset=utf-8', | ||||||
|  | 			}); | ||||||
| 		} else { | 		} else { | ||||||
| 			respond({data: `Message ${id} not found.`, content_type: 'text/html; charset=utf-8'}); | 			respond({ | ||||||
|  | 				data: `Message ${id} not found.`, | ||||||
|  | 				content_type: 'text/html; charset=utf-8', | ||||||
|  | 			}); | ||||||
| 		} | 		} | ||||||
| 	} else if (request.path == 'atom') { | 	} else if (request.path == 'atom') { | ||||||
| 		let blogs = await blog.get_posts(); | 		let blogs = await blog.get_posts(); | ||||||
| 		respond({data: blog.render_atom(blogs), content_type: 'application/atom+xml'}); | 		respond({ | ||||||
|  | 			data: blog.render_atom(blogs), | ||||||
|  | 			content_type: 'application/atom+xml', | ||||||
|  | 		}); | ||||||
| 	} else { | 	} else { | ||||||
| 		let blogs = await blog.get_posts(); | 		let blogs = await blog.get_posts(); | ||||||
| 		for (let blog_post of blogs) { | 		for (let blog_post of blogs) { | ||||||
| 			let title = (blog_post.title || '').replaceAll(/\W/g, '_').toLowerCase(); | 			let title = (blog_post.title || '').replaceAll(/\W/g, '_').toLowerCase(); | ||||||
| 			if (request.path === title) { | 			if (request.path === title) { | ||||||
| 				respond({data: await blog.render_blog_post_html(blog_post), content_type: 'text/html; charset=utf-8'}); | 				respond({ | ||||||
|  | 					data: await blog.render_blog_post_html(blog_post), | ||||||
|  | 					content_type: 'text/html; charset=utf-8', | ||||||
|  | 				}); | ||||||
| 				return; | 				return; | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 		respond({data: blog.render_html(blogs), content_type: 'text/html; charset=utf-8'}); | 		respond({ | ||||||
|  | 			data: blog.render_html(blogs), | ||||||
|  | 			content_type: 'text/html; charset=utf-8', | ||||||
|  | 		}); | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| main().catch(function(error) { | main().catch(function (error) { | ||||||
| 	respond({data: `<!DOCTYPE html> | 	respond({ | ||||||
| 	<pre style="color: #f00">${error.message}\n${error.stack}</pre>`, content_type: 'text/html'}); | 		data: `<!DOCTYPE html> | ||||||
|  | 	<pre style="color: #f00">${error.message}\n${error.stack}</pre>`, | ||||||
|  | 		content_type: 'text/html', | ||||||
|  | 	}); | ||||||
| }); | }); | ||||||
| @@ -51,7 +51,7 @@ async function key_list(db) { | |||||||
| 	app.setDocument(doc); | 	app.setDocument(doc); | ||||||
| } | } | ||||||
|  |  | ||||||
| core.register('message', async function(message) { | core.register('message', async function (message) { | ||||||
| 	if (message.event == 'hashChange') { | 	if (message.event == 'hashChange') { | ||||||
| 		let hash = message.hash.substring(1); | 		let hash = message.hash.substring(1); | ||||||
| 		if (hash.startsWith(':shared:')) { | 		if (hash.startsWith(':shared:')) { | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ let g_about_cache = {}; | |||||||
|  |  | ||||||
| async function query(sql, args) { | async function query(sql, args) { | ||||||
| 	let result = []; | 	let result = []; | ||||||
| 	await ssb.sqlAsync(sql, args, function(row) { | 	await ssb.sqlAsync(sql, args, function (row) { | ||||||
| 		result.push(row); | 		result.push(row); | ||||||
| 	}); | 	}); | ||||||
| 	return result; | 	return result; | ||||||
| @@ -21,7 +21,8 @@ async function contacts_internal(id, last_row_id, following, max_row_id) { | |||||||
| 				json_extract(content, '$.type') = 'contact' | 				json_extract(content, '$.type') = 'contact' | ||||||
| 				ORDER BY sequence | 				ORDER BY sequence | ||||||
| 			`, | 			`, | ||||||
| 		[id, last_row_id, max_row_id]); | 		[id, last_row_id, max_row_id] | ||||||
|  | 	); | ||||||
| 	for (let row of contacts) { | 	for (let row of contacts) { | ||||||
| 		let contact = JSON.parse(row.content); | 		let contact = JSON.parse(row.content); | ||||||
| 		if (contact.following === true) { | 		if (contact.following === true) { | ||||||
| @@ -42,15 +43,34 @@ async function contact(id, last_row_id, following, max_row_id) { | |||||||
| 	return await contacts_internal(id, last_row_id, following, max_row_id); | 	return await contacts_internal(id, last_row_id, following, max_row_id); | ||||||
| } | } | ||||||
|  |  | ||||||
| async function following_deep_internal(ids, depth, blocking, last_row_id, following, max_row_id) { | async function following_deep_internal( | ||||||
| 	let contacts = await Promise.all([...new Set(ids)].map(x => contact(x, last_row_id, following, max_row_id))); | 	ids, | ||||||
|  | 	depth, | ||||||
|  | 	blocking, | ||||||
|  | 	last_row_id, | ||||||
|  | 	following, | ||||||
|  | 	max_row_id | ||||||
|  | ) { | ||||||
|  | 	let contacts = await Promise.all( | ||||||
|  | 		[...new Set(ids)].map((x) => contact(x, last_row_id, following, max_row_id)) | ||||||
|  | 	); | ||||||
| 	let result = {}; | 	let result = {}; | ||||||
| 	for (let i = 0; i < ids.length; i++) { | 	for (let i = 0; i < ids.length; i++) { | ||||||
| 		let id = ids[i]; | 		let id = ids[i]; | ||||||
| 		let contact = contacts[i]; | 		let contact = contacts[i]; | ||||||
| 		let all_blocking = Object.assign({}, contact.blocking, blocking); | 		let all_blocking = Object.assign({}, contact.blocking, blocking); | ||||||
| 		let found = Object.keys(contact.following).filter(y => !all_blocking[y]); | 		let found = Object.keys(contact.following).filter((y) => !all_blocking[y]); | ||||||
| 		let deeper = depth > 1 ? await following_deep_internal(found, depth - 1, all_blocking, last_row_id, following, max_row_id) : []; | 		let deeper = | ||||||
|  | 			depth > 1 | ||||||
|  | 				? await following_deep_internal( | ||||||
|  | 						found, | ||||||
|  | 						depth - 1, | ||||||
|  | 						all_blocking, | ||||||
|  | 						last_row_id, | ||||||
|  | 						following, | ||||||
|  | 						max_row_id | ||||||
|  | 					) | ||||||
|  | 				: []; | ||||||
| 		result[id] = [id, ...found, ...deeper]; | 		result[id] = [id, ...found, ...deeper]; | ||||||
| 	} | 	} | ||||||
| 	return [...new Set(Object.values(result).flat())]; | 	return [...new Set(Object.values(result).flat())]; | ||||||
| @@ -68,10 +88,22 @@ async function following_deep(ids, depth, blocking) { | |||||||
| 			last_row_id: 0, | 			last_row_id: 0, | ||||||
| 		}; | 		}; | ||||||
| 	} | 	} | ||||||
| 	let max_row_id = (await query(` | 	let max_row_id = ( | ||||||
|  | 		await query( | ||||||
|  | 			` | ||||||
| 			SELECT MAX(rowid) AS max_row_id FROM messages | 			SELECT MAX(rowid) AS max_row_id FROM messages | ||||||
| 		`, []))[0].max_row_id; | 		`, | ||||||
| 	let result = await following_deep_internal(ids, depth, blocking, cache.last_row_id, cache.following, max_row_id); | 			[] | ||||||
|  | 		) | ||||||
|  | 	)[0].max_row_id; | ||||||
|  | 	let result = await following_deep_internal( | ||||||
|  | 		ids, | ||||||
|  | 		depth, | ||||||
|  | 		blocking, | ||||||
|  | 		cache.last_row_id, | ||||||
|  | 		cache.following, | ||||||
|  | 		max_row_id | ||||||
|  | 	); | ||||||
| 	cache.last_row_id = max_row_id; | 	cache.last_row_id = max_row_id; | ||||||
| 	let store = JSON.stringify(cache); | 	let store = JSON.stringify(cache); | ||||||
| 	await db.set('following', store); | 	await db.set('following', store); | ||||||
| @@ -90,13 +122,15 @@ async function fetch_about(db, ids, users) { | |||||||
| 		}; | 		}; | ||||||
| 	} | 	} | ||||||
| 	let max_row_id = 0; | 	let max_row_id = 0; | ||||||
| 	await ssb.sqlAsync(` | 	await ssb.sqlAsync( | ||||||
|  | 		` | ||||||
| 			SELECT MAX(rowid) AS max_row_id FROM messages | 			SELECT MAX(rowid) AS max_row_id FROM messages | ||||||
| 		`, | 		`, | ||||||
| 		[], | 		[], | ||||||
| 		function(row) { | 		function (row) { | ||||||
| 			max_row_id = row.max_row_id; | 			max_row_id = row.max_row_id; | ||||||
| 		}); | 		} | ||||||
|  | 	); | ||||||
| 	for (let id of Object.keys(cache.about)) { | 	for (let id of Object.keys(cache.about)) { | ||||||
| 		if (ids.indexOf(id) == -1) { | 		if (ids.indexOf(id) == -1) { | ||||||
| 			delete cache.about[id]; | 			delete cache.about[id]; | ||||||
| @@ -129,17 +163,21 @@ async function fetch_about(db, ids, users) { | |||||||
| 				ORDER BY messages.author, messages.sequence | 				ORDER BY messages.author, messages.sequence | ||||||
| 			`, | 			`, | ||||||
| 		[ | 		[ | ||||||
| 			JSON.stringify(ids.filter(id => cache.about[id])), | 			JSON.stringify(ids.filter((id) => cache.about[id])), | ||||||
| 			JSON.stringify(ids.filter(id => !cache.about[id])), | 			JSON.stringify(ids.filter((id) => !cache.about[id])), | ||||||
| 			cache.last_row_id, | 			cache.last_row_id, | ||||||
| 			max_row_id, | 			max_row_id, | ||||||
| 		]); | 		] | ||||||
|  | 	); | ||||||
| 	for (let about of abouts) { | 	for (let about of abouts) { | ||||||
| 		let content = JSON.parse(about.content); | 		let content = JSON.parse(about.content); | ||||||
| 		if (content.about === about.author) { | 		if (content.about === about.author) { | ||||||
| 			delete content.type; | 			delete content.type; | ||||||
| 			delete content.about; | 			delete content.about; | ||||||
| 			cache.about[about.author] = Object.assign(cache.about[about.author] || {}, content); | 			cache.about[about.author] = Object.assign( | ||||||
|  | 				cache.about[about.author] || {}, | ||||||
|  | 				content | ||||||
|  | 			); | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 	cache.last_row_id = max_row_id; | 	cache.last_row_id = max_row_id; | ||||||
| @@ -155,41 +193,41 @@ async function getAbout(db, id) { | |||||||
| 	if (g_about_cache[id]) { | 	if (g_about_cache[id]) { | ||||||
| 		return g_about_cache[id]; | 		return g_about_cache[id]; | ||||||
| 	} | 	} | ||||||
| 	let o = await db.get(id + ":about"); | 	let o = await db.get(id + ':about'); | ||||||
| 	const k_version = 4; | 	const k_version = 4; | ||||||
| 	let f = o ? JSON.parse(o) : o; | 	let f = o ? JSON.parse(o) : o; | ||||||
| 	if (!f || f.version != k_version) { | 	if (!f || f.version != k_version) { | ||||||
| 		f = {about: {}, sequence: 0, version: k_version}; | 		f = {about: {}, sequence: 0, version: k_version}; | ||||||
| 	} | 	} | ||||||
| 	await ssb.sqlAsync( | 	await ssb.sqlAsync( | ||||||
| 		"SELECT "+ | 		'SELECT ' + | ||||||
| 		"  sequence, "+ | 			'  sequence, ' + | ||||||
| 		"  content "+ | 			'  content ' + | ||||||
| 		"FROM messages "+ | 			'FROM messages ' + | ||||||
| 		"WHERE "+ | 			'WHERE ' + | ||||||
| 		"  author = ?1 AND "+ | 			'  author = ?1 AND ' + | ||||||
| 		"  sequence > ?2 AND "+ | 			'  sequence > ?2 AND ' + | ||||||
| 		"  json_extract(content, '$.type') = 'about' AND "+ | 			"  json_extract(content, '$.type') = 'about' AND " + | ||||||
| 		"  json_extract(content, '$.about') = ?1 "+ | 			"  json_extract(content, '$.about') = ?1 " + | ||||||
| 		"UNION SELECT MAX(sequence) as sequence, NULL FROM messages WHERE author = ?1 "+ | 			'UNION SELECT MAX(sequence) as sequence, NULL FROM messages WHERE author = ?1 ' + | ||||||
| 		"ORDER BY sequence", | 			'ORDER BY sequence', | ||||||
| 		[id, f.sequence], | 		[id, f.sequence], | ||||||
| 		function(row) { | 		function (row) { | ||||||
| 			f.sequence = row.sequence; | 			f.sequence = row.sequence; | ||||||
| 			if (row.content) { | 			if (row.content) { | ||||||
| 				let about = {}; | 				let about = {}; | ||||||
| 				try { | 				try { | ||||||
| 					about = JSON.parse(row.content); | 					about = JSON.parse(row.content); | ||||||
| 				} catch { | 				} catch {} | ||||||
| 				} |  | ||||||
| 				delete about.about; | 				delete about.about; | ||||||
| 				delete about.type; | 				delete about.type; | ||||||
| 				f.about = Object.assign(f.about, about); | 				f.about = Object.assign(f.about, about); | ||||||
| 			} | 			} | ||||||
| 		}); | 		} | ||||||
|  | 	); | ||||||
| 	let j = JSON.stringify(f); | 	let j = JSON.stringify(f); | ||||||
| 	if (o != j) { | 	if (o != j) { | ||||||
| 		await db.set(id + ":about", j); | 		await db.set(id + ':about', j); | ||||||
| 	} | 	} | ||||||
| 	g_about_cache[id] = f.about; | 	g_about_cache[id] = f.about; | ||||||
| 	return f.about; | 	return f.about; | ||||||
| @@ -198,15 +236,15 @@ async function getAbout(db, id) { | |||||||
| async function getSize(db, id) { | async function getSize(db, id) { | ||||||
| 	let size = 0; | 	let size = 0; | ||||||
| 	await ssb.sqlAsync( | 	await ssb.sqlAsync( | ||||||
| 		"SELECT (LENGTH(author) * COUNT(*) + SUM(LENGTH(content)) + SUM(LENGTH(id))) AS size FROM messages WHERE author = ?1", | 		'SELECT (LENGTH(author) * COUNT(*) + SUM(LENGTH(content)) + SUM(LENGTH(id))) AS size FROM messages WHERE author = ?1', | ||||||
| 		[id], | 		[id], | ||||||
| 		function (row) { | 		function (row) { | ||||||
| 			size += row.size; | 			size += row.size; | ||||||
| 		}); | 		} | ||||||
|  | 	); | ||||||
| 	return size; | 	return size; | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| async function getSizes(ids) { | async function getSizes(ids) { | ||||||
| 	let sizes = {}; | 	let sizes = {}; | ||||||
| 	await ssb.sqlAsync( | 	await ssb.sqlAsync( | ||||||
| @@ -221,7 +259,8 @@ async function getSizes(ids) { | |||||||
| 		[JSON.stringify(ids)], | 		[JSON.stringify(ids)], | ||||||
| 		function (row) { | 		function (row) { | ||||||
| 			sizes[row.author] = row.size; | 			sizes[row.author] = row.size; | ||||||
| 		}); | 		} | ||||||
|  | 	); | ||||||
| 	return sizes; | 	return sizes; | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -241,7 +280,10 @@ function niceSize(bytes) { | |||||||
| } | } | ||||||
|  |  | ||||||
| function escape(value) { | function escape(value) { | ||||||
| 	return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); | 	return value | ||||||
|  | 		.replaceAll('&', '&') | ||||||
|  | 		.replaceAll('<', '<') | ||||||
|  | 		.replaceAll('>', '>'); | ||||||
| } | } | ||||||
|  |  | ||||||
| async function main() { | async function main() { | ||||||
| @@ -249,19 +291,27 @@ async function main() { | |||||||
| 	let db = await database('ssb'); | 	let db = await database('ssb'); | ||||||
| 	let whoami = await ssb.getIdentities(); | 	let whoami = await ssb.getIdentities(); | ||||||
| 	let tree = ''; | 	let tree = ''; | ||||||
| 	await app.setDocument(`<pre style="color: #fff">Enumerating followed users...</pre>`); | 	await app.setDocument( | ||||||
|  | 		`<pre style="color: #fff">Enumerating followed users...</pre>` | ||||||
|  | 	); | ||||||
| 	let following = await following_deep(whoami, 2, {}); | 	let following = await following_deep(whoami, 2, {}); | ||||||
| 	await app.setDocument(`<pre style="color: #fff">Getting names and sizes...</pre>`); | 	await app.setDocument( | ||||||
|  | 		`<pre style="color: #fff">Getting names and sizes...</pre>` | ||||||
|  | 	); | ||||||
| 	let [about, sizes] = await Promise.all([ | 	let [about, sizes] = await Promise.all([ | ||||||
| 		fetch_about(db, following, {}), | 		fetch_about(db, following, {}), | ||||||
| 		getSizes(following), | 		getSizes(following), | ||||||
| 	]); | 	]); | ||||||
| 	await app.setDocument(`<pre style="color: #fff">Finishing...</pre>`); | 	await app.setDocument(`<pre style="color: #fff">Finishing...</pre>`); | ||||||
| 	following.sort((a, b) => ((sizes[b] ?? 0) - (sizes[a] ?? 0))); | 	following.sort((a, b) => (sizes[b] ?? 0) - (sizes[a] ?? 0)); | ||||||
| 	for (let id of following) { | 	for (let id of following) { | ||||||
| 		tree += `<li><a href="/~core/ssb/#${id}">${escape(about[id]?.name ?? id)}</a> ${niceSize(sizes[id] ?? 0)}</li>\n`; | 		tree += `<li><a href="/~core/ssb/#${id}">${escape(about[id]?.name ?? id)}</a> ${niceSize(sizes[id] ?? 0)}</li>\n`; | ||||||
| 	} | 	} | ||||||
| 	await app.setDocument('<!DOCTYPE html>\n<html>\n<body style="color: #fff"><h1>Following</h1>\n<ul>' + tree + '</ul>\n</body>\n</html>'); | 	await app.setDocument( | ||||||
|  | 		'<!DOCTYPE html>\n<html>\n<body style="color: #fff"><h1>Following</h1>\n<ul>' + | ||||||
|  | 			tree + | ||||||
|  | 			'</ul>\n</body>\n</html>' | ||||||
|  | 	); | ||||||
| } | } | ||||||
|  |  | ||||||
| main(); | main(); | ||||||
| @@ -1,5 +1,5 @@ | |||||||
| { | { | ||||||
|   "type": "tildefriends-app", | 	"type": "tildefriends-app", | ||||||
|   "emoji": "🗺", | 	"emoji": "🗺", | ||||||
|   "previous": "&0XSp+xdQwVtQ88bXzvWdH15Ex63hv5zUKTa4zx7HBGM=.sha256" | 	"previous": "&0XSp+xdQwVtQ88bXzvWdH15Ex63hv5zUKTa4zx7HBGM=.sha256" | ||||||
| } | } | ||||||
| @@ -46,7 +46,7 @@ tfrpc.register(async function query(sql, args) { | |||||||
| 	return result; | 	return result; | ||||||
| }); | }); | ||||||
| tfrpc.register(async function store_blob(blob) { | tfrpc.register(async function store_blob(blob) { | ||||||
| 	if (typeof(blob) == 'string') { | 	if (typeof blob == 'string') { | ||||||
| 		blob = utf8Encode(blob); | 		blob = utf8Encode(blob); | ||||||
| 	} | 	} | ||||||
| 	if (Array.isArray(blob)) { | 	if (Array.isArray(blob)) { | ||||||
| @@ -71,10 +71,15 @@ async function main() { | |||||||
| 		let shared_db = await shared_database('state'); | 		let shared_db = await shared_database('state'); | ||||||
| 		attempt = await shared_db.get(core.user.credentials.session.name); | 		attempt = await shared_db.get(core.user.credentials.session.name); | ||||||
| 	} | 	} | ||||||
| 	app.setDocument(utf8Decode(getFile('index.html')).replace('${data}', JSON.stringify({ | 	app.setDocument( | ||||||
| 		attempt: attempt, | 		utf8Decode(getFile('index.html')).replace( | ||||||
| 		state: core.user?.credentials?.session?.name, | 			'${data}', | ||||||
| 	}))); | 			JSON.stringify({ | ||||||
|  | 				attempt: attempt, | ||||||
|  | 				state: core.user?.credentials?.session?.name, | ||||||
|  | 			}) | ||||||
|  | 		) | ||||||
|  | 	); | ||||||
| } | } | ||||||
|  |  | ||||||
| main(); | main(); | ||||||
| @@ -17,7 +17,7 @@ function xml_parse(xml) { | |||||||
| 			let tag = xml.substring(tag_begin, i).trim(); | 			let tag = xml.substring(tag_begin, i).trim(); | ||||||
| 			if (tag.startsWith('?') && tag.endsWith('?')) { | 			if (tag.startsWith('?') && tag.endsWith('?')) { | ||||||
| 				/* Ignore directives. */ | 				/* Ignore directives. */ | ||||||
| 			} else  if (tag.startsWith('/')) { | 			} else if (tag.startsWith('/')) { | ||||||
| 				path.pop(); | 				path.pop(); | ||||||
| 			} else { | 			} else { | ||||||
| 				let parts = tag.split(' '); | 				let parts = tag.split(' '); | ||||||
| @@ -63,7 +63,10 @@ export function gpx_parse(xml) { | |||||||
| 			for (let trkseg of xml_each(trk, 'trkseg')) { | 			for (let trkseg of xml_each(trk, 'trkseg')) { | ||||||
| 				let segment = []; | 				let segment = []; | ||||||
| 				for (let trkpt of xml_each(trkseg, 'trkpt')) { | 				for (let trkpt of xml_each(trkseg, 'trkpt')) { | ||||||
| 					segment.push({lat: parseFloat(trkpt.attributes.lat), lon: parseFloat(trkpt.attributes.lon)}); | 					segment.push({ | ||||||
|  | 						lat: parseFloat(trkpt.attributes.lat), | ||||||
|  | 						lon: parseFloat(trkpt.attributes.lon), | ||||||
|  | 					}); | ||||||
| 				} | 				} | ||||||
| 				result.segments.push(segment); | 				result.segments.push(segment); | ||||||
| 			} | 			} | ||||||
|   | |||||||
| @@ -1,14 +1,26 @@ | |||||||
| <!DOCTYPE html> | <!doctype html> | ||||||
| <html style="width: 100%; height: 100%; margin: 0; padding: 0"> | <html style="width: 100%; height: 100%; margin: 0; padding: 0"> | ||||||
| 	<head> | 	<head> | ||||||
| 		<script>window.litDisableBundleWarning = true;</script> | 		<script> | ||||||
|  | 			window.litDisableBundleWarning = true; | ||||||
|  | 		</script> | ||||||
| 		<script> | 		<script> | ||||||
| 			let g_data = ${data}; | 			let g_data = ${data}; | ||||||
| 		</script> | 		</script> | ||||||
| 		<script src="script.js" type="module"></script> | 		<script src="script.js" type="module"></script> | ||||||
| 		<script src="leaflet.js"></script> | 		<script src="leaflet.js"></script> | ||||||
| 	</head> | 	</head> | ||||||
| 	<body style="color: #fff; display: flex; flex-flow: column; height: 100%; width: 100%; margin: 0; padding: 0"> | 	<body | ||||||
|  | 		style=" | ||||||
|  | 			color: #fff; | ||||||
|  | 			display: flex; | ||||||
|  | 			flex-flow: column; | ||||||
|  | 			height: 100%; | ||||||
|  | 			width: 100%; | ||||||
|  | 			margin: 0; | ||||||
|  | 			padding: 0; | ||||||
|  | 		" | ||||||
|  | 	> | ||||||
| 		<gg-app style="width: 100%; height: 100%" id="ggapp"></gg-app> | 		<gg-app style="width: 100%; height: 100%" id="ggapp"></gg-app> | ||||||
| 	</body> | 	</body> | ||||||
| </html> | </html> | ||||||
| @@ -10,24 +10,24 @@ | |||||||
| var polyline = {}; | var polyline = {}; | ||||||
|  |  | ||||||
| function py2_round(value) { | function py2_round(value) { | ||||||
|     // Google's polyline algorithm uses the same rounding strategy as Python 2, which is different from JS for negative values | 	// Google's polyline algorithm uses the same rounding strategy as Python 2, which is different from JS for negative values | ||||||
|     return Math.floor(Math.abs(value) + 0.5) * (value >= 0 ? 1 : -1); | 	return Math.floor(Math.abs(value) + 0.5) * (value >= 0 ? 1 : -1); | ||||||
| } | } | ||||||
|  |  | ||||||
| function encode(current, previous, factor) { | function encode(current, previous, factor) { | ||||||
|     current = py2_round(current * factor); | 	current = py2_round(current * factor); | ||||||
|     previous = py2_round(previous * factor); | 	previous = py2_round(previous * factor); | ||||||
|     var coordinate = (current - previous) * 2; | 	var coordinate = (current - previous) * 2; | ||||||
|     if (coordinate < 0) { | 	if (coordinate < 0) { | ||||||
|         coordinate = -coordinate - 1 | 		coordinate = -coordinate - 1; | ||||||
|     } | 	} | ||||||
|     var output = ''; | 	var output = ''; | ||||||
|     while (coordinate >= 0x20) { | 	while (coordinate >= 0x20) { | ||||||
|         output += String.fromCharCode((0x20 | (coordinate & 0x1f)) + 63); | 		output += String.fromCharCode((0x20 | (coordinate & 0x1f)) + 63); | ||||||
|         coordinate /= 32; | 		coordinate /= 32; | ||||||
|     } | 	} | ||||||
|     output += String.fromCharCode((coordinate | 0) + 63); | 	output += String.fromCharCode((coordinate | 0) + 63); | ||||||
|     return output; | 	return output; | ||||||
| } | } | ||||||
|  |  | ||||||
| /** | /** | ||||||
| @@ -41,54 +41,53 @@ function encode(current, previous, factor) { | |||||||
|  * |  * | ||||||
|  * @see https://github.com/Project-OSRM/osrm-frontend/blob/master/WebContent/routing/OSRM.RoutingGeometry.js |  * @see https://github.com/Project-OSRM/osrm-frontend/blob/master/WebContent/routing/OSRM.RoutingGeometry.js | ||||||
|  */ |  */ | ||||||
| polyline.decode = function(str, precision) { | polyline.decode = function (str, precision) { | ||||||
|     var index = 0, | 	var index = 0, | ||||||
|         lat = 0, | 		lat = 0, | ||||||
|         lng = 0, | 		lng = 0, | ||||||
|         coordinates = [], | 		coordinates = [], | ||||||
|         shift = 0, | 		shift = 0, | ||||||
|         result = 0, | 		result = 0, | ||||||
|         byte = null, | 		byte = null, | ||||||
|         latitude_change, | 		latitude_change, | ||||||
|         longitude_change, | 		longitude_change, | ||||||
|         factor = Math.pow(10, Number.isInteger(precision) ? precision : 5); | 		factor = Math.pow(10, Number.isInteger(precision) ? precision : 5); | ||||||
|  |  | ||||||
|     // Coordinates have variable length when encoded, so just keep | 	// Coordinates have variable length when encoded, so just keep | ||||||
|     // track of whether we've hit the end of the string. In each | 	// track of whether we've hit the end of the string. In each | ||||||
|     // loop iteration, a single coordinate is decoded. | 	// loop iteration, a single coordinate is decoded. | ||||||
|     while (index < str.length) { | 	while (index < str.length) { | ||||||
|  | 		// Reset shift, result, and byte | ||||||
|  | 		byte = null; | ||||||
|  | 		shift = 1; | ||||||
|  | 		result = 0; | ||||||
|  |  | ||||||
|         // Reset shift, result, and byte | 		do { | ||||||
|         byte = null; | 			byte = str.charCodeAt(index++) - 63; | ||||||
|         shift = 1; | 			result += (byte & 0x1f) * shift; | ||||||
|         result = 0; | 			shift *= 32; | ||||||
|  | 		} while (byte >= 0x20); | ||||||
|  |  | ||||||
|         do { | 		latitude_change = result & 1 ? (-result - 1) / 2 : result / 2; | ||||||
|             byte = str.charCodeAt(index++) - 63; |  | ||||||
|             result += (byte & 0x1f) * shift; |  | ||||||
|             shift *= 32; |  | ||||||
|         } while (byte >= 0x20); |  | ||||||
|  |  | ||||||
|         latitude_change = (result & 1) ? ((-result - 1) / 2) : (result / 2); | 		shift = 1; | ||||||
|  | 		result = 0; | ||||||
|  |  | ||||||
|         shift = 1; | 		do { | ||||||
|         result = 0; | 			byte = str.charCodeAt(index++) - 63; | ||||||
|  | 			result += (byte & 0x1f) * shift; | ||||||
|  | 			shift *= 32; | ||||||
|  | 		} while (byte >= 0x20); | ||||||
|  |  | ||||||
|         do { | 		longitude_change = result & 1 ? (-result - 1) / 2 : result / 2; | ||||||
|             byte = str.charCodeAt(index++) - 63; |  | ||||||
|             result += (byte & 0x1f) * shift; |  | ||||||
|             shift *= 32; |  | ||||||
|         } while (byte >= 0x20); |  | ||||||
|  |  | ||||||
|         longitude_change = (result & 1) ? ((-result - 1) / 2) : (result / 2); | 		lat += latitude_change; | ||||||
|  | 		lng += longitude_change; | ||||||
|  |  | ||||||
|         lat += latitude_change; | 		coordinates.push([lat / factor, lng / factor]); | ||||||
|         lng += longitude_change; | 	} | ||||||
|  |  | ||||||
|         coordinates.push([lat / factor, lng / factor]); | 	return coordinates; | ||||||
|     } |  | ||||||
|  |  | ||||||
|     return coordinates; |  | ||||||
| }; | }; | ||||||
|  |  | ||||||
| /** | /** | ||||||
| @@ -98,28 +97,33 @@ polyline.decode = function(str, precision) { | |||||||
|  * @param {Number} precision |  * @param {Number} precision | ||||||
|  * @returns {String} |  * @returns {String} | ||||||
|  */ |  */ | ||||||
| polyline.encode = function(coordinates, precision) { | polyline.encode = function (coordinates, precision) { | ||||||
|     if (!coordinates.length) { return ''; } | 	if (!coordinates.length) { | ||||||
|  | 		return ''; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|     var factor = Math.pow(10, Number.isInteger(precision) ? precision : 5), | 	var factor = Math.pow(10, Number.isInteger(precision) ? precision : 5), | ||||||
|         output = encode(coordinates[0][0], 0, factor) + encode(coordinates[0][1], 0, factor); | 		output = | ||||||
|  | 			encode(coordinates[0][0], 0, factor) + | ||||||
|  | 			encode(coordinates[0][1], 0, factor); | ||||||
|  |  | ||||||
|     for (var i = 1; i < coordinates.length; i++) { | 	for (var i = 1; i < coordinates.length; i++) { | ||||||
|         var a = coordinates[i], b = coordinates[i - 1]; | 		var a = coordinates[i], | ||||||
|         output += encode(a[0], b[0], factor); | 			b = coordinates[i - 1]; | ||||||
|         output += encode(a[1], b[1], factor); | 		output += encode(a[0], b[0], factor); | ||||||
|     } | 		output += encode(a[1], b[1], factor); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|     return output; | 	return output; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| function flipped(coords) { | function flipped(coords) { | ||||||
|     var flipped = []; | 	var flipped = []; | ||||||
|     for (var i = 0; i < coords.length; i++) { | 	for (var i = 0; i < coords.length; i++) { | ||||||
|         var coord = coords[i].slice(); | 		var coord = coords[i].slice(); | ||||||
|         flipped.push([coord[1], coord[0]]); | 		flipped.push([coord[1], coord[0]]); | ||||||
|     } | 	} | ||||||
|     return flipped; | 	return flipped; | ||||||
| } | } | ||||||
|  |  | ||||||
| /** | /** | ||||||
| @@ -129,14 +133,14 @@ function flipped(coords) { | |||||||
|  * @param {Number} precision |  * @param {Number} precision | ||||||
|  * @returns {String} |  * @returns {String} | ||||||
|  */ |  */ | ||||||
| polyline.fromGeoJSON = function(geojson, precision) { | polyline.fromGeoJSON = function (geojson, precision) { | ||||||
|     if (geojson && geojson.type === 'Feature') { | 	if (geojson && geojson.type === 'Feature') { | ||||||
|         geojson = geojson.geometry; | 		geojson = geojson.geometry; | ||||||
|     } | 	} | ||||||
|     if (!geojson || geojson.type !== 'LineString') { | 	if (!geojson || geojson.type !== 'LineString') { | ||||||
|         throw new Error('Input must be a GeoJSON LineString'); | 		throw new Error('Input must be a GeoJSON LineString'); | ||||||
|     } | 	} | ||||||
|     return polyline.encode(flipped(geojson.coordinates), precision); | 	return polyline.encode(flipped(geojson.coordinates), precision); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| /** | /** | ||||||
| @@ -146,13 +150,13 @@ polyline.fromGeoJSON = function(geojson, precision) { | |||||||
|  * @param {Number} precision |  * @param {Number} precision | ||||||
|  * @returns {Object} |  * @returns {Object} | ||||||
|  */ |  */ | ||||||
| polyline.toGeoJSON = function(str, precision) { | polyline.toGeoJSON = function (str, precision) { | ||||||
|     var coords = polyline.decode(str, precision); | 	var coords = polyline.decode(str, precision); | ||||||
|     return { | 	return { | ||||||
|         type: 'LineString', | 		type: 'LineString', | ||||||
|         coordinates: flipped(coords) | 		coordinates: flipped(coords), | ||||||
|     }; | 	}; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| let polyline_decode = polyline.decode; | let polyline_decode = polyline.decode; | ||||||
| export { polyline_decode as decode }; | export {polyline_decode as decode}; | ||||||
|   | |||||||
| @@ -1,4 +1,11 @@ | |||||||
| import {LitElement, html, unsafeHTML, css, guard, until} from './lit-all.min.js'; | import { | ||||||
|  | 	LitElement, | ||||||
|  | 	html, | ||||||
|  | 	unsafeHTML, | ||||||
|  | 	css, | ||||||
|  | 	guard, | ||||||
|  | 	until, | ||||||
|  | } from './lit-all.min.js'; | ||||||
| import * as tfrpc from '/static/tfrpc.js'; | import * as tfrpc from '/static/tfrpc.js'; | ||||||
| import * as polyline from './polyline.js'; | import * as polyline from './polyline.js'; | ||||||
| import {gpx_parse} from './gpx.js'; | import {gpx_parse} from './gpx.js'; | ||||||
| @@ -56,7 +63,7 @@ class GgAppElement extends LitElement { | |||||||
| 		this.focus = undefined; | 		this.focus = undefined; | ||||||
| 		this.status = undefined; | 		this.status = undefined; | ||||||
| 		this.tab = 'map'; | 		this.tab = 'map'; | ||||||
| 		this.load().catch(function(e) { | 		this.load().catch(function (e) { | ||||||
| 			console.log('load error', e); | 			console.log('load error', e); | ||||||
| 		}); | 		}); | ||||||
| 		this.to_build = '🏠'; | 		this.to_build = '🏠'; | ||||||
| @@ -65,9 +72,12 @@ class GgAppElement extends LitElement { | |||||||
| 	async load() { | 	async load() { | ||||||
| 		console.log('load'); | 		console.log('load'); | ||||||
| 		let emojis = await (await fetch('emojis.json')).json(); | 		let emojis = await (await fetch('emojis.json')).json(); | ||||||
| 		emojis = Object.values(emojis).map(x => Object.values(x)).flat(); | 		emojis = Object.values(emojis) | ||||||
|  | 			.map((x) => Object.values(x)) | ||||||
|  | 			.flat(); | ||||||
| 		let today = new Date(); | 		let today = new Date(); | ||||||
| 		let date_index = today.getYear() * 356 + today.getMonth() * 31 + today.getDate(); | 		let date_index = | ||||||
|  | 			today.getYear() * 356 + today.getMonth() * 31 + today.getDate(); | ||||||
| 		this.emoji_of_the_day = emojis[(date_index * 123457) % emojis.length]; | 		this.emoji_of_the_day = emojis[(date_index * 123457) % emojis.length]; | ||||||
| 		this.user = await tfrpc.rpc.getUser(); | 		this.user = await tfrpc.rpc.getUser(); | ||||||
| 		this.url = (await tfrpc.rpc.url()).split('?')[0]; | 		this.url = (await tfrpc.rpc.url()).split('?')[0]; | ||||||
| @@ -109,7 +119,8 @@ class GgAppElement extends LitElement { | |||||||
| 	async get_activities_from_ssb() { | 	async get_activities_from_ssb() { | ||||||
| 		this.status = {text: 'loading activities'}; | 		this.status = {text: 'loading activities'}; | ||||||
| 		this.loaded_activities = []; | 		this.loaded_activities = []; | ||||||
| 		let rows = await tfrpc.rpc.query(` | 		let rows = await tfrpc.rpc.query( | ||||||
|  | 			` | ||||||
| 			SELECT messages.author, json_extract(mention.value, '$.link') AS blob_id | 			SELECT messages.author, json_extract(mention.value, '$.link') AS blob_id | ||||||
| 			FROM messages_fts('"gg-activity"') | 			FROM messages_fts('"gg-activity"') | ||||||
| 			JOIN messages ON messages.rowid = messages_fts.rowid, | 			JOIN messages ON messages.rowid = messages_fts.rowid, | ||||||
| @@ -117,10 +128,15 @@ class GgAppElement extends LitElement { | |||||||
| 			WHERE json_extract(messages.content, '$.type') = 'gg-activity' AND | 			WHERE json_extract(messages.content, '$.type') = 'gg-activity' AND | ||||||
| 			json_extract(mention.value, '$.name') = 'activity_data' | 			json_extract(mention.value, '$.name') = 'activity_data' | ||||||
| 			ORDER BY messages.timestamp DESC | 			ORDER BY messages.timestamp DESC | ||||||
| 		`, []); | 		`, | ||||||
|  | 			[] | ||||||
|  | 		); | ||||||
| 		this.status = {text: 'loading activity data'}; | 		this.status = {text: 'loading activity data'}; | ||||||
| 		let authors = rows.map(x => x.author); | 		let authors = rows.map((x) => x.author); | ||||||
| 		let blobs = await this.promise_all(rows.map(x => tfrpc.rpc.get_blob(x.blob_id)), 8); | 		let blobs = await this.promise_all( | ||||||
|  | 			rows.map((x) => tfrpc.rpc.get_blob(x.blob_id)), | ||||||
|  | 			8 | ||||||
|  | 		); | ||||||
| 		this.status = {text: 'processing activity data'}; | 		this.status = {text: 'processing activity data'}; | ||||||
| 		for (let [index, blob] of blobs.entries()) { | 		for (let [index, blob] of blobs.entries()) { | ||||||
| 			let activity; | 			let activity; | ||||||
| @@ -135,13 +151,19 @@ class GgAppElement extends LitElement { | |||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 		this.status = {text: 'calculating balance'}; | 		this.status = {text: 'calculating balance'}; | ||||||
| 		rows = await tfrpc.rpc.query(` | 		rows = await tfrpc.rpc.query( | ||||||
|  | 			` | ||||||
| 			SELECT count(*) AS currency FROM messages WHERE author = ? AND json_extract(content, '$.type') = 'gg-activity' | 			SELECT count(*) AS currency FROM messages WHERE author = ? AND json_extract(content, '$.type') = 'gg-activity' | ||||||
| 		`, [this.whoami]); | 		`, | ||||||
|  | 			[this.whoami] | ||||||
|  | 		); | ||||||
| 		let currency = rows[0].currency; | 		let currency = rows[0].currency; | ||||||
| 		rows = await tfrpc.rpc.query(` | 		rows = await tfrpc.rpc.query( | ||||||
|  | 			` | ||||||
| 			SELECT SUM(json_extract(content, '$.cost')) AS cost FROM messages WHERE author = ? AND json_extract(content, '$.type') = 'gg-place' | 			SELECT SUM(json_extract(content, '$.cost')) AS cost FROM messages WHERE author = ? AND json_extract(content, '$.type') = 'gg-place' | ||||||
| 		`, [this.whoami]); | 		`, | ||||||
|  | 			[this.whoami] | ||||||
|  | 		); | ||||||
| 		let spent = rows[0].cost; | 		let spent = rows[0].cost; | ||||||
| 		this.currency = currency - spent; | 		this.currency = currency - spent; | ||||||
| 		this.status = {text: 'getting placed emojis'}; | 		this.status = {text: 'getting placed emojis'}; | ||||||
| @@ -166,8 +188,11 @@ class GgAppElement extends LitElement { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	async sync_activities() { | 	async sync_activities() { | ||||||
| 		let ids = this.activities.map(x => `https://www.strava.com/activities/${x.id}`); | 		let ids = this.activities.map( | ||||||
| 		let missing = await tfrpc.rpc.query(` | 			(x) => `https://www.strava.com/activities/${x.id}` | ||||||
|  | 		); | ||||||
|  | 		let missing = await tfrpc.rpc.query( | ||||||
|  | 			` | ||||||
| 			WITH my_activities AS ( | 			WITH my_activities AS ( | ||||||
| 				SELECT json_extract(mention.value, '$.link') AS url | 				SELECT json_extract(mention.value, '$.link') AS url | ||||||
| 				FROM messages, json_each(messages.content, '$.mentions') AS mention | 				FROM messages, json_each(messages.content, '$.mentions') AS mention | ||||||
| @@ -178,17 +203,26 @@ class GgAppElement extends LitElement { | |||||||
| 			SELECT from_strava.value FROM json_each(?) AS from_strava | 			SELECT from_strava.value FROM json_each(?) AS from_strava | ||||||
| 			LEFT OUTER JOIN my_activities ON from_strava.value = my_activities.url | 			LEFT OUTER JOIN my_activities ON from_strava.value = my_activities.url | ||||||
| 			WHERE my_activities.url IS NULL | 			WHERE my_activities.url IS NULL | ||||||
| 			`, [this.whoami, JSON.stringify(ids)]); | 			`, | ||||||
|  | 			[this.whoami, JSON.stringify(ids)] | ||||||
|  | 		); | ||||||
| 		console.log('missing = ', missing); | 		console.log('missing = ', missing); | ||||||
| 		for (let [index, row] of missing.entries()) { | 		for (let [index, row] of missing.entries()) { | ||||||
| 			this.status = {text: 'syncing from strava', value: index, max: missing.length}; | 			this.status = { | ||||||
|  | 				text: 'syncing from strava', | ||||||
|  | 				value: index, | ||||||
|  | 				max: missing.length, | ||||||
|  | 			}; | ||||||
| 			let url = row.value; | 			let url = row.value; | ||||||
| 			let id = url.match(/.*\/(\d+)/)[1]; | 			let id = url.match(/.*\/(\d+)/)[1]; | ||||||
| 			let response = await fetch(`https://www.strava.com/api/v3/activities/${id}`, { | 			let response = await fetch( | ||||||
| 				headers: { | 				`https://www.strava.com/api/v3/activities/${id}`, | ||||||
| 					'Authorization': `Bearer ${this.strava.access_token}`, | 				{ | ||||||
| 				}, | 					headers: { | ||||||
| 			}); | 						Authorization: `Bearer ${this.strava.access_token}`, | ||||||
|  | 					}, | ||||||
|  | 				} | ||||||
|  | 			); | ||||||
| 			let activity = await response.json(); | 			let activity = await response.json(); | ||||||
| 			let blob_id = await tfrpc.rpc.store_blob(JSON.stringify(activity)); | 			let blob_id = await tfrpc.rpc.store_blob(JSON.stringify(activity)); | ||||||
| 			let message = { | 			let message = { | ||||||
| @@ -201,7 +235,7 @@ class GgAppElement extends LitElement { | |||||||
| 					{ | 					{ | ||||||
| 						link: blob_id, | 						link: blob_id, | ||||||
| 						name: 'activity_data', | 						name: 'activity_data', | ||||||
| 					} | 					}, | ||||||
| 				], | 				], | ||||||
| 			}; | 			}; | ||||||
| 			await tfrpc.rpc.appendMessage(this.whoami, message); | 			await tfrpc.rpc.appendMessage(this.whoami, message); | ||||||
| @@ -215,13 +249,20 @@ class GgAppElement extends LitElement { | |||||||
| 			return; | 			return; | ||||||
| 		} | 		} | ||||||
| 		let ids = await tfrpc.rpc.getIdentities(); | 		let ids = await tfrpc.rpc.getIdentities(); | ||||||
| 		let players = ids.length ? (await tfrpc.rpc.query(` | 		let players = ids.length | ||||||
|  | 			? ( | ||||||
|  | 					await tfrpc.rpc.query( | ||||||
|  | 						` | ||||||
| 			SELECT author FROM messages JOIN json_each(?) ON messages.author = json_each.value | 			SELECT author FROM messages JOIN json_each(?) ON messages.author = json_each.value | ||||||
| 			WHERE | 			WHERE | ||||||
| 				json_extract(messages.content, '$.type') = 'gg-player' AND | 				json_extract(messages.content, '$.type') = 'gg-player' AND | ||||||
| 				json_extract(messages.content, '$.active') | 				json_extract(messages.content, '$.active') | ||||||
| 			ORDER BY timestamp DESC limit 1 | 			ORDER BY timestamp DESC limit 1 | ||||||
| 			`, [JSON.stringify(ids)])).map(row => row.author) : []; | 			`, | ||||||
|  | 						[JSON.stringify(ids)] | ||||||
|  | 					) | ||||||
|  | 				).map((row) => row.author) | ||||||
|  | 			: []; | ||||||
| 		if (!players.length) { | 		if (!players.length) { | ||||||
| 			this.whoami = await tfrpc.rpc.createIdentity(); | 			this.whoami = await tfrpc.rpc.createIdentity(); | ||||||
| 			if (this.whoami) { | 			if (this.whoami) { | ||||||
| @@ -246,9 +287,14 @@ class GgAppElement extends LitElement { | |||||||
| 			await tfrpc.rpc.databaseSet('strava', shared); | 			await tfrpc.rpc.databaseSet('strava', shared); | ||||||
| 			await tfrpc.rpc.sharedDatabaseRemove(name); | 			await tfrpc.rpc.sharedDatabaseRemove(name); | ||||||
| 		} | 		} | ||||||
| 		this.strava = JSON.parse(await tfrpc.rpc.databaseGet('strava') || '{}'); | 		this.strava = JSON.parse((await tfrpc.rpc.databaseGet('strava')) || '{}'); | ||||||
| 		if (new Date().valueOf() / 1000 > this.strava.expires_at) { | 		if (new Date().valueOf() / 1000 > this.strava.expires_at) { | ||||||
| 			console.log('this looks expired', new Date().valueOf() / 1000, '>', this.strava.expires_at); | 			console.log( | ||||||
|  | 				'this looks expired', | ||||||
|  | 				new Date().valueOf() / 1000, | ||||||
|  | 				'>', | ||||||
|  | 				this.strava.expires_at | ||||||
|  | 			); | ||||||
| 			let x = await tfrpc.rpc.refresh_token(this.strava); | 			let x = await tfrpc.rpc.refresh_token(this.strava); | ||||||
| 			if (x) { | 			if (x) { | ||||||
| 				this.strava = x; | 				this.strava = x; | ||||||
| @@ -261,13 +307,16 @@ class GgAppElement extends LitElement { | |||||||
|  |  | ||||||
| 	async update_activities() { | 	async update_activities() { | ||||||
| 		if (this?.strava?.access_token) { | 		if (this?.strava?.access_token) { | ||||||
| 			let response = await fetch('https://www.strava.com/api/v3/athlete/activities', { | 			let response = await fetch( | ||||||
| 				headers: { | 				'https://www.strava.com/api/v3/athlete/activities', | ||||||
| 					'Authorization': `Bearer ${this.strava.access_token}`, | 				{ | ||||||
| 				}, | 					headers: { | ||||||
| 			}); | 						Authorization: `Bearer ${this.strava.access_token}`, | ||||||
|  | 					}, | ||||||
|  | 				} | ||||||
|  | 			); | ||||||
| 			this.activities = await response.json(); | 			this.activities = await response.json(); | ||||||
| 			this.activities.sort((a, b) => (a.id - b.id)); | 			this.activities.sort((a, b) => a.id - b.id); | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -282,10 +331,12 @@ class GgAppElement extends LitElement { | |||||||
| 			[k_color_default, '🟧'], | 			[k_color_default, '🟧'], | ||||||
| 		]; | 		]; | ||||||
| 		for (let m of k_map) { | 		for (let m of k_map) { | ||||||
| 			if (m[0][0] == color[0] && | 			if ( | ||||||
|  | 				m[0][0] == color[0] && | ||||||
| 				m[0][1] == color[1] && | 				m[0][1] == color[1] && | ||||||
| 				m[0][2] == color[2] && | 				m[0][2] == color[2] && | ||||||
| 				m[0][3] == color[3]) { | 				m[0][3] == color[3] | ||||||
|  | 			) { | ||||||
| 				return m[1]; | 				return m[1]; | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| @@ -329,9 +380,11 @@ class GgAppElement extends LitElement { | |||||||
| 	on_click(event) { | 	on_click(event) { | ||||||
| 		let popup = L.popup() | 		let popup = L.popup() | ||||||
| 			.setLatLng(event.latlng) | 			.setLatLng(event.latlng) | ||||||
| 			.setContent(` | 			.setContent( | ||||||
|  | 				` | ||||||
| 				<div><a target="_top" href="https://www.google.com/maps/search/?api=1&query=${event.latlng.lat},${event.latlng.lng}">${event.latlng.lat}, ${event.latlng.lng}</a></div> | 				<div><a target="_top" href="https://www.google.com/maps/search/?api=1&query=${event.latlng.lat},${event.latlng.lng}">${event.latlng.lat}, ${event.latlng.lng}</a></div> | ||||||
| 			`) | 			` | ||||||
|  | 			) | ||||||
| 			.openOn(this.leaflet); | 			.openOn(this.leaflet); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -368,31 +421,43 @@ class GgAppElement extends LitElement { | |||||||
| 	on_marker_click(event) { | 	on_marker_click(event) { | ||||||
| 		this.popup = L.popup() | 		this.popup = L.popup() | ||||||
| 			.setLatLng(event.latlng) | 			.setLatLng(event.latlng) | ||||||
| 			.setContent(` | 			.setContent( | ||||||
|  | 				` | ||||||
| 				${this.to_build} (-${k_store[this.to_build]}) <input type="button" value="Build" onclick="document.getElementById('ggapp').build()"></input> | 				${this.to_build} (-${k_store[this.to_build]}) <input type="button" value="Build" onclick="document.getElementById('ggapp').build()"></input> | ||||||
| 			`) | 			` | ||||||
|  | 			) | ||||||
| 			.openOn(this.leaflet); | 			.openOn(this.leaflet); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	snap_to_grid(latlng, fudge, zoom) { | 	snap_to_grid(latlng, fudge, zoom) { | ||||||
| 		let position = this.leaflet.options.crs.latLngToPoint(latlng, zoom ?? this.leaflet.getZoom()); | 		let position = this.leaflet.options.crs.latLngToPoint( | ||||||
|  | 			latlng, | ||||||
|  | 			zoom ?? this.leaflet.getZoom() | ||||||
|  | 		); | ||||||
| 		position.x = Math.round(position.x / 16) * 16 + (fudge?.x ?? 0); | 		position.x = Math.round(position.x / 16) * 16 + (fudge?.x ?? 0); | ||||||
| 		position.y = Math.round(position.y / 16) * 16 + (fudge?.y ?? 0); | 		position.y = Math.round(position.y / 16) * 16 + (fudge?.y ?? 0); | ||||||
| 		position = this.leaflet.options.crs.pointToLatLng(position, zoom ?? this.leaflet.getZoom()); | 		position = this.leaflet.options.crs.pointToLatLng( | ||||||
|  | 			position, | ||||||
|  | 			zoom ?? this.leaflet.getZoom() | ||||||
|  | 		); | ||||||
| 		return position; | 		return position; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	on_marker_move(event) { | 	on_marker_move(event) { | ||||||
| 		if (!this.no_snap && this.marker) { | 		if (!this.no_snap && this.marker) { | ||||||
| 			this.no_snap = true; | 			this.no_snap = true; | ||||||
| 			this.marker.setLatLng(this.snap_to_grid(this.marker.getLatLng(), k_marker_snap)); | 			this.marker.setLatLng( | ||||||
|  | 				this.snap_to_grid(this.marker.getLatLng(), k_marker_snap) | ||||||
|  | 			); | ||||||
| 			this.no_snap = false; | 			this.no_snap = false; | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	on_zoom(event) { | 	on_zoom(event) { | ||||||
| 		if (this.marker) { | 		if (this.marker) { | ||||||
| 			this.marker.setLatLng(this.snap_to_grid(this.marker.getLatLng(), k_marker_snap)); | 			this.marker.setLatLng( | ||||||
|  | 				this.snap_to_grid(this.marker.getLatLng(), k_marker_snap) | ||||||
|  | 			); | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -403,7 +468,10 @@ class GgAppElement extends LitElement { | |||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if (this.to_build) { | 		if (this.to_build) { | ||||||
| 			this.marker = L.marker(this.snap_to_grid(event.latlng, k_marker_snap), {icon: L.divIcon({className: 'build-icon'}), draggable: true}).addTo(this.leaflet); | 			this.marker = L.marker(this.snap_to_grid(event.latlng, k_marker_snap), { | ||||||
|  | 				icon: L.divIcon({className: 'build-icon'}), | ||||||
|  | 				draggable: true, | ||||||
|  | 			}).addTo(this.leaflet); | ||||||
| 			this.marker.on({click: this.on_marker_click.bind(this)}); | 			this.marker.on({click: this.on_marker_click.bind(this)}); | ||||||
| 			this.marker.on({drag: this.on_marker_move.bind(this)}); | 			this.marker.on({drag: this.on_marker_move.bind(this)}); | ||||||
| 		} | 		} | ||||||
| @@ -417,14 +485,18 @@ class GgAppElement extends LitElement { | |||||||
| 			return; | 			return; | ||||||
| 		} | 		} | ||||||
| 		if (!this.leaflet) { | 		if (!this.leaflet) { | ||||||
| 			this.leaflet = L.map(map, {attributionControl: false, maxZoom: 16, bounceAtZoomLimits: false}); | 			this.leaflet = L.map(map, { | ||||||
|  | 				attributionControl: false, | ||||||
|  | 				maxZoom: 16, | ||||||
|  | 				bounceAtZoomLimits: false, | ||||||
|  | 			}); | ||||||
| 			this.leaflet.on({contextmenu: this.on_click.bind(this)}); | 			this.leaflet.on({contextmenu: this.on_click.bind(this)}); | ||||||
| 			this.leaflet.on({click: this.on_mouse_down.bind(this)}); | 			this.leaflet.on({click: this.on_mouse_down.bind(this)}); | ||||||
| 			this.leaflet.on({zoom: this.on_zoom.bind(this)}); | 			this.leaflet.on({zoom: this.on_zoom.bind(this)}); | ||||||
| 		} | 		} | ||||||
| 		let self = this; | 		let self = this; | ||||||
| 		let grid_layer = L.GridLayer.extend({ | 		let grid_layer = L.GridLayer.extend({ | ||||||
| 			createTile: function(coords) { | 			createTile: function (coords) { | ||||||
| 				var tile = L.DomUtil.create('canvas', 'leaflet-tile'); | 				var tile = L.DomUtil.create('canvas', 'leaflet-tile'); | ||||||
| 				var size = this.getTileSize(); | 				var size = this.getTileSize(); | ||||||
| 				tile.width = size.x; | 				tile.width = size.x; | ||||||
| @@ -432,7 +504,7 @@ class GgAppElement extends LitElement { | |||||||
| 				var context = tile.getContext('2d'); | 				var context = tile.getContext('2d'); | ||||||
| 				context.font = '10pt sans'; | 				context.font = '10pt sans'; | ||||||
| 				let bounds = this._tileCoordsToBounds(coords); | 				let bounds = this._tileCoordsToBounds(coords); | ||||||
| 				let degrees = 360.0 / (2 ** coords.z); | 				let degrees = 360.0 / 2 ** coords.z; | ||||||
| 				let ul = bounds.getNorthWest(); | 				let ul = bounds.getNorthWest(); | ||||||
| 				let lr = bounds.getSouthEast(); | 				let lr = bounds.getSouthEast(); | ||||||
|  |  | ||||||
| @@ -442,33 +514,53 @@ class GgAppElement extends LitElement { | |||||||
| 				let mini_context = mini.getContext('2d'); | 				let mini_context = mini.getContext('2d'); | ||||||
| 				let image_data = context.getImageData(0, 0, mini.width, mini.height); | 				let image_data = context.getImageData(0, 0, mini.width, mini.height); | ||||||
| 				for (let activity of self.loaded_activities) { | 				for (let activity of self.loaded_activities) { | ||||||
| 					self.draw_activity_to_tile(image_data, mini.width, mini.height, ul, lr, activity); | 					self.draw_activity_to_tile( | ||||||
|  | 						image_data, | ||||||
|  | 						mini.width, | ||||||
|  | 						mini.height, | ||||||
|  | 						ul, | ||||||
|  | 						lr, | ||||||
|  | 						activity | ||||||
|  | 					); | ||||||
| 				} | 				} | ||||||
| 				context.textAlign = 'left'; | 				context.textAlign = 'left'; | ||||||
| 				context.textBaseline = 'bottom'; | 				context.textBaseline = 'bottom'; | ||||||
| 				for (let x = 0; x < mini.width; x++) { | 				for (let x = 0; x < mini.width; x++) { | ||||||
| 					for (let y = 0; y < mini.height; y++) { | 					for (let y = 0; y < mini.height; y++) { | ||||||
| 						let start = (y * mini.width + x) * 4; | 						let start = (y * mini.width + x) * 4; | ||||||
| 						let pixel = self.color_to_emoji(image_data.data.slice(start, start + 4)); | 						let pixel = self.color_to_emoji( | ||||||
|  | 							image_data.data.slice(start, start + 4) | ||||||
|  | 						); | ||||||
| 						if (pixel) { | 						if (pixel) { | ||||||
| 							//context.fillRect(x * size.x / mini.width, y * size.y / mini.height, size.x / mini.width, size.y / mini.height); | 							//context.fillRect(x * size.x / mini.width, y * size.y / mini.height, size.x / mini.width, size.y / mini.height); | ||||||
| 							context.fillText(pixel, x * size.x / mini.width, y * size.y / mini.height + mini.height); | 							context.fillText( | ||||||
|  | 								pixel, | ||||||
|  | 								(x * size.x) / mini.width, | ||||||
|  | 								(y * size.y) / mini.height + mini.height | ||||||
|  | 							); | ||||||
| 						} | 						} | ||||||
| 					} | 					} | ||||||
| 				} | 				} | ||||||
| 				for (let placed of self.placed_emojis) { | 				for (let placed of self.placed_emojis) { | ||||||
| 					let position = self.leaflet.options.crs.latLngToPoint(self.snap_to_grid(placed.position, undefined, coords.z), coords.z); | 					let position = self.leaflet.options.crs.latLngToPoint( | ||||||
|  | 						self.snap_to_grid(placed.position, undefined, coords.z), | ||||||
|  | 						coords.z | ||||||
|  | 					); | ||||||
| 					let tile_x = Math.floor(position.x / size.x); | 					let tile_x = Math.floor(position.x / size.x); | ||||||
| 					let tile_y = Math.floor(position.y / size.y); | 					let tile_y = Math.floor(position.y / size.y); | ||||||
| 					position.x = position.x - tile_x * size.x; | 					position.x = position.x - tile_x * size.x; | ||||||
| 					position.y = position.y - tile_y * size.y; | 					position.y = position.y - tile_y * size.y; | ||||||
| 					if (tile_x == coords.x && tile_y == coords.y) { | 					if (tile_x == coords.x && tile_y == coords.y) { | ||||||
| 						//context.fillRect(position.x, position.y, size.x / mini.width, size.y / mini.height); | 						//context.fillRect(position.x, position.y, size.x / mini.width, size.y / mini.height); | ||||||
| 						context.fillText(placed.emoji, position.x, position.y + mini.height); | 						context.fillText( | ||||||
|  | 							placed.emoji, | ||||||
|  | 							position.x, | ||||||
|  | 							position.y + mini.height | ||||||
|  | 						); | ||||||
| 					} | 					} | ||||||
| 				} | 				} | ||||||
| 				return tile; | 				return tile; | ||||||
| 			} | 			}, | ||||||
| 		}); | 		}); | ||||||
| 		if (this.grid_layer) { | 		if (this.grid_layer) { | ||||||
| 			this.grid_layer.redraw(); | 			this.grid_layer.redraw(); | ||||||
| @@ -484,10 +576,7 @@ class GgAppElement extends LitElement { | |||||||
| 			this.max_lon = Math.max(this.max_lon, bounds.max.lng); | 			this.max_lon = Math.max(this.max_lon, bounds.max.lng); | ||||||
| 		} | 		} | ||||||
| 		if (this.focus) { | 		if (this.focus) { | ||||||
| 			this.leaflet.fitBounds([ | 			this.leaflet.fitBounds([this.focus.min, this.focus.max]); | ||||||
| 				this.focus.min, |  | ||||||
| 				this.focus.max, |  | ||||||
| 			]); |  | ||||||
| 			this.focus = undefined; | 			this.focus = undefined; | ||||||
| 		} else { | 		} else { | ||||||
| 			this.leaflet.fitBounds([ | 			this.leaflet.fitBounds([ | ||||||
| @@ -588,7 +677,12 @@ class GgAppElement extends LitElement { | |||||||
| 		let sy = y0 < y1 ? 1 : -1; | 		let sy = y0 < y1 ? 1 : -1; | ||||||
| 		let error = dx + dy; | 		let error = dx + dy; | ||||||
| 		while (true) { | 		while (true) { | ||||||
| 			if (x0 >= 0 && y0 >= 0 && x0 < image_data.width && y0 < image_data.height) { | 			if ( | ||||||
|  | 				x0 >= 0 && | ||||||
|  | 				y0 >= 0 && | ||||||
|  | 				x0 < image_data.width && | ||||||
|  | 				y0 < image_data.height | ||||||
|  | 			) { | ||||||
| 				let base = (y0 * image_data.width + x0) * 4; | 				let base = (y0 * image_data.width + x0) * 4; | ||||||
| 				image_data.data[base + 0] = value[0]; | 				image_data.data[base + 0] = value[0]; | ||||||
| 				image_data.data[base + 1] = value[1]; | 				image_data.data[base + 1] = value[1]; | ||||||
| @@ -623,8 +717,8 @@ class GgAppElement extends LitElement { | |||||||
| 			let last; | 			let last; | ||||||
| 			for (let pt of polyline.decode(activity.map.polyline)) { | 			for (let pt of polyline.decode(activity.map.polyline)) { | ||||||
| 				let px = [ | 				let px = [ | ||||||
| 					Math.floor(width * (pt[1] - ul.lng) / (lr.lng - ul.lng)), | 					Math.floor((width * (pt[1] - ul.lng)) / (lr.lng - ul.lng)), | ||||||
| 					Math.floor(height * (pt[0] - ul.lat) / (lr.lat - ul.lat)), | 					Math.floor((height * (pt[0] - ul.lat)) / (lr.lat - ul.lat)), | ||||||
| 				]; | 				]; | ||||||
| 				if (last) { | 				if (last) { | ||||||
| 					this.line(image_data, last[0], last[1], px[0], px[1], color); | 					this.line(image_data, last[0], last[1], px[0], px[1], color); | ||||||
| @@ -637,8 +731,8 @@ class GgAppElement extends LitElement { | |||||||
| 				let last; | 				let last; | ||||||
| 				for (let pt of segment) { | 				for (let pt of segment) { | ||||||
| 					let px = [ | 					let px = [ | ||||||
| 						Math.floor(width * (pt.lon - ul.lng) / (lr.lng - ul.lng)), | 						Math.floor((width * (pt.lon - ul.lng)) / (lr.lng - ul.lng)), | ||||||
| 						Math.floor(height * (pt.lat - ul.lat) / (lr.lat - ul.lat)), | 						Math.floor((height * (pt.lat - ul.lat)) / (lr.lat - ul.lat)), | ||||||
| 					]; | 					]; | ||||||
| 					if (last) { | 					if (last) { | ||||||
| 						this.line(image_data, last[0], last[1], px[0], px[1], color); | 						this.line(image_data, last[0], last[1], px[0], px[1], color); | ||||||
| @@ -667,7 +761,7 @@ class GgAppElement extends LitElement { | |||||||
| 					{ | 					{ | ||||||
| 						link: blob_id, | 						link: blob_id, | ||||||
| 						name: 'activity_data', | 						name: 'activity_data', | ||||||
| 					} | 					}, | ||||||
| 				], | 				], | ||||||
| 			}; | 			}; | ||||||
| 			console.log('id =', this.whoami, 'message = ', message); | 			console.log('id =', this.whoami, 'message = ', message); | ||||||
| @@ -693,8 +787,7 @@ class GgAppElement extends LitElement { | |||||||
|  |  | ||||||
| 	focus_map(activity) { | 	focus_map(activity) { | ||||||
| 		let bounds = this.activity_bounds(activity); | 		let bounds = this.activity_bounds(activity); | ||||||
| 		if (bounds.min.lat < bounds.max.lat && | 		if (bounds.min.lat < bounds.max.lat && bounds.min.lng < bounds.max.lng) { | ||||||
| 			bounds.min.lng < bounds.max.lng) { |  | ||||||
| 			this.tab = 'map'; | 			this.tab = 'map'; | ||||||
| 			this.focus = bounds; | 			this.focus = bounds; | ||||||
| 		} | 		} | ||||||
| @@ -703,9 +796,13 @@ class GgAppElement extends LitElement { | |||||||
| 	render_news() { | 	render_news() { | ||||||
| 		return html` | 		return html` | ||||||
| 			<ul> | 			<ul> | ||||||
| 				${this.loaded_activities.map(x => html` | 				${this.loaded_activities.map( | ||||||
| 					<li style="cursor: pointer" @click=${() => this.focus_map(x)}>${x.author} ${x.name ?? x.time}</li> | 					(x) => html` | ||||||
| 				`)} | 						<li style="cursor: pointer" @click=${() => this.focus_map(x)}> | ||||||
|  | 							${x.author} ${x.name ?? x.time} | ||||||
|  | 						</li> | ||||||
|  | 					` | ||||||
|  | 				)} | ||||||
| 			</ul> | 			</ul> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
| @@ -714,7 +811,7 @@ class GgAppElement extends LitElement { | |||||||
| 		let [emoji, cost] = item; | 		let [emoji, cost] = item; | ||||||
| 		return html` | 		return html` | ||||||
| 			<div> | 			<div> | ||||||
| 				<input type="button" value="${emoji}" @click=${() => this.to_build = emoji}></input> ${cost} ${emoji == this.to_build ? '<-- Will be built next' : undefined} | 				<input type="button" value="${emoji}" @click=${() => (this.to_build = emoji)}></input> ${cost} ${emoji == this.to_build ? '<-- Will be built next' : undefined} | ||||||
| 			</div> | 			</div> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
| @@ -732,7 +829,10 @@ class GgAppElement extends LitElement { | |||||||
| 	render() { | 	render() { | ||||||
| 		let header; | 		let header; | ||||||
| 		if (!this.user?.credentials?.session?.name) { | 		if (!this.user?.credentials?.session?.name) { | ||||||
| 			header = html`<div style="flex: 1 0">Please <a target="_top" href="/login?return=${this.url}">login</a> to Tilde Friends, first.</div>`; | 			header = html`<div style="flex: 1 0"> | ||||||
|  | 				Please <a target="_top" href="/login?return=${this.url}">login</a> to | ||||||
|  | 				Tilde Friends, first. | ||||||
|  | 			</div>`; | ||||||
| 		} else if (!this.strava?.access_token) { | 		} else if (!this.strava?.access_token) { | ||||||
| 			let strava_url = `https://www.strava.com/oauth/authorize?client_id=${k_client_id}&redirect_uri=${k_redirect_url}&response_type=code&approval_prompt=auto&scope=activity%3Aread&state=${g_data.state}`; | 			let strava_url = `https://www.strava.com/oauth/authorize?client_id=${k_client_id}&redirect_uri=${k_redirect_url}&response_type=code&approval_prompt=auto&scope=activity%3Aread&state=${g_data.state}`; | ||||||
| 			header = html` | 			header = html` | ||||||
| @@ -765,10 +865,10 @@ class GgAppElement extends LitElement { | |||||||
| 				} | 				} | ||||||
| 			</style> | 			</style> | ||||||
| 			<div id="navigation" style="display: flex; flex-direction: row"> | 			<div id="navigation" style="display: flex; flex-direction: row"> | ||||||
| 				<input type="button" id="button_map" @click=${() => this.tab = 'map'} value="🗺️Map"></input> | 				<input type="button" id="button_map" @click=${() => (this.tab = 'map')} value="🗺️Map"></input> | ||||||
| 				<input type="button" id="button_news" @click=${() => this.tab = 'news'} value="🏃News"></input> | 				<input type="button" id="button_news" @click=${() => (this.tab = 'news')} value="🏃News"></input> | ||||||
| 				<input type="button" id="button_friends" @click=${() => this.tab = 'friends'} value="👫Friends"></input> | 				<input type="button" id="button_friends" @click=${() => (this.tab = 'friends')} value="👫Friends"></input> | ||||||
| 				<input type="button" id="button_store" @click=${() => this.tab = 'store'} value="🏗️Store"></input> | 				<input type="button" id="button_store" @click=${() => (this.tab = 'store')} value="🏗️Store"></input> | ||||||
| 			</div> | 			</div> | ||||||
| 		`; | 		`; | ||||||
|  |  | ||||||
| @@ -790,13 +890,15 @@ class GgAppElement extends LitElement { | |||||||
|  |  | ||||||
| 		return html` | 		return html` | ||||||
| 			<style> | 			<style> | ||||||
| 			.build-icon::before { | 				.build-icon::before { | ||||||
| 				content: '📍'; | 					content: '📍'; | ||||||
| 				border: 2px solid red; | 					border: 2px solid red; | ||||||
| 			} | 				} | ||||||
| 			</style> | 			</style> | ||||||
| 			<link rel="stylesheet" href="leaflet.css"/> | 			<link rel="stylesheet" href="leaflet.css" /> | ||||||
| 			<div style="width: 100%; height: 100%; display: flex; flex-direction: column"> | 			<div | ||||||
|  | 				style="width: 100%; height: 100%; display: flex; flex-direction: column" | ||||||
|  | 			> | ||||||
| 				${header} | 				${header} | ||||||
| 				<div style="flex: 1 0; overflow: scroll">${content}</div> | 				<div style="flex: 1 0; overflow: scroll">${content}</div> | ||||||
| 				${navigation} | 				${navigation} | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| { | { | ||||||
|   "type": "tildefriends-app", | 	"type": "tildefriends-app", | ||||||
|   "emoji": "🪪", | 	"emoji": "🪪", | ||||||
|   "previous": "&kgukkyDk1RxgfzgMH6H/0QeDPIuwPZypLuAFax21ljk=.sha256" | 	"previous": "&kgukkyDk1RxgfzgMH6H/0QeDPIuwPZypLuAFax21ljk=.sha256" | ||||||
| } | } | ||||||
| @@ -18,7 +18,8 @@ tfrpc.register(async function reload() { | |||||||
|  |  | ||||||
| async function main() { | async function main() { | ||||||
| 	let ids = await ssb.getIdentities(); | 	let ids = await ssb.getIdentities(); | ||||||
| 	await app.setDocument(`<body style="color: #fff"> | 	await app.setDocument( | ||||||
|  | 		`<body style="color: #fff"> | ||||||
| 		<script>const handler = {};</script> | 		<script>const handler = {};</script> | ||||||
| 		<script type="module"> | 		<script type="module"> | ||||||
| 			import * as tfrpc from '/static/tfrpc.js'; | 			import * as tfrpc from '/static/tfrpc.js'; | ||||||
| @@ -74,14 +75,19 @@ async function main() { | |||||||
| 		<h2>Import an SSB Identity from 12 BIP39 English Words</h2> | 		<h2>Import an SSB Identity from 12 BIP39 English Words</h2> | ||||||
| 		<textarea id="add_id" style="width: 100%" rows="4"></textarea><button id="add" onclick="handler.add_id(event)">Import Identity</button> | 		<textarea id="add_id" style="width: 100%" rows="4"></textarea><button id="add" onclick="handler.add_id(event)">Import Identity</button> | ||||||
| 		<h2>Identities</h2> | 		<h2>Identities</h2> | ||||||
| 		<ul>`+ | 		<ul>` + | ||||||
| 		ids.map(id => `<li> | 			ids | ||||||
|  | 				.map( | ||||||
|  | 					(id) => `<li> | ||||||
| 			<button onclick="handler.export_id(event)" data-id="${id}">Export Identity</button> | 			<button onclick="handler.export_id(event)" data-id="${id}">Export Identity</button> | ||||||
| 			<button onclick="handler.delete_id(event)" data-id="${id}">Delete Identity</button> | 			<button onclick="handler.delete_id(event)" data-id="${id}">Delete Identity</button> | ||||||
| 			${id} | 			${id} | ||||||
| 		</li>`).join('\n')+ | 		</li>` | ||||||
| 	`	</ul> | 				) | ||||||
| 	</body>`); | 				.join('\n') + | ||||||
|  | 			`	</ul> | ||||||
|  | 	</body>` | ||||||
|  | 	); | ||||||
| } | } | ||||||
|  |  | ||||||
| main(); | main(); | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| { | { | ||||||
|   "type": "tildefriends-app", | 	"type": "tildefriends-app", | ||||||
|   "emoji": "🦟", | 	"emoji": "🦟", | ||||||
|   "previous": "&TegdzvFE+im94shygaHkgDYSaSrwY2h0OKUXSRPBQDM=.sha256" | 	"previous": "&TegdzvFE+im94shygaHkgDYSaSrwY2h0OKUXSRPBQDM=.sha256" | ||||||
| } | } | ||||||
| @@ -67,7 +67,7 @@ tfrpc.register(function getHash(id, message) { | |||||||
| tfrpc.register(function setHash(hash) { | tfrpc.register(function setHash(hash) { | ||||||
| 	return app.setHash(hash); | 	return app.setHash(hash); | ||||||
| }); | }); | ||||||
| ssb.addEventListener('message', async function(id) { | ssb.addEventListener('message', async function (id) { | ||||||
| 	await tfrpc.rpc.notifyNewMessage(id); | 	await tfrpc.rpc.notifyNewMessage(id); | ||||||
| }); | }); | ||||||
| tfrpc.register(async function store_blob(blob) { | tfrpc.register(async function store_blob(blob) { | ||||||
| @@ -88,16 +88,16 @@ tfrpc.register(function apps() { | |||||||
| tfrpc.register(async function try_decrypt(id, content) { | tfrpc.register(async function try_decrypt(id, content) { | ||||||
| 	return await ssb.privateMessageDecrypt(id, content); | 	return await ssb.privateMessageDecrypt(id, content); | ||||||
| }); | }); | ||||||
| ssb.addEventListener('broadcasts', async function() { | ssb.addEventListener('broadcasts', async function () { | ||||||
| 	await tfrpc.rpc.set('broadcasts', await ssb.getBroadcasts()); | 	await tfrpc.rpc.set('broadcasts', await ssb.getBroadcasts()); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| core.register('onConnectionsChanged', async function() { | core.register('onConnectionsChanged', async function () { | ||||||
| 	await tfrpc.rpc.set('connections', await ssb.connections()); | 	await tfrpc.rpc.set('connections', await ssb.connections()); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| async function main() { | async function main() { | ||||||
| 	if (typeof(database) !== 'undefined') { | 	if (typeof database !== 'undefined') { | ||||||
| 		g_database = await database('ssb'); | 		g_database = await database('ssb'); | ||||||
| 	} | 	} | ||||||
| 	await app.setDocument(utf8Decode(await getFile('index.html'))); | 	await app.setDocument(utf8Decode(await getFile('index.html'))); | ||||||
|   | |||||||
| @@ -1,12 +1,14 @@ | |||||||
| <!DOCTYPE html> | <!doctype html> | ||||||
| <html style="color: #fff"> | <html style="color: #fff"> | ||||||
| 	<head> | 	<head> | ||||||
| 		<title>Tilde Friends</title> | 		<title>Tilde Friends</title> | ||||||
| 		<base target="_top"> | 		<base target="_top" /> | ||||||
| 	</head> | 	</head> | ||||||
| 	<body> | 	<body> | ||||||
| 		<tf-issues-app/> | 		<tf-issues-app /> | ||||||
| 		<script>window.litDisableBundleWarning = true;</script> | 		<script> | ||||||
|  | 			window.litDisableBundleWarning = true; | ||||||
|  | 		</script> | ||||||
| 		<script src="commonmark.min.js"></script> | 		<script src="commonmark.min.js"></script> | ||||||
| 		<script src="commonmark-linkify.js" type="module"></script> | 		<script src="commonmark-linkify.js" type="module"></script> | ||||||
| 		<script src="script.js" type="module"></script> | 		<script src="script.js" type="module"></script> | ||||||
|   | |||||||
| @@ -31,7 +31,12 @@ class TfIdPickerElement extends LitElement { | |||||||
| 		if (this.ids) { | 		if (this.ids) { | ||||||
| 			return html` | 			return html` | ||||||
| 				<select @change=${this.changed} style="max-width: 100%"> | 				<select @change=${this.changed} style="max-width: 100%"> | ||||||
| 					${(this.ids).map(id => html`<option ?selected=${id == this.selected} value=${id}>${id}</option>`)} | 					${this.ids.map( | ||||||
|  | 						(id) => | ||||||
|  | 							html`<option ?selected=${id == this.selected} value=${id}> | ||||||
|  | 								${id} | ||||||
|  | 							</option>` | ||||||
|  | 					)} | ||||||
| 				</select> | 				</select> | ||||||
| 			`; | 			`; | ||||||
| 		} else { | 		} else { | ||||||
| @@ -57,13 +62,15 @@ class TfComposeElement extends LitElement { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	submit() { | 	submit() { | ||||||
| 		this.dispatchEvent(new CustomEvent('tf-submit', { | 		this.dispatchEvent( | ||||||
| 			bubbles: true, | 			new CustomEvent('tf-submit', { | ||||||
| 			composed: true, | 				bubbles: true, | ||||||
| 			detail: { | 				composed: true, | ||||||
| 				value: this.renderRoot.getElementById('input').value, | 				detail: { | ||||||
| 			}, | 					value: this.renderRoot.getElementById('input').value, | ||||||
| 		})); | 				}, | ||||||
|  | 			}) | ||||||
|  | 		); | ||||||
| 		this.renderRoot.getElementById('input').value = ''; | 		this.renderRoot.getElementById('input').value = ''; | ||||||
| 		this.input(); | 		this.input(); | ||||||
| 	} | 	} | ||||||
| @@ -96,7 +103,8 @@ class TfIssuesAppElement extends LitElement { | |||||||
|  |  | ||||||
| 	async load() { | 	async load() { | ||||||
| 		let issues = {}; | 		let issues = {}; | ||||||
| 		let messages = await tfrpc.rpc.query(` | 		let messages = await tfrpc.rpc.query( | ||||||
|  | 			` | ||||||
| 			WITH issues AS (SELECT messages.* FROM messages_refs JOIN messages ON | 			WITH issues AS (SELECT messages.* FROM messages_refs JOIN messages ON | ||||||
| 				messages.id = messages_refs.message | 				messages.id = messages_refs.message | ||||||
| 				WHERE messages_refs.ref = ? AND json_extract(messages.content, '$.type') = 'issue'), | 				WHERE messages_refs.ref = ? AND json_extract(messages.content, '$.type') = 'issue'), | ||||||
| @@ -107,7 +115,9 @@ class TfIssuesAppElement extends LitElement { | |||||||
| 			SELECT * FROM issues | 			SELECT * FROM issues | ||||||
| 			UNION | 			UNION | ||||||
| 			SELECT * FROM edits ORDER BY timestamp | 			SELECT * FROM edits ORDER BY timestamp | ||||||
| 		`, [k_project]); | 		`, | ||||||
|  | 			[k_project] | ||||||
|  | 		); | ||||||
| 		for (let message of messages) { | 		for (let message of messages) { | ||||||
| 			let content = JSON.parse(message.content); | 			let content = JSON.parse(message.content); | ||||||
| 			switch (content.type) { | 			switch (content.type) { | ||||||
| @@ -123,7 +133,7 @@ class TfIssuesAppElement extends LitElement { | |||||||
| 					break; | 					break; | ||||||
| 				case 'issue-edit': | 				case 'issue-edit': | ||||||
| 				case 'post': | 				case 'post': | ||||||
| 					for (let issue of (content.issues || [])) { | 					for (let issue of content.issues || []) { | ||||||
| 						if (issues[issue.link]) { | 						if (issues[issue.link]) { | ||||||
| 							if (issue.open !== undefined) { | 							if (issue.open !== undefined) { | ||||||
| 								issues[issue.link].open = issue.open; | 								issues[issue.link].open = issue.open; | ||||||
| @@ -136,7 +146,9 @@ class TfIssuesAppElement extends LitElement { | |||||||
| 					break; | 					break; | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 		this.issues = Object.values(issues).sort((x, y) => (y.open - x.open) || (y.created - x.created)); | 		this.issues = Object.values(issues).sort( | ||||||
|  | 			(x, y) => y.open - x.open || y.created - x.created | ||||||
|  | 		); | ||||||
| 		if (this.selected) { | 		if (this.selected) { | ||||||
| 			for (let issue of this.issues) { | 			for (let issue of this.issues) { | ||||||
| 				if (issue.id == this.selected.id) { | 				if (issue.id == this.selected.id) { | ||||||
| @@ -150,11 +162,20 @@ class TfIssuesAppElement extends LitElement { | |||||||
| 		return html` | 		return html` | ||||||
| 			<tr> | 			<tr> | ||||||
| 				<td>${issue.open ? '☐ open' : '☑ closed'}</td> | 				<td>${issue.open ? '☐ open' : '☑ closed'}</td> | ||||||
| 				<td style="max-width: 8em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis">${issue.author}</td> | 				<td | ||||||
| 				<td style="max-width: 40em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; cursor: pointer" @click=${() => this.selected = issue}> | 					style="max-width: 8em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis" | ||||||
|  | 				> | ||||||
|  | 					${issue.author} | ||||||
|  | 				</td> | ||||||
|  | 				<td | ||||||
|  | 					style="max-width: 40em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; cursor: pointer" | ||||||
|  | 					@click=${() => (this.selected = issue)} | ||||||
|  | 				> | ||||||
| 					${issue.text.split('\n')?.[0]} | 					${issue.text.split('\n')?.[0]} | ||||||
| 				</td> | 				</td> | ||||||
| 				<td>${new Date(issue.updated ?? issue.created).toLocaleDateString()}</td> | 				<td> | ||||||
|  | 					${new Date(issue.updated ?? issue.created).toLocaleDateString()} | ||||||
|  | 				</td> | ||||||
| 			</tr> | 			</tr> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
| @@ -170,13 +191,21 @@ class TfIssuesAppElement extends LitElement { | |||||||
| 				<div>${new Date(update.timestamp).toLocaleString()}</div> | 				<div>${new Date(update.timestamp).toLocaleString()}</div> | ||||||
| 				<div>${update.author}</div> | 				<div>${update.author}</div> | ||||||
| 				<div>${message}</div> | 				<div>${message}</div> | ||||||
| 				<div>${update.open !== undefined ? (update.open ? 'issue opened' : 'issue closed') : undefined}</div> | 				<div> | ||||||
|  | 					${update.open !== undefined | ||||||
|  | 						? update.open | ||||||
|  | 							? 'issue opened' | ||||||
|  | 							: 'issue closed' | ||||||
|  | 						: undefined} | ||||||
|  | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	async set_open(id, open) { | 	async set_open(id, open) { | ||||||
| 		if (confirm(`Are you sure you want to ${open ? 'open' : 'close'} this issue?`)) { | 		if ( | ||||||
|  | 			confirm(`Are you sure you want to ${open ? 'open' : 'close'} this issue?`) | ||||||
|  | 		) { | ||||||
| 			let whoami = this.shadowRoot.getElementById('picker').selected; | 			let whoami = this.shadowRoot.getElementById('picker').selected; | ||||||
| 			await tfrpc.rpc.appendMessage(whoami, { | 			await tfrpc.rpc.appendMessage(whoami, { | ||||||
| 				type: 'issue-edit', | 				type: 'issue-edit', | ||||||
| @@ -207,7 +236,9 @@ class TfIssuesAppElement extends LitElement { | |||||||
| 			type: 'post', | 			type: 'post', | ||||||
| 			text: event.detail.value, | 			text: event.detail.value, | ||||||
| 			root: this.selected.id, | 			root: this.selected.id, | ||||||
| 			branch: this.selected.updates.length ? this.selected.updates[this.selected.updates.length - 1].id : this.selected.id, | 			branch: this.selected.updates.length | ||||||
|  | 				? this.selected.updates[this.selected.updates.length - 1].id | ||||||
|  | 				: this.selected.id, | ||||||
| 			issues: [ | 			issues: [ | ||||||
| 				{ | 				{ | ||||||
| 					link: this.selected.id, | 					link: this.selected.id, | ||||||
| @@ -226,16 +257,18 @@ class TfIssuesAppElement extends LitElement { | |||||||
| 			return html` | 			return html` | ||||||
| 				${header} | 				${header} | ||||||
| 				<div> | 				<div> | ||||||
| 					<input type="button" value="Back" @click=${() => this.selected = undefined}></input> | 					<input type="button" value="Back" @click=${() => (this.selected = undefined)}></input> | ||||||
| 					${this.selected.open ? | 					${ | ||||||
| 						html`<input type="button" value="Close Issue" @click=${() => this.set_open(this.selected.id, false)}></input>` : | 						this.selected.open | ||||||
| 						html`<input type="button" value="Reopen Issue" @click=${() => this.set_open(this.selected.id, true)}></input>`} | 							? html`<input type="button" value="Close Issue" @click=${() => this.set_open(this.selected.id, false)}></input>` | ||||||
|  | 							: html`<input type="button" value="Reopen Issue" @click=${() => this.set_open(this.selected.id, true)}></input>` | ||||||
|  | 					} | ||||||
| 				</div> | 				</div> | ||||||
| 				<div>${new Date(this.selected.created).toLocaleString()}</div> | 				<div>${new Date(this.selected.created).toLocaleString()}</div> | ||||||
| 				<div>${this.selected.author}</div> | 				<div>${this.selected.author}</div> | ||||||
| 				<div>${this.selected.id}</div> | 				<div>${this.selected.id}</div> | ||||||
| 				<div>${unsafeHTML(tfutils.markdown(this.selected.text))}</div> | 				<div>${unsafeHTML(tfutils.markdown(this.selected.text))}</div> | ||||||
| 				${this.selected.updates.map(x => this.render_update(x))} | 				${this.selected.updates.map((x) => this.render_update(x))} | ||||||
| 				<tf-compose @tf-submit=${this.reply_to_issue}></tf-compose> | 				<tf-compose @tf-submit=${this.reply_to_issue}></tf-compose> | ||||||
| 			`; | 			`; | ||||||
| 		} else { | 		} else { | ||||||
| @@ -250,7 +283,7 @@ class TfIssuesAppElement extends LitElement { | |||||||
| 						<th>Title</th> | 						<th>Title</th> | ||||||
| 						<th>Date</th> | 						<th>Date</th> | ||||||
| 					</tr> | 					</tr> | ||||||
| 					${this.issues.map(x => this.render_issue_table_row(x))} | 					${this.issues.map((x) => this.render_issue_table_row(x))} | ||||||
| 				</table> | 				</table> | ||||||
| 			`; | 			`; | ||||||
| 		} | 		} | ||||||
|   | |||||||
| @@ -1,20 +1,32 @@ | |||||||
| import * as linkify from './commonmark-linkify.js'; | import * as linkify from './commonmark-linkify.js'; | ||||||
|  |  | ||||||
| function image(node, entering) { | function image(node, entering) { | ||||||
| 	if (node.firstChild?.type === 'text' && | 	if ( | ||||||
| 		node.firstChild.literal.startsWith('video:')) { | 		node.firstChild?.type === 'text' && | ||||||
|  | 		node.firstChild.literal.startsWith('video:') | ||||||
|  | 	) { | ||||||
| 		if (entering) { | 		if (entering) { | ||||||
| 			this.lit('<video style="max-width: 100%; max-height: 480px" title="' + this.esc(node.firstChild?.literal) + '" controls>'); | 			this.lit( | ||||||
|  | 				'<video style="max-width: 100%; max-height: 480px" title="' + | ||||||
|  | 					this.esc(node.firstChild?.literal) + | ||||||
|  | 					'" controls>' | ||||||
|  | 			); | ||||||
| 			this.lit('<source src="' + this.esc(node.destination) + '"></source>'); | 			this.lit('<source src="' + this.esc(node.destination) + '"></source>'); | ||||||
| 			this.disableTags += 1; | 			this.disableTags += 1; | ||||||
| 		} else { | 		} else { | ||||||
| 			this.disableTags -= 1; | 			this.disableTags -= 1; | ||||||
| 			this.lit('</video>'); | 			this.lit('</video>'); | ||||||
| 		} | 		} | ||||||
| 	} else if (node.firstChild?.type === 'text' && | 	} else if ( | ||||||
| 		node.firstChild.literal.startsWith('audio:')) { | 		node.firstChild?.type === 'text' && | ||||||
|  | 		node.firstChild.literal.startsWith('audio:') | ||||||
|  | 	) { | ||||||
| 		if (entering) { | 		if (entering) { | ||||||
| 			this.lit('<audio style="height: 32px; max-width: 100%" title="' + this.esc(node.firstChild?.literal) + '" controls>'); | 			this.lit( | ||||||
|  | 				'<audio style="height: 32px; max-width: 100%" title="' + | ||||||
|  | 					this.esc(node.firstChild?.literal) + | ||||||
|  | 					'" controls>' | ||||||
|  | 			); | ||||||
| 			this.lit('<source src="' + this.esc(node.destination) + '"></source>'); | 			this.lit('<source src="' + this.esc(node.destination) + '"></source>'); | ||||||
| 			this.disableTags += 1; | 			this.disableTags += 1; | ||||||
| 		} else { | 		} else { | ||||||
| @@ -24,7 +36,11 @@ function image(node, entering) { | |||||||
| 	} else { | 	} else { | ||||||
| 		if (entering) { | 		if (entering) { | ||||||
| 			if (this.disableTags === 0) { | 			if (this.disableTags === 0) { | ||||||
| 				this.lit('<div class="img_caption">' + this.esc(node.firstChild?.literal || node.destination) + '</div>'); | 				this.lit( | ||||||
|  | 					'<div class="img_caption">' + | ||||||
|  | 						this.esc(node.firstChild?.literal || node.destination) + | ||||||
|  | 						'</div>' | ||||||
|  | 				); | ||||||
| 				if (this.options.safe && potentiallyUnsafe(node.destination)) { | 				if (this.options.safe && potentiallyUnsafe(node.destination)) { | ||||||
| 					this.lit('<img src="" alt="'); | 					this.lit('<img src="" alt="'); | ||||||
| 				} else { | 				} else { | ||||||
| @@ -56,14 +72,20 @@ export function markdown(md) { | |||||||
| 		node = event.node; | 		node = event.node; | ||||||
| 		if (event.entering) { | 		if (event.entering) { | ||||||
| 			if (node.type == 'link') { | 			if (node.type == 'link') { | ||||||
| 				if (node.destination.startsWith('@') && | 				if ( | ||||||
| 					node.destination.endsWith('.ed25519')) { | 					node.destination.startsWith('@') && | ||||||
|  | 					node.destination.endsWith('.ed25519') | ||||||
|  | 				) { | ||||||
| 					node.destination = '#' + node.destination; | 					node.destination = '#' + node.destination; | ||||||
| 				} else if (node.destination.startsWith('%') && | 				} else if ( | ||||||
| 					node.destination.endsWith('.sha256')) { | 					node.destination.startsWith('%') && | ||||||
|  | 					node.destination.endsWith('.sha256') | ||||||
|  | 				) { | ||||||
| 					node.destination = '#' + node.destination; | 					node.destination = '#' + node.destination; | ||||||
| 				} else if (node.destination.startsWith('&') && | 				} else if ( | ||||||
| 					node.destination.endsWith('.sha256')) { | 					node.destination.startsWith('&') && | ||||||
|  | 					node.destination.endsWith('.sha256') | ||||||
|  | 				) { | ||||||
| 					node.destination = '/' + node.destination + '/view'; | 					node.destination = '/' + node.destination + '/view'; | ||||||
| 				} | 				} | ||||||
| 			} else if (node.type == 'image') { | 			} else if (node.type == 'image') { | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| { | { | ||||||
|   "type": "tildefriends-app", | 	"type": "tildefriends-app", | ||||||
|   "emoji": "📝", | 	"emoji": "📝", | ||||||
|   "previous": "&2hdIDbBrAg63T2X1MzdGSF7yiqHvlnfF0PnInQLp0DA=.sha256" | 	"previous": "&2hdIDbBrAg63T2X1MzdGSF7yiqHvlnfF0PnInQLp0DA=.sha256" | ||||||
| } | } | ||||||
| @@ -47,7 +47,7 @@ tfrpc.register(async function get_blob(id) { | |||||||
| }); | }); | ||||||
|  |  | ||||||
| let g_new_message_resolve; | let g_new_message_resolve; | ||||||
| let g_new_message_promise = new Promise(function(resolve, reject) { | let g_new_message_promise = new Promise(function (resolve, reject) { | ||||||
| 	g_new_message_resolve = resolve; | 	g_new_message_resolve = resolve; | ||||||
| }); | }); | ||||||
|  |  | ||||||
| @@ -55,9 +55,9 @@ function new_message() { | |||||||
| 	return g_new_message_promise; | 	return g_new_message_promise; | ||||||
| } | } | ||||||
|  |  | ||||||
| ssb.addEventListener('message', function(id) { | ssb.addEventListener('message', function (id) { | ||||||
| 	let resolve = g_new_message_resolve; | 	let resolve = g_new_message_resolve; | ||||||
| 	g_new_message_promise = new Promise(function(resolve, reject) { | 	g_new_message_promise = new Promise(function (resolve, reject) { | ||||||
| 		g_new_message_resolve = resolve; | 		g_new_message_resolve = resolve; | ||||||
| 	}); | 	}); | ||||||
| 	if (resolve) { | 	if (resolve) { | ||||||
| @@ -104,8 +104,7 @@ async function process_message(whoami, collection, message, kind, parent) { | |||||||
| 		if (!x) { | 		if (!x) { | ||||||
| 			return; | 			return; | ||||||
| 		} | 		} | ||||||
| 		if (content.type !== kind || | 		if (content.type !== kind || (parent && content.parent !== parent)) { | ||||||
| 			(parent && content.parent !== parent)) { |  | ||||||
| 			return; | 			return; | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| @@ -113,7 +112,10 @@ async function process_message(whoami, collection, message, kind, parent) { | |||||||
| 		if (content?.tombstone) { | 		if (content?.tombstone) { | ||||||
| 			delete collection[content.key]; | 			delete collection[content.key]; | ||||||
| 		} else { | 		} else { | ||||||
| 			collection[content.key] = Object.assign(collection[content.key] || {}, content); | 			collection[content.key] = Object.assign( | ||||||
|  | 				collection[content.key] || {}, | ||||||
|  | 				content | ||||||
|  | 			); | ||||||
| 		} | 		} | ||||||
| 	} else { | 	} else { | ||||||
| 		collection[message.id] = Object.assign(content, {id: message.id}); | 		collection[message.id] = Object.assign(content, {id: message.id}); | ||||||
| @@ -125,20 +127,29 @@ tfrpc.register(async function collection(ids, kind, parent, max_rowid, data) { | |||||||
| 	let whoami = await ssb.getIdentities(); | 	let whoami = await ssb.getIdentities(); | ||||||
| 	data = data ?? {}; | 	data = data ?? {}; | ||||||
| 	let rowid = 0; | 	let rowid = 0; | ||||||
| 	await ssb.sqlAsync('SELECT MAX(rowid) AS rowid FROM messages', [], function(row) { | 	await ssb.sqlAsync( | ||||||
| 		rowid = row.rowid; | 		'SELECT MAX(rowid) AS rowid FROM messages', | ||||||
| 	}); | 		[], | ||||||
|  | 		function (row) { | ||||||
|  | 			rowid = row.rowid; | ||||||
|  | 		} | ||||||
|  | 	); | ||||||
| 	while (true) { | 	while (true) { | ||||||
| 		if (rowid == max_rowid) { | 		if (rowid == max_rowid) { | ||||||
| 			await new_message(); | 			await new_message(); | ||||||
| 			await ssb.sqlAsync('SELECT MAX(rowid) AS rowid FROM messages', [], function(row) { | 			await ssb.sqlAsync( | ||||||
| 				rowid = row.rowid; | 				'SELECT MAX(rowid) AS rowid FROM messages', | ||||||
| 			}); | 				[], | ||||||
|  | 				function (row) { | ||||||
|  | 					rowid = row.rowid; | ||||||
|  | 				} | ||||||
|  | 			); | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		let modified = false; | 		let modified = false; | ||||||
| 		let rows = []; | 		let rows = []; | ||||||
| 		await ssb.sqlAsync(` | 		await ssb.sqlAsync( | ||||||
|  | 			` | ||||||
| 			SELECT messages.id, author, content, timestamp | 			SELECT messages.id, author, content, timestamp | ||||||
| 			FROM messages | 			FROM messages | ||||||
| 			JOIN json_each(?1) AS id ON messages.author = id.value | 			JOIN json_each(?1) AS id ON messages.author = id.value | ||||||
| @@ -150,9 +161,10 @@ tfrpc.register(async function collection(ids, kind, parent, max_rowid, data) { | |||||||
| 				content LIKE '"%') | 				content LIKE '"%') | ||||||
| 			`, | 			`, | ||||||
| 			[JSON.stringify(ids), max_rowid ?? -1, rowid, kind, parent], | 			[JSON.stringify(ids), max_rowid ?? -1, rowid, kind, parent], | ||||||
| 			function(row) { | 			function (row) { | ||||||
| 				rows.push(row); | 				rows.push(row); | ||||||
| 			}); | 			} | ||||||
|  | 		); | ||||||
| 		max_rowid = rowid; | 		max_rowid = rowid; | ||||||
| 		for (let row of rows) { | 		for (let row of rows) { | ||||||
| 			if (await process_message(whoami, data, row, kind, parent)) { | 			if (await process_message(whoami, data, row, kind, parent)) { | ||||||
|   | |||||||
| @@ -1,12 +1,14 @@ | |||||||
| <!DOCTYPE html> | <!doctype html> | ||||||
| <html> | <html> | ||||||
| 	<head> | 	<head> | ||||||
| 		<base target="_top"> | 		<base target="_top" /> | ||||||
| 	</head> | 	</head> | ||||||
| 	<body style="color: #fff"> | 	<body style="color: #fff"> | ||||||
| 		<tf-journal-app></tf-journal-app> | 		<tf-journal-app></tf-journal-app> | ||||||
| 		<script src="commonmark.min.js"></script> | 		<script src="commonmark.min.js"></script> | ||||||
| 		<script>window.litDisableBundleWarning = true;</script> | 		<script> | ||||||
|  | 			window.litDisableBundleWarning = true; | ||||||
|  | 		</script> | ||||||
| 		<script src="tf-journal-app.js" type="module"></script> | 		<script src="tf-journal-app.js" type="module"></script> | ||||||
| 		<script src="tf-journal-entry.js" type="module"></script> | 		<script src="tf-journal-entry.js" type="module"></script> | ||||||
| 		<script src="tf-id-picker.js" type="module"></script> | 		<script src="tf-id-picker.js" type="module"></script> | ||||||
|   | |||||||
| @@ -2,8 +2,8 @@ import {LitElement, html} from './lit-all.min.js'; | |||||||
| import * as tfrpc from '/static/tfrpc.js'; | import * as tfrpc from '/static/tfrpc.js'; | ||||||
|  |  | ||||||
| /* | /* | ||||||
| ** Provide a list of IDs, and this lets the user pick one. |  ** Provide a list of IDs, and this lets the user pick one. | ||||||
| */ |  */ | ||||||
| class TfIdentityPickerElement extends LitElement { | class TfIdentityPickerElement extends LitElement { | ||||||
| 	static get properties() { | 	static get properties() { | ||||||
| 		return { | 		return { | ||||||
| @@ -19,15 +19,22 @@ class TfIdentityPickerElement extends LitElement { | |||||||
|  |  | ||||||
| 	changed(event) { | 	changed(event) { | ||||||
| 		this.selected = event.srcElement.value; | 		this.selected = event.srcElement.value; | ||||||
| 		this.dispatchEvent(new Event('change', { | 		this.dispatchEvent( | ||||||
| 			srcElement: this, | 			new Event('change', { | ||||||
| 		})); | 				srcElement: this, | ||||||
|  | 			}) | ||||||
|  | 		); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render() { | 	render() { | ||||||
| 		return html` | 		return html` | ||||||
| 			<select @change=${this.changed} style="max-width: 100%"> | 			<select @change=${this.changed} style="max-width: 100%"> | ||||||
| 				${(this.ids ?? []).map(id => html`<option ?selected=${id == this.selected} value=${id}>${id}</option>`)} | 				${(this.ids ?? []).map( | ||||||
|  | 					(id) => | ||||||
|  | 						html`<option ?selected=${id == this.selected} value=${id}> | ||||||
|  | 							${id} | ||||||
|  | 						</option>` | ||||||
|  | 				)} | ||||||
| 			</select> | 			</select> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -28,9 +28,14 @@ class TfJournalAppElement extends LitElement { | |||||||
| 	async read_journals() { | 	async read_journals() { | ||||||
| 		let max_rowid; | 		let max_rowid; | ||||||
| 		let journals; | 		let journals; | ||||||
| 		while (true) | 		while (true) { | ||||||
| 		{ | 			[max_rowid, journals] = await tfrpc.rpc.collection( | ||||||
| 			[max_rowid, journals] = await tfrpc.rpc.collection([this.whoami], 'journal-entry', undefined, max_rowid, journals); | 				[this.whoami], | ||||||
|  | 				'journal-entry', | ||||||
|  | 				undefined, | ||||||
|  | 				max_rowid, | ||||||
|  | 				journals | ||||||
|  | 			); | ||||||
| 			this.journals = Object.assign({}, journals); | 			this.journals = Object.assign({}, journals); | ||||||
| 			console.log('JOURNALS', this.journals); | 			console.log('JOURNALS', this.journals); | ||||||
| 		} | 		} | ||||||
| @@ -52,7 +57,11 @@ class TfJournalAppElement extends LitElement { | |||||||
| 		}; | 		}; | ||||||
| 		message.recps = [this.whoami]; | 		message.recps = [this.whoami]; | ||||||
| 		print(message); | 		print(message); | ||||||
| 		message = await tfrpc.rpc.encrypt(this.whoami, message.recps, JSON.stringify(message)); | 		message = await tfrpc.rpc.encrypt( | ||||||
|  | 			this.whoami, | ||||||
|  | 			message.recps, | ||||||
|  | 			JSON.stringify(message) | ||||||
|  | 		); | ||||||
| 		print(message); | 		print(message); | ||||||
| 		await tfrpc.rpc.appendMessage(this.whoami, message); | 		await tfrpc.rpc.appendMessage(this.whoami, message); | ||||||
| 	} | 	} | ||||||
| @@ -62,12 +71,17 @@ class TfJournalAppElement extends LitElement { | |||||||
| 		let self = this; | 		let self = this; | ||||||
| 		return html` | 		return html` | ||||||
| 			<div> | 			<div> | ||||||
| 				<tf-id-picker .ids=${this.ids} selected=${this.whoami} @change=${this.on_whoami_changed}></tf-id-picker> | 				<tf-id-picker | ||||||
|  | 					.ids=${this.ids} | ||||||
|  | 					selected=${this.whoami} | ||||||
|  | 					@change=${this.on_whoami_changed} | ||||||
|  | 				></tf-id-picker> | ||||||
| 			</div> | 			</div> | ||||||
| 			<tf-journal-entry | 			<tf-journal-entry | ||||||
| 				whoami=${this.whoami} | 				whoami=${this.whoami} | ||||||
| 				.journals=${this.journals} | 				.journals=${this.journals} | ||||||
| 				@publish=${this.on_journal_publish}></tf-journal-entry> | 				@publish=${this.on_journal_publish} | ||||||
|  | 			></tf-journal-entry> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|   | |||||||
| @@ -30,13 +30,15 @@ class TfJournalEntryElement extends LitElement { | |||||||
|  |  | ||||||
| 	async on_publish() { | 	async on_publish() { | ||||||
| 		console.log('publish', this.text); | 		console.log('publish', this.text); | ||||||
| 		this.dispatchEvent(new CustomEvent('publish', { | 		this.dispatchEvent( | ||||||
| 			bubbles: true, | 			new CustomEvent('publish', { | ||||||
| 			detail: { | 				bubbles: true, | ||||||
| 				key: this.shadowRoot.getElementById('date_picker').value, | 				detail: { | ||||||
| 				text: this.text, | 					key: this.shadowRoot.getElementById('date_picker').value, | ||||||
| 			}, | 					text: this.text, | ||||||
| 		})); | 				}, | ||||||
|  | 			}) | ||||||
|  | 		); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	back_dates(count) { | 	back_dates(count) { | ||||||
| @@ -63,19 +65,30 @@ class TfJournalEntryElement extends LitElement { | |||||||
| 		console.log('RENDER ENTRY', this.key, this.journals?.[this.key]); | 		console.log('RENDER ENTRY', this.key, this.journals?.[this.key]); | ||||||
| 		return html` | 		return html` | ||||||
| 			<select id="date_picker" @change=${this.on_date_change}> | 			<select id="date_picker" @change=${this.on_date_change}> | ||||||
| 				${this.back_dates(10).map(x => html` | 				${this.back_dates(10).map( | ||||||
| 					<option value=${x}>${x}</option> | 					(x) => html` <option value=${x}>${x}</option> ` | ||||||
| 				`)} | 				)} | ||||||
| 			</select> | 			</select> | ||||||
| 			<div style="display: inline-flex; flex-direction: row"> | 			<div style="display: inline-flex; flex-direction: row"> | ||||||
| 				<button ?disabled=${this.text == this.journals?.[this.key]?.text} @click=${this.on_publish}>Publish</button> | 				<button | ||||||
|  | 					?disabled=${this.text == this.journals?.[this.key]?.text} | ||||||
|  | 					@click=${this.on_publish} | ||||||
|  | 				> | ||||||
|  | 					Publish | ||||||
|  | 				</button> | ||||||
| 				<button @click=${this.on_discard}>Discard</button> | 				<button @click=${this.on_discard}>Discard</button> | ||||||
| 			</div> | 			</div> | ||||||
| 			<div style="display: flex; flex-direction: row"> | 			<div style="display: flex; flex-direction: row"> | ||||||
| 				<textarea | 				<textarea | ||||||
| 					style="flex: 1 1; min-height: 10em" | 					style="flex: 1 1; min-height: 10em" | ||||||
| 					@input=${this.on_edit} .value=${this.text ?? this.journals?.[this.key]?.text ?? ''}></textarea> | 					@input=${this.on_edit} | ||||||
| 				<div style="flex: 1 1">${unsafeHTML(this.markdown(this.text ?? this.journals?.[this.key]?.text))}</div> | 					.value=${this.text ?? this.journals?.[this.key]?.text ?? ''} | ||||||
|  | 				></textarea> | ||||||
|  | 				<div style="flex: 1 1"> | ||||||
|  | 					${unsafeHTML( | ||||||
|  | 						this.markdown(this.text ?? this.journals?.[this.key]?.text) | ||||||
|  | 					)} | ||||||
|  | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| { | { | ||||||
|   "type": "tildefriends-app", | 	"type": "tildefriends-app", | ||||||
|   "emoji": "👟" | 	"emoji": "👟" | ||||||
| } | } | ||||||
| @@ -1,12 +1,14 @@ | |||||||
| <!DOCTYPE html> | <!doctype html> | ||||||
| <html style="color: #fff"> | <html style="color: #fff"> | ||||||
| 	<head> | 	<head> | ||||||
| 		<title>Tilde Friends</title> | 		<title>Tilde Friends</title> | ||||||
| 		<base target="_top"> | 		<base target="_top" /> | ||||||
| 	</head> | 	</head> | ||||||
| 	<body> | 	<body> | ||||||
| 		<tf-sneaker-app/> | 		<tf-sneaker-app /> | ||||||
| 		<script>window.litDisableBundleWarning = true;</script> | 		<script> | ||||||
|  | 			window.litDisableBundleWarning = true; | ||||||
|  | 		</script> | ||||||
| 		<script src="filesaver.min.js"></script> | 		<script src="filesaver.min.js"></script> | ||||||
| 		<script src="jszip.min.js"></script> | 		<script src="jszip.min.js"></script> | ||||||
| 		<script src="script.js" type="module"></script> | 		<script src="script.js" type="module"></script> | ||||||
|   | |||||||
| @@ -19,7 +19,8 @@ class TfSneakerAppElement extends LitElement { | |||||||
|  |  | ||||||
| 	async search() { | 	async search() { | ||||||
| 		let q = this.renderRoot.getElementById('search').value; | 		let q = this.renderRoot.getElementById('search').value; | ||||||
| 		let result = await tfrpc.rpc.query(` | 		let result = await tfrpc.rpc.query( | ||||||
|  | 			` | ||||||
| 			SELECT messages.author AS id, json_extract(messages.content, '$.name') AS name | 			SELECT messages.author AS id, json_extract(messages.content, '$.name') AS name | ||||||
| 			FROM messages_fts(?) | 			FROM messages_fts(?) | ||||||
| 			JOIN messages ON messages.rowid = messages_fts.rowid | 			JOIN messages ON messages.rowid = messages_fts.rowid | ||||||
| @@ -31,8 +32,9 @@ class TfSneakerAppElement extends LitElement { | |||||||
| 			HAVING MAX(messages.sequence) | 			HAVING MAX(messages.sequence) | ||||||
| 			ORDER BY COUNT(*) DESC | 			ORDER BY COUNT(*) DESC | ||||||
| 			`, | 			`, | ||||||
| 			[`"${q.replaceAll('"', '""')}"`]); | 			[`"${q.replaceAll('"', '""')}"`] | ||||||
| 		this.feeds = Object.fromEntries(result.map(x => [x.id, x.name])); | 		); | ||||||
|  | 		this.feeds = Object.fromEntries(result.map((x) => [x.id, x.name])); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	format_message(message) { | 	format_message(message) { | ||||||
| @@ -70,24 +72,104 @@ class TfSneakerAppElement extends LitElement { | |||||||
| 			return true; | 			return true; | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if (startsWith(data, [0xff, 0xd8, 0xff, 0xdb]) || | 		if ( | ||||||
| 			startsWith(data, [0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]) || | 			startsWith(data, [0xff, 0xd8, 0xff, 0xdb]) || | ||||||
|  | 			startsWith( | ||||||
|  | 				data, | ||||||
|  | 				[0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01] | ||||||
|  | 			) || | ||||||
| 			startsWith(data, [0xff, 0xd8, 0xff, 0xee]) || | 			startsWith(data, [0xff, 0xd8, 0xff, 0xee]) || | ||||||
| 			startsWith(data, [0xff, 0xd8, 0xff, 0xe1, null, null, 0x45, 0x78, 0x69, 0x66, 0x00, 0x00])) { | 			startsWith(data, [ | ||||||
|  | 				0xff, | ||||||
|  | 				0xd8, | ||||||
|  | 				0xff, | ||||||
|  | 				0xe1, | ||||||
|  | 				null, | ||||||
|  | 				null, | ||||||
|  | 				0x45, | ||||||
|  | 				0x78, | ||||||
|  | 				0x69, | ||||||
|  | 				0x66, | ||||||
|  | 				0x00, | ||||||
|  | 				0x00, | ||||||
|  | 			]) | ||||||
|  | 		) { | ||||||
| 			return '.jpg'; | 			return '.jpg'; | ||||||
| 		} else if (startsWith(data, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { | 		} else if ( | ||||||
|  | 			startsWith(data, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) | ||||||
|  | 		) { | ||||||
| 			return '.png'; | 			return '.png'; | ||||||
| 		} else if (startsWith(data, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) || | 		} else if ( | ||||||
| 			startsWith(data, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61])) { | 			startsWith(data, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) || | ||||||
|  | 			startsWith(data, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]) | ||||||
|  | 		) { | ||||||
| 			return '.gif'; | 			return '.gif'; | ||||||
| 		} else if (startsWith(data, [0x52, 0x49, 0x46, 0x46, null, null, null, null, 0x57, 0x45, 0x42, 0x50])) { | 		} else if ( | ||||||
|  | 			startsWith(data, [ | ||||||
|  | 				0x52, | ||||||
|  | 				0x49, | ||||||
|  | 				0x46, | ||||||
|  | 				0x46, | ||||||
|  | 				null, | ||||||
|  | 				null, | ||||||
|  | 				null, | ||||||
|  | 				null, | ||||||
|  | 				0x57, | ||||||
|  | 				0x45, | ||||||
|  | 				0x42, | ||||||
|  | 				0x50, | ||||||
|  | 			]) | ||||||
|  | 		) { | ||||||
| 			return '.webp'; | 			return '.webp'; | ||||||
| 		} else if (startsWith(data, [0x3c, 0x73, 0x76, 0x67])) { | 		} else if (startsWith(data, [0x3c, 0x73, 0x76, 0x67])) { | ||||||
| 			return '.svg'; | 			return '.svg'; | ||||||
| 		} else if (startsWith(data, [null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34, 0x32])) { | 		} else if ( | ||||||
|  | 			startsWith(data, [ | ||||||
|  | 				null, | ||||||
|  | 				null, | ||||||
|  | 				null, | ||||||
|  | 				null, | ||||||
|  | 				0x66, | ||||||
|  | 				0x74, | ||||||
|  | 				0x79, | ||||||
|  | 				0x70, | ||||||
|  | 				0x6d, | ||||||
|  | 				0x70, | ||||||
|  | 				0x34, | ||||||
|  | 				0x32, | ||||||
|  | 			]) | ||||||
|  | 		) { | ||||||
| 			return '.mp3'; | 			return '.mp3'; | ||||||
| 		} else if (startsWith(data, [null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]) || | 		} else if ( | ||||||
| 			startsWith(data, [null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34, 0x32])) { | 			startsWith(data, [ | ||||||
|  | 				null, | ||||||
|  | 				null, | ||||||
|  | 				null, | ||||||
|  | 				null, | ||||||
|  | 				0x66, | ||||||
|  | 				0x74, | ||||||
|  | 				0x79, | ||||||
|  | 				0x70, | ||||||
|  | 				0x69, | ||||||
|  | 				0x73, | ||||||
|  | 				0x6f, | ||||||
|  | 				0x6d, | ||||||
|  | 			]) || | ||||||
|  | 			startsWith(data, [ | ||||||
|  | 				null, | ||||||
|  | 				null, | ||||||
|  | 				null, | ||||||
|  | 				null, | ||||||
|  | 				0x66, | ||||||
|  | 				0x74, | ||||||
|  | 				0x79, | ||||||
|  | 				0x70, | ||||||
|  | 				0x6d, | ||||||
|  | 				0x70, | ||||||
|  | 				0x34, | ||||||
|  | 				0x32, | ||||||
|  | 			]) | ||||||
|  | 		) { | ||||||
| 			return '.mp4'; | 			return '.mp4'; | ||||||
| 		} else { | 		} else { | ||||||
| 			return '.bin'; | 			return '.bin'; | ||||||
| @@ -98,17 +180,29 @@ class TfSneakerAppElement extends LitElement { | |||||||
| 		let all_messages = ''; | 		let all_messages = ''; | ||||||
| 		let sequence = -1; | 		let sequence = -1; | ||||||
| 		let messages_done = 0; | 		let messages_done = 0; | ||||||
| 		let messages_max = (await tfrpc.rpc.query('SELECT MAX(sequence) AS total FROM messages WHERE author = ?', [id]))[0].total; | 		let messages_max = ( | ||||||
|  | 			await tfrpc.rpc.query( | ||||||
|  | 				'SELECT MAX(sequence) AS total FROM messages WHERE author = ?', | ||||||
|  | 				[id] | ||||||
|  | 			) | ||||||
|  | 		)[0].total; | ||||||
| 		while (true) { | 		while (true) { | ||||||
| 			let messages = await tfrpc.rpc.query( | 			let messages = await tfrpc.rpc.query( | ||||||
| 					'SELECT * FROM messages WHERE author = ? AND SEQUENCE > ? ORDER BY sequence LIMIT 100', | 				'SELECT * FROM messages WHERE author = ? AND SEQUENCE > ? ORDER BY sequence LIMIT 100', | ||||||
| 					[id, sequence] | 				[id, sequence] | ||||||
| 			); | 			); | ||||||
| 			if (messages?.length) { | 			if (messages?.length) { | ||||||
| 				all_messages += messages.map(x => JSON.stringify(this.format_message(x))).join('\n') + '\n'; | 				all_messages += | ||||||
|  | 					messages | ||||||
|  | 						.map((x) => JSON.stringify(this.format_message(x))) | ||||||
|  | 						.join('\n') + '\n'; | ||||||
| 				sequence = messages[messages.length - 1].sequence; | 				sequence = messages[messages.length - 1].sequence; | ||||||
| 				messages_done += messages.length; | 				messages_done += messages.length; | ||||||
| 				this.progress = {name: 'messages', value: messages_done, max: messages_max}; | 				this.progress = { | ||||||
|  | 					name: 'messages', | ||||||
|  | 					value: messages_done, | ||||||
|  | 					max: messages_max, | ||||||
|  | 				}; | ||||||
| 			} else { | 			} else { | ||||||
| 				break; | 				break; | ||||||
| 			} | 			} | ||||||
| @@ -122,7 +216,8 @@ class TfSneakerAppElement extends LitElement { | |||||||
| 			FROM messages | 			FROM messages | ||||||
| 			JOIN messages_refs ON messages.id = messages_refs.message | 			JOIN messages_refs ON messages.id = messages_refs.message | ||||||
| 			WHERE messages.author = ? AND messages_refs.ref LIKE '&%.sha256'`, | 			WHERE messages.author = ? AND messages_refs.ref LIKE '&%.sha256'`, | ||||||
| 			[id]); | 			[id] | ||||||
|  | 		); | ||||||
| 		let blobs_done = 0; | 		let blobs_done = 0; | ||||||
| 		for (let row of blobs) { | 		for (let row of blobs) { | ||||||
| 			this.progress = {name: 'blobs', value: blobs_done, max: blobs.length}; | 			this.progress = {name: 'blobs', value: blobs_done, max: blobs.length}; | ||||||
| @@ -133,7 +228,10 @@ class TfSneakerAppElement extends LitElement { | |||||||
| 				console.log(`Failed to get ${row.id}: ${e.message}`); | 				console.log(`Failed to get ${row.id}: ${e.message}`); | ||||||
| 			} | 			} | ||||||
| 			if (blob) { | 			if (blob) { | ||||||
| 				zip.file(`blob/classic/${this.sanitize(row.id)}${this.guess_ext(blob)}`, new Uint8Array(blob)); | 				zip.file( | ||||||
|  | 					`blob/classic/${this.sanitize(row.id)}${this.guess_ext(blob)}`, | ||||||
|  | 					new Uint8Array(blob) | ||||||
|  | 				); | ||||||
| 			} | 			} | ||||||
| 			blobs_done++; | 			blobs_done++; | ||||||
| 		} | 		} | ||||||
| @@ -161,7 +259,7 @@ class TfSneakerAppElement extends LitElement { | |||||||
| 		file = await zip.loadAsync(file); | 		file = await zip.loadAsync(file); | ||||||
| 		let messages = []; | 		let messages = []; | ||||||
| 		let blobs = []; | 		let blobs = []; | ||||||
| 		file.forEach(function(path, entry) { | 		file.forEach(function (path, entry) { | ||||||
| 			if (!entry.dir) { | 			if (!entry.dir) { | ||||||
| 				if (path.startsWith('message/classic/')) { | 				if (path.startsWith('message/classic/')) { | ||||||
| 					messages.push(entry); | 					messages.push(entry); | ||||||
| @@ -181,7 +279,11 @@ class TfSneakerAppElement extends LitElement { | |||||||
| 					continue; | 					continue; | ||||||
| 				} | 				} | ||||||
| 				let message = JSON.parse(line); | 				let message = JSON.parse(line); | ||||||
| 				this.progress = {name: 'messages', value: progress++, max: total_messages}; | 				this.progress = { | ||||||
|  | 					name: 'messages', | ||||||
|  | 					value: progress++, | ||||||
|  | 					max: total_messages, | ||||||
|  | 				}; | ||||||
| 				if (await tfrpc.rpc.store_message(message.value)) { | 				if (await tfrpc.rpc.store_message(message.value)) { | ||||||
| 					success.messages++; | 					success.messages++; | ||||||
| 				} | 				} | ||||||
| @@ -202,7 +304,13 @@ class TfSneakerAppElement extends LitElement { | |||||||
| 		let progress; | 		let progress; | ||||||
| 		if (this.progress) { | 		if (this.progress) { | ||||||
| 			if (this.progress.max) { | 			if (this.progress.max) { | ||||||
| 				progress = html`<div><label for="progress">${this.progress.name}</label><progress value=${this.progress.value} max=${this.progress.max}></progress></div>`; | 				progress = html`<div> | ||||||
|  | 					<label for="progress">${this.progress.name}</label | ||||||
|  | 					><progress | ||||||
|  | 						value=${this.progress.value} | ||||||
|  | 						max=${this.progress.max} | ||||||
|  | 					></progress> | ||||||
|  | 				</div>`; | ||||||
| 			} else { | 			} else { | ||||||
| 				progress = html`<div><span>${this.progress.name}</span></div>`; | 				progress = html`<div><span>${this.progress.name}</span></div>`; | ||||||
| 			} | 			} | ||||||
| @@ -218,13 +326,17 @@ class TfSneakerAppElement extends LitElement { | |||||||
| 			<input type="text" id="search" @keypress=${this.keypress}></input> | 			<input type="text" id="search" @keypress=${this.keypress}></input> | ||||||
| 			<input type="button" value="Search Users" @click=${this.search}></input> | 			<input type="button" value="Search Users" @click=${this.search}></input> | ||||||
| 			<ul> | 			<ul> | ||||||
| 				${Object.entries(this.feeds).map(([id, name]) => html` | 				${Object.entries(this.feeds).map( | ||||||
| 					<li> | 					([id, name]) => html` | ||||||
| 						${this.progress ? undefined : html`<input type="button" value="Export" @click=${() => this.export(id)}></input>`} | 						<li> | ||||||
| 						${name} | 							${this.progress | ||||||
| 						<code style="color: #ccc">${id}</code> | 								? undefined | ||||||
| 					</li> | 								: html`<input type="button" value="Export" @click=${() => this.export(id)}></input>`} | ||||||
| 				`)} | 							${name} | ||||||
|  | 							<code style="color: #ccc">${id}</code> | ||||||
|  | 						</li> | ||||||
|  | 					` | ||||||
|  | 				)} | ||||||
| 			</ul> | 			</ul> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -76,7 +76,7 @@ tfrpc.register(function getHash(id, message) { | |||||||
| tfrpc.register(function setHash(hash) { | tfrpc.register(function setHash(hash) { | ||||||
| 	return app.setHash(hash); | 	return app.setHash(hash); | ||||||
| }); | }); | ||||||
| ssb.addEventListener('message', async function(id) { | ssb.addEventListener('message', async function (id) { | ||||||
| 	await tfrpc.rpc.notifyNewMessage(id); | 	await tfrpc.rpc.notifyNewMessage(id); | ||||||
| }); | }); | ||||||
| tfrpc.register(async function store_blob(blob) { | tfrpc.register(async function store_blob(blob) { | ||||||
| @@ -100,16 +100,16 @@ tfrpc.register(async function try_decrypt(id, content) { | |||||||
| tfrpc.register(async function encrypt(id, recipients, content) { | tfrpc.register(async function encrypt(id, recipients, content) { | ||||||
| 	return await ssb.privateMessageEncrypt(id, recipients, content); | 	return await ssb.privateMessageEncrypt(id, recipients, content); | ||||||
| }); | }); | ||||||
| ssb.addEventListener('broadcasts', async function() { | ssb.addEventListener('broadcasts', async function () { | ||||||
| 	await tfrpc.rpc.set('broadcasts', await ssb.getBroadcasts()); | 	await tfrpc.rpc.set('broadcasts', await ssb.getBroadcasts()); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| core.register('onConnectionsChanged', async function() { | core.register('onConnectionsChanged', async function () { | ||||||
| 	await tfrpc.rpc.set('connections', await ssb.connections()); | 	await tfrpc.rpc.set('connections', await ssb.connections()); | ||||||
| }); | }); | ||||||
|  |  | ||||||
| async function main() { | async function main() { | ||||||
| 	if (typeof(database) !== 'undefined') { | 	if (typeof database !== 'undefined') { | ||||||
| 		g_database = await database('ssb'); | 		g_database = await database('ssb'); | ||||||
| 	} | 	} | ||||||
| 	await app.setDocument(utf8Decode(await getFile('index.html'))); | 	await app.setDocument(utf8Decode(await getFile('index.html'))); | ||||||
|   | |||||||
| @@ -4,14 +4,14 @@ function get_emojis() { | |||||||
| 	if (g_emojis) { | 	if (g_emojis) { | ||||||
| 		return Promise.resolve(g_emojis); | 		return Promise.resolve(g_emojis); | ||||||
| 	} | 	} | ||||||
| 	return fetch('emojis.json').then(function(result) { | 	return fetch('emojis.json').then(function (result) { | ||||||
| 		g_emojis = result.json(); | 		g_emojis = result.json(); | ||||||
| 		return g_emojis; | 		return g_emojis; | ||||||
| 	}); | 	}); | ||||||
| } | } | ||||||
|  |  | ||||||
| export function picker(callback, anchor) { | export function picker(callback, anchor) { | ||||||
| 	get_emojis().then(function(json) { | 	get_emojis().then(function (json) { | ||||||
| 		let div = document.createElement('div'); | 		let div = document.createElement('div'); | ||||||
| 		div.id = 'emoji_picker'; | 		div.id = 'emoji_picker'; | ||||||
| 		div.style.color = '#000'; | 		div.style.color = '#000'; | ||||||
| @@ -36,7 +36,7 @@ export function picker(callback, anchor) { | |||||||
| 		div.appendChild(input); | 		div.appendChild(input); | ||||||
| 		let list = document.createElement('div'); | 		let list = document.createElement('div'); | ||||||
| 		div.appendChild(list); | 		div.appendChild(list); | ||||||
| 		div.addEventListener('mousedown', function(event) { | 		div.addEventListener('mousedown', function (event) { | ||||||
| 			event.stopPropagation(); | 			event.stopPropagation(); | ||||||
| 		}); | 		}); | ||||||
|  |  | ||||||
| @@ -72,9 +72,11 @@ export function picker(callback, anchor) { | |||||||
| 				list.appendChild(header); | 				list.appendChild(header); | ||||||
| 				let any = false; | 				let any = false; | ||||||
| 				for (let entry of Object.entries(row[1])) { | 				for (let entry of Object.entries(row[1])) { | ||||||
| 					if (search && | 					if ( | ||||||
|  | 						search && | ||||||
| 						search.length && | 						search.length && | ||||||
| 						entry[0].toLowerCase().indexOf(search) == -1) { | 						entry[0].toLowerCase().indexOf(search) == -1 | ||||||
|  | 					) { | ||||||
| 						continue; | 						continue; | ||||||
| 					} | 					} | ||||||
| 					let emoji = document.createElement('span'); | 					let emoji = document.createElement('span'); | ||||||
|   | |||||||
| @@ -1,9 +1,9 @@ | |||||||
| <!DOCTYPE html> | <!doctype html> | ||||||
| <html style="color: #fff"> | <html style="color: #fff"> | ||||||
| 	<head> | 	<head> | ||||||
| 		<title>Tilde Friends</title> | 		<title>Tilde Friends</title> | ||||||
| 		<base target="_top"> | 		<base target="_top" /> | ||||||
| 		<link rel="stylesheet" href="tribute.css"/> | 		<link rel="stylesheet" href="tribute.css" /> | ||||||
| 		<style> | 		<style> | ||||||
| 			.tribute-container { | 			.tribute-container { | ||||||
| 				color: #000; | 				color: #000; | ||||||
| @@ -11,8 +11,10 @@ | |||||||
| 		</style> | 		</style> | ||||||
| 	</head> | 	</head> | ||||||
| 	<body style="background-color: #223a5e"> | 	<body style="background-color: #223a5e"> | ||||||
| 		<tf-app class="w3-deep-purple"/> | 		<tf-app class="w3-deep-purple" /> | ||||||
| 		<script>window.litDisableBundleWarning = true;</script> | 		<script> | ||||||
|  | 			window.litDisableBundleWarning = true; | ||||||
|  | 		</script> | ||||||
| 		<script src="filesaver.min.js"></script> | 		<script src="filesaver.min.js"></script> | ||||||
| 		<script src="commonmark.min.js"></script> | 		<script src="commonmark.min.js"></script> | ||||||
| 		<script src="commonmark-linkify.js" type="module"></script> | 		<script src="commonmark-linkify.js" type="module"></script> | ||||||
|   | |||||||
| @@ -34,9 +34,13 @@ class TfElement extends LitElement { | |||||||
| 		this.users = {}; | 		this.users = {}; | ||||||
| 		this.loaded = false; | 		this.loaded = false; | ||||||
| 		this.tags = []; | 		this.tags = []; | ||||||
| 		tfrpc.rpc.getBroadcasts().then(b => { self.broadcasts = b || []; }); | 		tfrpc.rpc.getBroadcasts().then((b) => { | ||||||
| 		tfrpc.rpc.getConnections().then(c => { self.connections = c || []; }); | 			self.broadcasts = b || []; | ||||||
| 		tfrpc.rpc.getHash().then(hash => self.set_hash(hash)); | 		}); | ||||||
|  | 		tfrpc.rpc.getConnections().then((c) => { | ||||||
|  | 			self.connections = c || []; | ||||||
|  | 		}); | ||||||
|  | 		tfrpc.rpc.getHash().then((hash) => self.set_hash(hash)); | ||||||
| 		tfrpc.register(function hashChanged(hash) { | 		tfrpc.register(function hashChanged(hash) { | ||||||
| 			self.set_hash(hash); | 			self.set_hash(hash); | ||||||
| 		}); | 		}); | ||||||
| @@ -86,9 +90,14 @@ class TfElement extends LitElement { | |||||||
| 				last_row_id: 0, | 				last_row_id: 0, | ||||||
| 			}; | 			}; | ||||||
| 		} | 		} | ||||||
| 		let max_row_id = (await tfrpc.rpc.query(` | 		let max_row_id = ( | ||||||
|  | 			await tfrpc.rpc.query( | ||||||
|  | 				` | ||||||
| 			SELECT MAX(rowid) AS max_row_id FROM messages | 			SELECT MAX(rowid) AS max_row_id FROM messages | ||||||
| 		`, []))[0].max_row_id; | 		`, | ||||||
|  | 				[] | ||||||
|  | 			) | ||||||
|  | 		)[0].max_row_id; | ||||||
| 		for (let id of Object.keys(cache.about)) { | 		for (let id of Object.keys(cache.about)) { | ||||||
| 			if (ids.indexOf(id) == -1) { | 			if (ids.indexOf(id) == -1) { | ||||||
| 				delete cache.about[id]; | 				delete cache.about[id]; | ||||||
| @@ -120,17 +129,21 @@ class TfElement extends LitElement { | |||||||
| 				ORDER BY messages.author, messages.sequence | 				ORDER BY messages.author, messages.sequence | ||||||
| 			`, | 			`, | ||||||
| 			[ | 			[ | ||||||
| 				JSON.stringify(ids.filter(id => cache.about[id])), | 				JSON.stringify(ids.filter((id) => cache.about[id])), | ||||||
| 				JSON.stringify(ids.filter(id => !cache.about[id])), | 				JSON.stringify(ids.filter((id) => !cache.about[id])), | ||||||
| 				cache.last_row_id, | 				cache.last_row_id, | ||||||
| 				max_row_id, | 				max_row_id, | ||||||
| 			]); | 			] | ||||||
|  | 		); | ||||||
| 		for (let about of abouts) { | 		for (let about of abouts) { | ||||||
| 			let content = JSON.parse(about.content); | 			let content = JSON.parse(about.content); | ||||||
| 			if (content.about === about.author) { | 			if (content.about === about.author) { | ||||||
| 				delete content.type; | 				delete content.type; | ||||||
| 				delete content.about; | 				delete content.about; | ||||||
| 				cache.about[about.author] = Object.assign(cache.about[about.author] || {}, content); | 				cache.about[about.author] = Object.assign( | ||||||
|  | 					cache.about[about.author] || {}, | ||||||
|  | 					content | ||||||
|  | 				); | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 		cache.last_row_id = max_row_id; | 		cache.last_row_id = max_row_id; | ||||||
| @@ -150,10 +163,8 @@ class TfElement extends LitElement { | |||||||
| 				JOIN json_each(?) AS following ON messages.author = following.value | 				JOIN json_each(?) AS following ON messages.author = following.value | ||||||
| 				WHERE messages.id = ? | 				WHERE messages.id = ? | ||||||
| 			`, | 			`, | ||||||
| 			[ | 			[JSON.stringify(this.following), id] | ||||||
| 				JSON.stringify(this.following), | 		); | ||||||
| 				id, |  | ||||||
| 			]); |  | ||||||
| 		if (messages && messages.length) { | 		if (messages && messages.length) { | ||||||
| 			this.unread = [...this.unread, ...messages]; | 			this.unread = [...this.unread, ...messages]; | ||||||
| 			this.unread = this.unread.slice(this.unread.length - 1024); | 			this.unread = this.unread.slice(this.unread.length - 1024); | ||||||
| @@ -173,7 +184,7 @@ class TfElement extends LitElement { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	async create_identity() { | 	async create_identity() { | ||||||
| 		if (confirm("Are you sure you want to create a new identity?")) { | 		if (confirm('Are you sure you want to create a new identity?')) { | ||||||
| 			await tfrpc.rpc.createIdentity(); | 			await tfrpc.rpc.createIdentity(); | ||||||
| 			this.ids = (await tfrpc.rpc.getIdentities()) || []; | 			this.ids = (await tfrpc.rpc.getIdentities()) || []; | ||||||
| 			if (this.ids && !this.whoami) { | 			if (this.ids && !this.whoami) { | ||||||
| @@ -185,15 +196,30 @@ class TfElement extends LitElement { | |||||||
| 	render_id_picker() { | 	render_id_picker() { | ||||||
| 		return html` | 		return html` | ||||||
| 			<div style="display: flex; gap: 8px"> | 			<div style="display: flex; gap: 8px"> | ||||||
| 				<tf-id-picker id="picker" style="flex: 1 1 auto" selected=${this.whoami} .ids=${this.ids} .users=${this.users} @change=${this._handle_whoami_changed}></tf-id-picker> | 				<tf-id-picker | ||||||
| 				<button class="w3-button w3-dark-grey w3-border" style="flex: 0 0 auto" @click=${this.create_identity} id="create_identity">Create Identity</button> | 					id="picker" | ||||||
|  | 					style="flex: 1 1 auto" | ||||||
|  | 					selected=${this.whoami} | ||||||
|  | 					.ids=${this.ids} | ||||||
|  | 					.users=${this.users} | ||||||
|  | 					@change=${this._handle_whoami_changed} | ||||||
|  | 				></tf-id-picker> | ||||||
|  | 				<button | ||||||
|  | 					class="w3-button w3-dark-grey w3-border" | ||||||
|  | 					style="flex: 0 0 auto" | ||||||
|  | 					@click=${this.create_identity} | ||||||
|  | 					id="create_identity" | ||||||
|  | 				> | ||||||
|  | 					Create Identity | ||||||
|  | 				</button> | ||||||
| 			</div> | 			</div> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	async load_recent_tags() { | 	async load_recent_tags() { | ||||||
| 		let start = new Date(); | 		let start = new Date(); | ||||||
| 		this.tags = await tfrpc.rpc.query(` | 		this.tags = await tfrpc.rpc.query( | ||||||
|  | 			` | ||||||
| 			WITH | 			WITH | ||||||
| 				recent AS (SELECT id, content FROM messages | 				recent AS (SELECT id, content FROM messages | ||||||
| 					WHERE messages.timestamp > ? AND json_extract(content, '$.type') = 'post' | 					WHERE messages.timestamp > ? AND json_extract(content, '$.type') = 'post' | ||||||
| @@ -207,7 +233,9 @@ class TfElement extends LitElement { | |||||||
| 				combined AS (SELECT id, tag FROM recent_channels UNION ALL SELECT id, tag FROM recent_mentions), | 				combined AS (SELECT id, tag FROM recent_channels UNION ALL SELECT id, tag FROM recent_mentions), | ||||||
| 				by_message AS (SELECT DISTINCT id, tag FROM combined) | 				by_message AS (SELECT DISTINCT id, tag FROM combined) | ||||||
| 			SELECT tag, COUNT(*) AS count FROM by_message GROUP BY tag ORDER BY count DESC LIMIT 10 | 			SELECT tag, COUNT(*) AS count FROM by_message GROUP BY tag ORDER BY count DESC LIMIT 10 | ||||||
| 		`, [new Date() - 7 * 24 * 60 * 60 * 1000]); | 		`, | ||||||
|  | 			[new Date() - 7 * 24 * 60 * 60 * 1000] | ||||||
|  | 		); | ||||||
| 		console.log('tags took', (new Date() - start) / 1000.0, 'seconds'); | 		console.log('tags took', (new Date() - start) / 1000.0, 'seconds'); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -241,23 +269,53 @@ class TfElement extends LitElement { | |||||||
| 		let users = this.users; | 		let users = this.users; | ||||||
| 		if (this.tab === 'news') { | 		if (this.tab === 'news') { | ||||||
| 			return html` | 			return html` | ||||||
| 				<tf-tab-news id="tf-tab-news" .following=${this.following} whoami=${this.whoami} .users=${this.users} hash=${this.hash} .unread=${this.unread} @refresh=${() => this.unread = []}></tf-tab-news> | 				<tf-tab-news | ||||||
|  | 					id="tf-tab-news" | ||||||
|  | 					.following=${this.following} | ||||||
|  | 					whoami=${this.whoami} | ||||||
|  | 					.users=${this.users} | ||||||
|  | 					hash=${this.hash} | ||||||
|  | 					.unread=${this.unread} | ||||||
|  | 					@refresh=${() => (this.unread = [])} | ||||||
|  | 				></tf-tab-news> | ||||||
| 			`; | 			`; | ||||||
| 		} else if (this.tab === 'connections') { | 		} else if (this.tab === 'connections') { | ||||||
| 			return html` | 			return html` | ||||||
| 				<tf-tab-connections .users=${this.users} .connections=${this.connections} .broadcasts=${this.broadcasts}></tf-tab-connections> | 				<tf-tab-connections | ||||||
|  | 					.users=${this.users} | ||||||
|  | 					.connections=${this.connections} | ||||||
|  | 					.broadcasts=${this.broadcasts} | ||||||
|  | 				></tf-tab-connections> | ||||||
| 			`; | 			`; | ||||||
| 		} else if (this.tab === 'mentions') { | 		} else if (this.tab === 'mentions') { | ||||||
| 			return html` | 			return html` | ||||||
| 				<tf-tab-mentions .following=${this.following} whoami=${this.whoami} .users=${this.users}}></tf-tab-mentions> | 				<tf-tab-mentions | ||||||
|  | 					.following=${this.following} | ||||||
|  | 					whoami=${this.whoami} | ||||||
|  | 					.users="${this.users}}" | ||||||
|  | 				></tf-tab-mentions> | ||||||
| 			`; | 			`; | ||||||
| 		} else if (this.tab === 'search') { | 		} else if (this.tab === 'search') { | ||||||
| 			return html` | 			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> | 				<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> | ||||||
| 			`; | 			`; | ||||||
| 		} else if (this.tab === 'query') { | 		} else if (this.tab === 'query') { | ||||||
| 			return html` | 			return html` | ||||||
| 				<tf-tab-query .following=${this.following} whoami=${this.whoami} .users=${this.users} query=${this.hash?.startsWith('#sql=') ? decodeURIComponent(this.hash.substring(5)) : null}></tf-tab-query> | 				<tf-tab-query | ||||||
|  | 					.following=${this.following} | ||||||
|  | 					whoami=${this.whoami} | ||||||
|  | 					.users=${this.users} | ||||||
|  | 					query=${this.hash?.startsWith('#sql=') | ||||||
|  | 						? decodeURIComponent(this.hash.substring(5)) | ||||||
|  | 						: null} | ||||||
|  | 				></tf-tab-query> | ||||||
| 			`; | 			`; | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| @@ -280,7 +338,7 @@ class TfElement extends LitElement { | |||||||
|  |  | ||||||
| 		if (!this.loading && this.whoami && this.loaded !== this.whoami) { | 		if (!this.loading && this.whoami && this.loaded !== this.whoami) { | ||||||
| 			this.loading = true; | 			this.loading = true; | ||||||
| 			this.load().finally(function() { | 			this.load().finally(function () { | ||||||
| 				self.loading = false; | 				self.loading = false; | ||||||
| 			}); | 			}); | ||||||
| 		} | 		} | ||||||
| @@ -295,21 +353,32 @@ class TfElement extends LitElement { | |||||||
|  |  | ||||||
| 		let tabs = html` | 		let tabs = html` | ||||||
| 			<div class="w3-bar w3-black"> | 			<div class="w3-bar w3-black"> | ||||||
| 				${Object.entries(k_tabs).map(([k, v]) => html` | 				${Object.entries(k_tabs).map( | ||||||
| 				<button title=${v} class="w3-bar-item w3-padding-large w3-hover-gray tab ${self.tab == v ? 'w3-red' : 'w3-black'}" @click=${() => self.set_tab(v)}>${k}</button> | 					([k, v]) => html` | ||||||
| 				`)} | 						<button | ||||||
|  | 							title=${v} | ||||||
|  | 							class="w3-bar-item w3-padding-large w3-hover-gray tab ${self.tab == | ||||||
|  | 							v | ||||||
|  | 								? 'w3-red' | ||||||
|  | 								: 'w3-black'}" | ||||||
|  | 							@click=${() => self.set_tab(v)} | ||||||
|  | 						> | ||||||
|  | 							${k} | ||||||
|  | 						</button> | ||||||
|  | 					` | ||||||
|  | 				)} | ||||||
| 			</div> | 			</div> | ||||||
| 		`; | 		`; | ||||||
| 		let contents = | 		let contents = !this.loaded | ||||||
| 				!this.loaded ? | 			? this.loading | ||||||
| 					this.loading ? | 				? html`<div>Loading...</div>` | ||||||
| 						html`<div>Loading...</div>` : | 				: html`<div>Select or create an identity.</div>` | ||||||
| 						html`<div>Select or create an identity.</div>` : | 			: this.render_tab(); | ||||||
| 					this.render_tab(); |  | ||||||
| 		return html` | 		return html` | ||||||
| 			${this.render_id_picker()} | 			${this.render_id_picker()} ${tabs} | ||||||
| 			${tabs} | 			${this.tags.map( | ||||||
| 			${this.tags.map(x => html`<tf-tag tag=${x.tag} count=${x.count}></tf-tag>`)} | 				(x) => html`<tf-tag tag=${x.tag} count=${x.count}></tf-tag>` | ||||||
|  | 			)} | ||||||
| 			${contents} | 			${contents} | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -58,7 +58,9 @@ class TfComposeElement extends LitElement { | |||||||
| 					link: link, | 					link: link, | ||||||
| 				}; | 				}; | ||||||
| 			} | 			} | ||||||
| 			draft.mentions[link].name = name.startsWith('@') ? name.substring(1) : name; | 			draft.mentions[link].name = name.startsWith('@') | ||||||
|  | 				? name.substring(1) | ||||||
|  | 				: name; | ||||||
| 			updated = true; | 			updated = true; | ||||||
| 		} | 		} | ||||||
| 		if (updated) { | 		if (updated) { | ||||||
| @@ -72,34 +74,39 @@ class TfComposeElement extends LitElement { | |||||||
| 		let preview = this.renderRoot.getElementById('preview'); | 		let preview = this.renderRoot.getElementById('preview'); | ||||||
| 		preview.innerHTML = this.process_text(edit.value); | 		preview.innerHTML = this.process_text(edit.value); | ||||||
| 		let content_warning = this.renderRoot.getElementById('content_warning'); | 		let content_warning = this.renderRoot.getElementById('content_warning'); | ||||||
| 		let content_warning_preview = this.renderRoot.getElementById('content_warning_preview'); | 		let content_warning_preview = this.renderRoot.getElementById( | ||||||
|  | 			'content_warning_preview' | ||||||
|  | 		); | ||||||
| 		if (content_warning && content_warning_preview) { | 		if (content_warning && content_warning_preview) { | ||||||
| 			content_warning_preview.innerText = content_warning.value; | 			content_warning_preview.innerText = content_warning.value; | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	notify(draft) { | 	notify(draft) { | ||||||
| 		this.dispatchEvent(new CustomEvent('tf-draft', { | 		this.dispatchEvent( | ||||||
| 			bubbles: true, | 			new CustomEvent('tf-draft', { | ||||||
| 			composed: true, | 				bubbles: true, | ||||||
| 			detail: { | 				composed: true, | ||||||
| 				id: this.branch, | 				detail: { | ||||||
| 				draft: draft | 					id: this.branch, | ||||||
| 			}, | 					draft: draft, | ||||||
| 		})); | 				}, | ||||||
|  | 			}) | ||||||
|  | 		); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	change() { | 	change() { | ||||||
| 		let draft = this.get_draft(); | 		let draft = this.get_draft(); | ||||||
| 		draft.text = this.renderRoot.getElementById('edit')?.value; | 		draft.text = this.renderRoot.getElementById('edit')?.value; | ||||||
| 		draft.content_warning = this.renderRoot.getElementById('content_warning')?.value; | 		draft.content_warning = | ||||||
|  | 			this.renderRoot.getElementById('content_warning')?.value; | ||||||
| 		this.notify(draft); | 		this.notify(draft); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	convert_to_format(buffer, type, mime_type) { | 	convert_to_format(buffer, type, mime_type) { | ||||||
| 		return new Promise(function(resolve, reject) { | 		return new Promise(function (resolve, reject) { | ||||||
| 			let img = new Image(); | 			let img = new Image(); | ||||||
| 			img.onload = function() { | 			img.onload = function () { | ||||||
| 				let canvas = document.createElement('canvas'); | 				let canvas = document.createElement('canvas'); | ||||||
| 				let width_scale = Math.min(img.width, 1024) / img.width; | 				let width_scale = Math.min(img.width, 1024) / img.width; | ||||||
| 				let height_scale = Math.min(img.height, 1024) / img.height; | 				let height_scale = Math.min(img.height, 1024) / img.height; | ||||||
| @@ -109,13 +116,17 @@ class TfComposeElement extends LitElement { | |||||||
| 				let context = canvas.getContext('2d'); | 				let context = canvas.getContext('2d'); | ||||||
| 				context.drawImage(img, 0, 0, canvas.width, canvas.height); | 				context.drawImage(img, 0, 0, canvas.width, canvas.height); | ||||||
| 				let data_url = canvas.toDataURL(mime_type); | 				let data_url = canvas.toDataURL(mime_type); | ||||||
| 				let result = atob(data_url.split(',')[1]).split('').map(x => x.charCodeAt(0)); | 				let result = atob(data_url.split(',')[1]) | ||||||
|  | 					.split('') | ||||||
|  | 					.map((x) => x.charCodeAt(0)); | ||||||
| 				resolve(result); | 				resolve(result); | ||||||
| 			}; | 			}; | ||||||
| 			img.onerror = function(event) { | 			img.onerror = function (event) { | ||||||
| 				reject(new Error('Failed to load image.')); | 				reject(new Error('Failed to load image.')); | ||||||
| 			}; | 			}; | ||||||
| 			let raw = Array.from(new Uint8Array(buffer)).map(b => String.fromCharCode(b)).join(''); | 			let raw = Array.from(new Uint8Array(buffer)) | ||||||
|  | 				.map((b) => String.fromCharCode(b)) | ||||||
|  | 				.join(''); | ||||||
| 			let original = `data:${type};base64,${btoa(raw)}`; | 			let original = `data:${type};base64,${btoa(raw)}`; | ||||||
| 			img.src = original; | 			img.src = original; | ||||||
| 		}); | 		}); | ||||||
| @@ -131,7 +142,11 @@ class TfComposeElement extends LitElement { | |||||||
| 				let best_buffer; | 				let best_buffer; | ||||||
| 				let best_type; | 				let best_type; | ||||||
| 				for (let format of ['image/png', 'image/jpeg', 'image/webp']) { | 				for (let format of ['image/png', 'image/jpeg', 'image/webp']) { | ||||||
| 					let test_buffer = await self.convert_to_format(buffer, file.type, format); | 					let test_buffer = await self.convert_to_format( | ||||||
|  | 						buffer, | ||||||
|  | 						file.type, | ||||||
|  | 						format | ||||||
|  | 					); | ||||||
| 					if (!best_buffer || test_buffer.length < best_buffer.length) { | 					if (!best_buffer || test_buffer.length < best_buffer.length) { | ||||||
| 						best_buffer = test_buffer; | 						best_buffer = test_buffer; | ||||||
| 						best_type = format; | 						best_type = format; | ||||||
| @@ -157,7 +172,7 @@ class TfComposeElement extends LitElement { | |||||||
| 			edit.value += `\n`; | 			edit.value += `\n`; | ||||||
| 			self.change(); | 			self.change(); | ||||||
| 			self.input(); | 			self.input(); | ||||||
| 		} catch(e) { | 		} catch (e) { | ||||||
| 			alert(e?.message); | 			alert(e?.message); | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| @@ -201,11 +216,15 @@ class TfComposeElement extends LitElement { | |||||||
| 			to = [...to]; | 			to = [...to]; | ||||||
| 			message.recps = to; | 			message.recps = to; | ||||||
| 			console.log('message is now', message); | 			console.log('message is now', message); | ||||||
| 			message = await tfrpc.rpc.encrypt(this.whoami, to, JSON.stringify(message)); | 			message = await tfrpc.rpc.encrypt( | ||||||
|  | 				this.whoami, | ||||||
|  | 				to, | ||||||
|  | 				JSON.stringify(message) | ||||||
|  | 			); | ||||||
| 			console.log('encrypted as', message); | 			console.log('encrypted as', message); | ||||||
| 		} | 		} | ||||||
| 		try { | 		try { | ||||||
| 			await tfrpc.rpc.appendMessage(this.whoami, message).then(function() { | 			await tfrpc.rpc.appendMessage(this.whoami, message).then(function () { | ||||||
| 				edit.value = ''; | 				edit.value = ''; | ||||||
| 				self.change(); | 				self.change(); | ||||||
| 				self.notify(undefined); | 				self.notify(undefined); | ||||||
| @@ -230,7 +249,7 @@ class TfComposeElement extends LitElement { | |||||||
| 		let edit = this.renderRoot.getElementById('edit'); | 		let edit = this.renderRoot.getElementById('edit'); | ||||||
| 		let input = document.createElement('input'); | 		let input = document.createElement('input'); | ||||||
| 		input.type = 'file'; | 		input.type = 'file'; | ||||||
| 		input.onchange = function(event) { | 		input.onchange = function (event) { | ||||||
| 			let file = event.target.files[0]; | 			let file = event.target.files[0]; | ||||||
| 			self.add_file(file); | 			self.add_file(file); | ||||||
| 		}; | 		}; | ||||||
| @@ -241,12 +260,15 @@ class TfComposeElement extends LitElement { | |||||||
| 		this.last_autocomplete = text; | 		this.last_autocomplete = text; | ||||||
| 		let results = []; | 		let results = []; | ||||||
| 		try { | 		try { | ||||||
| 			let rows = await tfrpc.rpc.query(` | 			let rows = await tfrpc.rpc.query( | ||||||
|  | 				` | ||||||
| 				SELECT messages.content FROM messages_fts(?) | 				SELECT messages.content FROM messages_fts(?) | ||||||
| 				JOIN messages ON messages.rowid = messages_fts.rowid | 				JOIN messages ON messages.rowid = messages_fts.rowid | ||||||
| 				WHERE messages.content LIKE ? | 				WHERE messages.content LIKE ? | ||||||
| 				ORDER BY timestamp DESC LIMIT 10 | 				ORDER BY timestamp DESC LIMIT 10 | ||||||
| 			`, ['"' + text.replace('"', '""') + '"', `%%`]); | 			`, | ||||||
|  | 				['"' + text.replace('"', '""') + '"', `%%`] | ||||||
|  | 			); | ||||||
| 			for (let row of rows) { | 			for (let row of rows) { | ||||||
| 				for (let match of row.content.matchAll(/!\[([^\]]*)\]\((&.*?)\)/g)) { | 				for (let match of row.content.matchAll(/!\[([^\]]*)\]\((&.*?)\)/g)) { | ||||||
| 					if (match[1].toLowerCase().indexOf(text.toLowerCase()) != -1) { | 					if (match[1].toLowerCase().indexOf(text.toLowerCase()) != -1) { | ||||||
| @@ -265,15 +287,18 @@ class TfComposeElement extends LitElement { | |||||||
| 		let tribute = new Tribute({ | 		let tribute = new Tribute({ | ||||||
| 			collection: [ | 			collection: [ | ||||||
| 				{ | 				{ | ||||||
| 					values: Object.entries(this.users).map(x => ({key: x[1].name, value: x[0]})), | 					values: Object.entries(this.users).map((x) => ({ | ||||||
| 					selectTemplate: function(item) { | 						key: x[1].name, | ||||||
|  | 						value: x[0], | ||||||
|  | 					})), | ||||||
|  | 					selectTemplate: function (item) { | ||||||
| 						return `[@${item.original.key}](${item.original.value})`; | 						return `[@${item.original.key}](${item.original.value})`; | ||||||
| 					}, | 					}, | ||||||
| 				}, | 				}, | ||||||
| 				{ | 				{ | ||||||
| 					trigger: '&', | 					trigger: '&', | ||||||
| 					values: this.autocomplete, | 					values: this.autocomplete, | ||||||
| 					selectTemplate: function(item) { | 					selectTemplate: function (item) { | ||||||
| 						return ``; | 						return ``; | ||||||
| 					}, | 					}, | ||||||
| 				}, | 				}, | ||||||
| @@ -293,8 +318,11 @@ class TfComposeElement extends LitElement { | |||||||
| 		let encrypt = this.renderRoot.getElementById('encrypt_to'); | 		let encrypt = this.renderRoot.getElementById('encrypt_to'); | ||||||
| 		if (encrypt) { | 		if (encrypt) { | ||||||
| 			let tribute = new Tribute({ | 			let tribute = new Tribute({ | ||||||
| 				values: Object.entries(this.users).map(x => ({key: x[1].name, value: x[0]})), | 				values: Object.entries(this.users).map((x) => ({ | ||||||
| 				selectTemplate: function(item) { | 					key: x[1].name, | ||||||
|  | 					value: x[0], | ||||||
|  | 				})), | ||||||
|  | 				selectTemplate: function (item) { | ||||||
| 					return item.original.value; | 					return item.original.value; | ||||||
| 				}, | 				}, | ||||||
| 			}); | 			}); | ||||||
| @@ -311,20 +339,30 @@ class TfComposeElement extends LitElement { | |||||||
|  |  | ||||||
| 	render_mention(mention) { | 	render_mention(mention) { | ||||||
| 		let self = this; | 		let self = this; | ||||||
| 		return html` | 		return html` <div style="display: flex; flex-direction: row"> | ||||||
| 			<div style="display: flex; flex-direction: row"> | 			<div style="align-self: center; margin: 0.5em"> | ||||||
| 				<div style="align-self: center; margin: 0.5em"> | 				<button | ||||||
| 					<button class="w3-button w3-dark-grey" title="Remove ${mention.name} mention" @click=${() => self.remove_mention(mention.link)}>🚮</button> | 					class="w3-button w3-dark-grey" | ||||||
|  | 					title="Remove ${mention.name} mention" | ||||||
|  | 					@click=${() => self.remove_mention(mention.link)} | ||||||
|  | 				> | ||||||
|  | 					🚮 | ||||||
|  | 				</button> | ||||||
|  | 			</div> | ||||||
|  | 			<div style="display: flex; flex-direction: column"> | ||||||
|  | 				<h3>${mention.name}</h3> | ||||||
|  | 				<div style="padding-left: 1em"> | ||||||
|  | 					${Object.entries(mention) | ||||||
|  | 						.filter((x) => x[0] != 'name') | ||||||
|  | 						.map( | ||||||
|  | 							(x) => | ||||||
|  | 								html`<div> | ||||||
|  | 									<span style="font-weight: bold">${x[0]}</span>: ${x[1]} | ||||||
|  | 								</div>` | ||||||
|  | 						)} | ||||||
| 				</div> | 				</div> | ||||||
| 				<div style="display: flex; flex-direction: column"> | 			</div> | ||||||
| 					<h3>${mention.name}</h3> | 		</div>`; | ||||||
| 					<div style="padding-left: 1em"> |  | ||||||
| 						${Object.entries(mention) |  | ||||||
| 							.filter(x => x[0] != 'name') |  | ||||||
| 							.map(x => html`<div><span style="font-weight: bold">${x[0]}</span>: ${x[1]}</div>`)} |  | ||||||
| 					</div> |  | ||||||
| 				</div> |  | ||||||
| 			</div>`; |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render_attach_app() { | 	render_attach_app() { | ||||||
| @@ -359,12 +397,21 @@ class TfComposeElement extends LitElement { | |||||||
| 			return html` | 			return html` | ||||||
| 				<div class="w3-card-4 w3-margin w3-padding"> | 				<div class="w3-card-4 w3-margin w3-padding"> | ||||||
| 					<select id="select" class="w3-select w3-dark-grey"> | 					<select id="select" class="w3-select w3-dark-grey"> | ||||||
| 						${Object.keys(self.apps).map(app => html`<option value=${app}>${app}</option>`)} | 						${Object.keys(self.apps).map( | ||||||
|  | 							(app) => html`<option value=${app}>${app}</option>` | ||||||
|  | 						)} | ||||||
| 					</select> | 					</select> | ||||||
| 					<button class="w3-button w3-dark-grey" @click=${attach_selected_app}>Attach</button> | 					<button class="w3-button w3-dark-grey" @click=${attach_selected_app}> | ||||||
| 					<button class="w3-button w3-dark-grey" @click=${() => this.apps = null}>Cancel</button> | 						Attach | ||||||
|  | 					</button> | ||||||
|  | 					<button | ||||||
|  | 						class="w3-button w3-dark-grey" | ||||||
|  | 						@click=${() => (this.apps = null)} | ||||||
|  | 					> | ||||||
|  | 						Cancel | ||||||
|  | 					</button> | ||||||
| 				</div> | 				</div> | ||||||
| 				`; | 			`; | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -374,9 +421,16 @@ class TfComposeElement extends LitElement { | |||||||
| 			self.apps = await tfrpc.rpc.apps(); | 			self.apps = await tfrpc.rpc.apps(); | ||||||
| 		} | 		} | ||||||
| 		if (!this.apps) { | 		if (!this.apps) { | ||||||
| 			return html`<button class="w3-button w3-dark-grey" @click=${attach_app}>Attach App</button>`; | 			return html`<button class="w3-button w3-dark-grey" @click=${attach_app}> | ||||||
|  | 				Attach App | ||||||
|  | 			</button>`; | ||||||
| 		} else { | 		} else { | ||||||
| 			return html`<button class="w3-button w3-dark-grey" @click=${() => this.apps = null}>Discard App</button>`; | 			return html`<button | ||||||
|  | 				class="w3-button w3-dark-grey" | ||||||
|  | 				@click=${() => (this.apps = null)} | ||||||
|  | 			> | ||||||
|  | 				Discard App | ||||||
|  | 			</button>`; | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -435,11 +489,13 @@ class TfComposeElement extends LitElement { | |||||||
| 				<button class="w3-button w3-dark-grey" @click=${() => this.set_encrypt(undefined)}>🚮</button> | 				<button class="w3-button w3-dark-grey" @click=${() => this.set_encrypt(undefined)}>🚮</button> | ||||||
| 			</div> | 			</div> | ||||||
| 			<ul> | 			<ul> | ||||||
| 				${draft.encrypt_to.map(x => html` | 				${draft.encrypt_to.map( | ||||||
|  | 					(x) => html` | ||||||
| 					<li> | 					<li> | ||||||
| 						<tf-user id=${x} .users=${this.users}></tf-user> | 						<tf-user id=${x} .users=${this.users}></tf-user> | ||||||
| 						<input type="button" class="w3-button w3-dark-grey" value="🚮" @click=${() => this.set_encrypt(draft.encrypt_to.filter(id => id != x))}></input> | 						<input type="button" class="w3-button w3-dark-grey" value="🚮" @click=${() => this.set_encrypt(draft.encrypt_to.filter((id) => id != x))}></input> | ||||||
| 					</li>`)} | 					</li>` | ||||||
|  | 				)} | ||||||
| 			</ul> | 			</ul> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
| @@ -455,34 +511,65 @@ class TfComposeElement extends LitElement { | |||||||
| 		let self = this; | 		let self = this; | ||||||
| 		let draft = self.get_draft(); | 		let draft = self.get_draft(); | ||||||
| 		let content_warning = | 		let content_warning = | ||||||
| 			draft.content_warning !== undefined ? | 			draft.content_warning !== undefined | ||||||
| 			html`<div class="w3-panel w3-round-xlarge w3-blue"> | 				? html`<div class="w3-panel w3-round-xlarge w3-blue"> | ||||||
| 				<p id="content_warning_preview">${draft.content_warning}</p> | 						<p id="content_warning_preview">${draft.content_warning}</p> | ||||||
| 			</div>` : | 					</div>` | ||||||
| 			undefined; | 				: undefined; | ||||||
| 		let encrypt = draft.encrypt_to !== undefined ? | 		let encrypt = | ||||||
| 			undefined : | 			draft.encrypt_to !== undefined | ||||||
| 			html`<button class="w3-button w3-dark-grey" @click=${() => this.set_encrypt([])}>🔐</button>`; | 				? undefined | ||||||
|  | 				: html`<button | ||||||
|  | 						class="w3-button w3-dark-grey" | ||||||
|  | 						@click=${() => this.set_encrypt([])} | ||||||
|  | 					> | ||||||
|  | 						🔐 | ||||||
|  | 					</button>`; | ||||||
| 		let result = html` | 		let result = html` | ||||||
| 			<div class="w3-card-4 w3-blue-grey w3-padding" style="box-sizing: border-box"> | 			<div | ||||||
|  | 				class="w3-card-4 w3-blue-grey w3-padding" | ||||||
|  | 				style="box-sizing: border-box" | ||||||
|  | 			> | ||||||
| 				${this.render_encrypt()} | 				${this.render_encrypt()} | ||||||
| 				<div style="display: flex; flex-direction: row; width: 100%; gap: 4px"> | 				<div style="display: flex; flex-direction: row; width: 100%; gap: 4px"> | ||||||
| 					<div style="flex: 1 0 50%"> | 					<div style="flex: 1 0 50%"> | ||||||
| 						<p><textarea class="w3-input w3-dark-grey w3-border" style="resize: vertical" placeholder="Write a post here." id="edit" @input=${this.input} @change=${this.change} @paste=${this.paste}>${draft.text}</textarea></p> | 						<p> | ||||||
|  | 							<textarea | ||||||
|  | 								class="w3-input w3-dark-grey w3-border" | ||||||
|  | 								style="resize: vertical" | ||||||
|  | 								placeholder="Write a post here." | ||||||
|  | 								id="edit" | ||||||
|  | 								@input=${this.input} | ||||||
|  | 								@change=${this.change} | ||||||
|  | 								@paste=${this.paste} | ||||||
|  | 							> | ||||||
|  | ${draft.text}</textarea | ||||||
|  | 							> | ||||||
|  | 						</p> | ||||||
| 					</div> | 					</div> | ||||||
| 					<div style="flex: 1 0 50%"> | 					<div style="flex: 1 0 50%"> | ||||||
| 						${content_warning} | 						${content_warning} | ||||||
| 						<div id="preview"></div> | 						<div id="preview"></div> | ||||||
| 					</div> | 					</div> | ||||||
| 				</div> | 				</div> | ||||||
| 				${Object.values(draft.mentions || {}).map(x => self.render_mention(x))} | 				${Object.values(draft.mentions || {}).map((x) => | ||||||
| 				${this.render_attach_app()} | 					self.render_mention(x) | ||||||
| 				${this.render_content_warning()} | 				)} | ||||||
| 				<button class="w3-button w3-dark-grey" id="submit" @click=${this.submit}>Submit</button> | 				${this.render_attach_app()} ${this.render_content_warning()} | ||||||
| 				<button class="w3-button w3-dark-grey" @click=${this.attach}>Attach</button> | 				<button | ||||||
| 				${this.render_attach_app_button()} | 					class="w3-button w3-dark-grey" | ||||||
| 				${encrypt} | 					id="submit" | ||||||
| 				<button class="w3-button w3-dark-grey" @click=${this.discard}>Discard</button> | 					@click=${this.submit} | ||||||
|  | 				> | ||||||
|  | 					Submit | ||||||
|  | 				</button> | ||||||
|  | 				<button class="w3-button w3-dark-grey" @click=${this.attach}> | ||||||
|  | 					Attach | ||||||
|  | 				</button> | ||||||
|  | 				${this.render_attach_app_button()} ${encrypt} | ||||||
|  | 				<button class="w3-button w3-dark-grey" @click=${this.discard}> | ||||||
|  | 					Discard | ||||||
|  | 				</button> | ||||||
| 			</div> | 			</div> | ||||||
| 		`; | 		`; | ||||||
| 		return result; | 		return result; | ||||||
|   | |||||||
| @@ -3,8 +3,8 @@ import * as tfrpc from '/static/tfrpc.js'; | |||||||
| import {styles} from './tf-styles.js'; | import {styles} from './tf-styles.js'; | ||||||
|  |  | ||||||
| /* | /* | ||||||
| ** Provide a list of IDs, and this lets the user pick one. |  ** Provide a list of IDs, and this lets the user pick one. | ||||||
| */ |  */ | ||||||
| class TfIdentityPickerElement extends LitElement { | class TfIdentityPickerElement extends LitElement { | ||||||
| 	static get properties() { | 	static get properties() { | ||||||
| 		return { | 		return { | ||||||
| @@ -24,15 +24,28 @@ class TfIdentityPickerElement extends LitElement { | |||||||
|  |  | ||||||
| 	changed(event) { | 	changed(event) { | ||||||
| 		this.selected = event.srcElement.value; | 		this.selected = event.srcElement.value; | ||||||
| 		this.dispatchEvent(new Event('change', { | 		this.dispatchEvent( | ||||||
| 			srcElement: this, | 			new Event('change', { | ||||||
| 		})); | 				srcElement: this, | ||||||
|  | 			}) | ||||||
|  | 		); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render() { | 	render() { | ||||||
| 		return html` | 		return html` | ||||||
| 			<select class="w3-select w3-dark-grey w3-padding w3-border" @change=${this.changed} style="max-width: 100%; overflow: hidden"> | 			<select | ||||||
| 				${(this.ids ?? []).map(id => html`<option ?selected=${id == this.selected} value=${id}>${this.users[id]?.name ? (this.users[id]?.name + ' - ') : undefined}<small>${id}</small></option>`)} | 				class="w3-select w3-dark-grey w3-padding w3-border" | ||||||
|  | 				@change=${this.changed} | ||||||
|  | 				style="max-width: 100%; overflow: hidden" | ||||||
|  | 			> | ||||||
|  | 				${(this.ids ?? []).map( | ||||||
|  | 					(id) => | ||||||
|  | 						html`<option ?selected=${id == this.selected} value=${id}> | ||||||
|  | 							${this.users[id]?.name | ||||||
|  | 								? this.users[id]?.name + ' - ' | ||||||
|  | 								: undefined}<small>${id}</small> | ||||||
|  | 						</option>` | ||||||
|  | 				)} | ||||||
| 			</select> | 			</select> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -31,14 +31,27 @@ class TfMessageElement extends LitElement { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	show_reply() { | 	show_reply() { | ||||||
| 		let event = new CustomEvent('tf-draft', {bubbles: true, composed: true, detail: {id: this.message?.id, draft: { | 		let event = new CustomEvent('tf-draft', { | ||||||
| 			encrypt_to: this.message?.decrypted?.recps, | 			bubbles: true, | ||||||
| 		}}}); | 			composed: true, | ||||||
|  | 			detail: { | ||||||
|  | 				id: this.message?.id, | ||||||
|  | 				draft: { | ||||||
|  | 					encrypt_to: this.message?.decrypted?.recps, | ||||||
|  | 				}, | ||||||
|  | 			}, | ||||||
|  | 		}); | ||||||
| 		this.dispatchEvent(event); | 		this.dispatchEvent(event); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	discard_reply() { | 	discard_reply() { | ||||||
| 		this.dispatchEvent(new CustomEvent('tf-draft', {bubbles: true, composed: true, detail: {id: this.id, draft: undefined}})); | 		this.dispatchEvent( | ||||||
|  | 			new CustomEvent('tf-draft', { | ||||||
|  | 				bubbles: true, | ||||||
|  | 				composed: true, | ||||||
|  | 				detail: {id: this.id, draft: undefined}, | ||||||
|  | 			}) | ||||||
|  | 		); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render_votes() { | 	render_votes() { | ||||||
| @@ -53,12 +66,19 @@ class TfMessageElement extends LitElement { | |||||||
| 				return expression; | 				return expression; | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 		return html`<div>${(this.message.votes || []).map( | 		return html`<div> | ||||||
| 			vote => html` | 			${(this.message.votes || []).map( | ||||||
| 				<span title="${this.users[vote.author]?.name ?? vote.author} ${new Date(vote.timestamp)}"> | 				(vote) => html` | ||||||
| 					${normalize_expression(vote.content.vote.expression)} | 					<span | ||||||
| 				</span> | 						title="${this.users[vote.author]?.name ?? vote.author} ${new Date( | ||||||
| 			`)}</div>`; | 							vote.timestamp | ||||||
|  | 						)}" | ||||||
|  | 					> | ||||||
|  | 						${normalize_expression(vote.content.vote.expression)} | ||||||
|  | 					</span> | ||||||
|  | 				` | ||||||
|  | 			)} | ||||||
|  | 		</div>`; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render_raw() { | 	render_raw() { | ||||||
| @@ -72,30 +92,40 @@ class TfMessageElement extends LitElement { | |||||||
| 			content: this.message?.content, | 			content: this.message?.content, | ||||||
| 			signature: this.message?.signature, | 			signature: this.message?.signature, | ||||||
| 		}; | 		}; | ||||||
| 		return html`<div style="white-space: pre-wrap">${JSON.stringify(raw, null, 2)}</div>`; | 		return html`<div style="white-space: pre-wrap"> | ||||||
|  | 			${JSON.stringify(raw, null, 2)} | ||||||
|  | 		</div>`; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	vote(emoji) { | 	vote(emoji) { | ||||||
| 		let reaction = emoji; | 		let reaction = emoji; | ||||||
| 		let message = this.message.id; | 		let message = this.message.id; | ||||||
| 		if (confirm('Are you sure you want to react with ' + reaction + ' to ' + message + '?')) { | 		if ( | ||||||
| 			tfrpc.rpc.appendMessage( | 			confirm( | ||||||
| 				this.whoami, | 				'Are you sure you want to react with ' + | ||||||
| 				{ | 					reaction + | ||||||
|  | 					' to ' + | ||||||
|  | 					message + | ||||||
|  | 					'?' | ||||||
|  | 			) | ||||||
|  | 		) { | ||||||
|  | 			tfrpc.rpc | ||||||
|  | 				.appendMessage(this.whoami, { | ||||||
| 					type: 'vote', | 					type: 'vote', | ||||||
| 					vote: { | 					vote: { | ||||||
| 						link: message, | 						link: message, | ||||||
| 						value: 1, | 						value: 1, | ||||||
| 						expression: reaction, | 						expression: reaction, | ||||||
| 					}, | 					}, | ||||||
| 				}).catch(function(error) { | 				}) | ||||||
|  | 				.catch(function (error) { | ||||||
| 					alert(error?.message); | 					alert(error?.message); | ||||||
| 				}); | 				}); | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	react(event) { | 	react(event) { | ||||||
| 		emojis.picker(x => this.vote(x)); | 		emojis.picker((x) => this.vote(x)); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	show_image(link) { | 	show_image(link) { | ||||||
| @@ -129,7 +159,10 @@ class TfMessageElement extends LitElement { | |||||||
| 	body_click(event) { | 	body_click(event) { | ||||||
| 		if (event.srcElement.tagName == 'IMG') { | 		if (event.srcElement.tagName == 'IMG') { | ||||||
| 			this.show_image(event.srcElement.src); | 			this.show_image(event.srcElement.src); | ||||||
| 		} else if (event.srcElement.tagName == 'DIV' && event.srcElement.classList.contains('img_caption')) { | 		} else if ( | ||||||
|  | 			event.srcElement.tagName == 'DIV' && | ||||||
|  | 			event.srcElement.classList.contains('img_caption') | ||||||
|  | 		) { | ||||||
| 			let next = event.srcElement.nextSibling; | 			let next = event.srcElement.nextSibling; | ||||||
| 			if (next.style.display == 'block') { | 			if (next.style.display == 'block') { | ||||||
| 				next.style.display = 'none'; | 				next.style.display = 'none'; | ||||||
| @@ -140,50 +173,77 @@ class TfMessageElement extends LitElement { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render_mention(mention) { | 	render_mention(mention) { | ||||||
| 		if (!mention?.link || typeof(mention.link) != 'string') { | 		if (!mention?.link || typeof mention.link != 'string') { | ||||||
| 			return html` <pre>${JSON.stringify(mention)}</pre>`; | 			return html` <pre>${JSON.stringify(mention)}</pre>`; | ||||||
| 		} else if (mention?.link?.startsWith('&') && | 		} else if ( | ||||||
| 			mention?.type?.startsWith('image/')) { | 			mention?.link?.startsWith('&') && | ||||||
|  | 			mention?.type?.startsWith('image/') | ||||||
|  | 		) { | ||||||
| 			return html` | 			return html` | ||||||
| 				<img src=${'/' + mention.link + '/view'} style="max-width: 128px; max-height: 128px" title=${mention.name} @click=${() => this.show_image('/' + mention.link + '/view')}> | 				<img | ||||||
|  | 					src=${'/' + mention.link + '/view'} | ||||||
|  | 					style="max-width: 128px; max-height: 128px" | ||||||
|  | 					title=${mention.name} | ||||||
|  | 					@click=${() => this.show_image('/' + mention.link + '/view')} | ||||||
|  | 				/> | ||||||
| 			`; | 			`; | ||||||
| 		} else if (mention.link?.startsWith('&') && | 		} else if ( | ||||||
| 			mention.name?.startsWith('audio:')) { | 			mention.link?.startsWith('&') && | ||||||
|  | 			mention.name?.startsWith('audio:') | ||||||
|  | 		) { | ||||||
| 			return html` | 			return html` | ||||||
| 				<audio controls style="height: 32px"> | 				<audio controls style="height: 32px"> | ||||||
| 					<source src=${'/' + mention.link + '/view'}></source> | 					<source src=${'/' + mention.link + '/view'}></source> | ||||||
| 				</audio> | 				</audio> | ||||||
| 			`; | 			`; | ||||||
| 		} else if (mention.link?.startsWith('&') && | 		} else if ( | ||||||
| 			mention.name?.startsWith('video:')) { | 			mention.link?.startsWith('&') && | ||||||
|  | 			mention.name?.startsWith('video:') | ||||||
|  | 		) { | ||||||
| 			return html` | 			return html` | ||||||
| 				<video controls style="max-height: 240px; max-width: 128px"> | 				<video controls style="max-height: 240px; max-width: 128px"> | ||||||
| 					<source src=${'/' + mention.link + '/view'}></source> | 					<source src=${'/' + mention.link + '/view'}></source> | ||||||
| 				</video> | 				</video> | ||||||
| 			`; | 			`; | ||||||
| 		} else if (mention.link?.startsWith('&') && | 		} else if ( | ||||||
| 			mention?.type === 'application/tildefriends') { | 			mention.link?.startsWith('&') && | ||||||
|  | 			mention?.type === 'application/tildefriends' | ||||||
|  | 		) { | ||||||
| 			return html` <a href=${`/${mention.link}/`}>😎 ${mention.name}</a>`; | 			return html` <a href=${`/${mention.link}/`}>😎 ${mention.name}</a>`; | ||||||
| 		} else if (mention.link?.startsWith('%') || mention.link?.startsWith('@')) { | 		} else if (mention.link?.startsWith('%') || mention.link?.startsWith('@')) { | ||||||
| 			return html` <a href=${'#' + encodeURIComponent(mention.link)}>${mention.name}</a>`; | 			return html` <a href=${'#' + encodeURIComponent(mention.link)} | ||||||
|  | 				>${mention.name}</a | ||||||
|  | 			>`; | ||||||
| 		} else if (mention.link?.startsWith('#')) { | 		} else if (mention.link?.startsWith('#')) { | ||||||
| 			return html` <a href=${'#q=' + encodeURIComponent(mention.link)}>${mention.link}</a>`; | 			return html` <a href=${'#q=' + encodeURIComponent(mention.link)} | ||||||
| 		} else if (Object.keys(mention).length == 2 && mention.link && mention.name) { | 				>${mention.link}</a | ||||||
|  | 			>`; | ||||||
|  | 		} else if ( | ||||||
|  | 			Object.keys(mention).length == 2 && | ||||||
|  | 			mention.link && | ||||||
|  | 			mention.name | ||||||
|  | 		) { | ||||||
| 			return html` <a href=${`/${mention.link}/view`}>${mention.name}</a>`; | 			return html` <a href=${`/${mention.link}/view`}>${mention.name}</a>`; | ||||||
| 		} else { | 		} else { | ||||||
| 			return html` <pre style="white-space: pre-wrap">${JSON.stringify(mention, null, 2)}</pre>`; | 			return html` <pre style="white-space: pre-wrap"> | ||||||
|  | ${JSON.stringify(mention, null, 2)}</pre | ||||||
|  | 			>`; | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render_mentions() { | 	render_mentions() { | ||||||
| 		let mentions = this.message?.content?.mentions || []; | 		let mentions = this.message?.content?.mentions || []; | ||||||
| 		mentions = mentions.filter(x => this.message?.content?.text?.indexOf(x.link) === -1); | 		mentions = mentions.filter( | ||||||
|  | 			(x) => this.message?.content?.text?.indexOf(x.link) === -1 | ||||||
|  | 		); | ||||||
| 		if (mentions.length) { | 		if (mentions.length) { | ||||||
| 			let self = this; | 			let self = this; | ||||||
| 			return html` | 			return html` | ||||||
| 				<fieldset style="background-color: rgba(0, 0, 0, 0.1); padding: 0.5em; border: 1px solid black"> | 				<fieldset | ||||||
|  | 					style="background-color: rgba(0, 0, 0, 0.1); padding: 0.5em; border: 1px solid black" | ||||||
|  | 				> | ||||||
| 					<legend>Mentions</legend> | 					<legend>Mentions</legend> | ||||||
| 					${mentions.map(x => self.render_mention(x))} | 					${mentions.map((x) => self.render_mention(x))} | ||||||
| 				</fieldset> | 				</fieldset> | ||||||
| 			`; | 			`; | ||||||
| 		} | 		} | ||||||
| @@ -194,28 +254,55 @@ class TfMessageElement extends LitElement { | |||||||
| 			return 0; | 			return 0; | ||||||
| 		} | 		} | ||||||
| 		let total = message.child_messages.length; | 		let total = message.child_messages.length; | ||||||
| 		for (let m of message.child_messages) | 		for (let m of message.child_messages) { | ||||||
| 		{ |  | ||||||
| 			total += this.total_child_messages(m); | 			total += this.total_child_messages(m); | ||||||
| 		} | 		} | ||||||
| 		return total; | 		return total; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	set_expanded(expanded, tag) { | 	set_expanded(expanded, tag) { | ||||||
| 		this.dispatchEvent(new CustomEvent('tf-expand', {bubbles: true, composed: true, detail: {id: (this.message.id || '') + (tag || ''), expanded: expanded}})); | 		this.dispatchEvent( | ||||||
|  | 			new CustomEvent('tf-expand', { | ||||||
|  | 				bubbles: true, | ||||||
|  | 				composed: true, | ||||||
|  | 				detail: {id: (this.message.id || '') + (tag || ''), expanded: expanded}, | ||||||
|  | 			}) | ||||||
|  | 		); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	toggle_expanded(tag) { | 	toggle_expanded(tag) { | ||||||
| 		this.set_expanded(!this.expanded[(this.message.id || '') + (tag || '')], tag); | 		this.set_expanded( | ||||||
|  | 			!this.expanded[(this.message.id || '') + (tag || '')], | ||||||
|  | 			tag | ||||||
|  | 		); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render_children() { | 	render_children() { | ||||||
| 		let self = this; | 		let self = this; | ||||||
| 		if (this.message.child_messages?.length) { | 		if (this.message.child_messages?.length) { | ||||||
| 			if (!this.expanded[this.message.id]) { | 			if (!this.expanded[this.message.id]) { | ||||||
| 				return html`<button class="w3-button w3-dark-grey" @click=${() => self.set_expanded(true)}>+ ${this.total_child_messages(this.message) + ' More'}</button>`; | 				return html`<button | ||||||
|  | 					class="w3-button w3-dark-grey" | ||||||
|  | 					@click=${() => self.set_expanded(true)} | ||||||
|  | 				> | ||||||
|  | 					+ ${this.total_child_messages(this.message) + ' More'} | ||||||
|  | 				</button>`; | ||||||
| 			} else { | 			} else { | ||||||
| 				return html`<button class="w3-button w3-dark-grey" @click=${() => self.set_expanded(false)}>Collapse</button>${(this.message.child_messages || []).map(x => html`<tf-message .message=${x} whoami=${this.whoami} .users=${this.users} .drafts=${this.drafts} .expanded=${this.expanded}></tf-message>`)}`; | 				return html`<button | ||||||
|  | 						class="w3-button w3-dark-grey" | ||||||
|  | 						@click=${() => self.set_expanded(false)} | ||||||
|  | 					> | ||||||
|  | 						Collapse</button | ||||||
|  | 					>${(this.message.child_messages || []).map( | ||||||
|  | 						(x) => | ||||||
|  | 							html`<tf-message | ||||||
|  | 								.message=${x} | ||||||
|  | 								whoami=${this.whoami} | ||||||
|  | 								.users=${this.users} | ||||||
|  | 								.drafts=${this.drafts} | ||||||
|  | 								.expanded=${this.expanded} | ||||||
|  | 							></tf-message>` | ||||||
|  | 					)}`; | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| @@ -231,13 +318,12 @@ class TfMessageElement extends LitElement { | |||||||
| 		} | 		} | ||||||
| 		if (Array.isArray(content.mentions)) { | 		if (Array.isArray(content.mentions)) { | ||||||
| 			for (let mention of content.mentions) { | 			for (let mention of content.mentions) { | ||||||
| 				if (typeof mention?.link === 'string' && | 				if (typeof mention?.link === 'string' && mention.link.startsWith('#')) { | ||||||
| 					mention.link.startsWith('#')) { |  | ||||||
| 					channels.push(mention.link); | 					channels.push(mention.link); | ||||||
| 				} | 				} | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 		return channels.map(x => html`<tf-tag tag=${x}></tf-tag>`); | 		return channels.map((x) => html`<tf-tag tag=${x}></tf-tag>`); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render() { | 	render() { | ||||||
| @@ -250,54 +336,110 @@ class TfMessageElement extends LitElement { | |||||||
| 		switch (this.format) { | 		switch (this.format) { | ||||||
| 			case 'raw': | 			case 'raw': | ||||||
| 				if (content?.type == 'post' || content?.type == 'blog') { | 				if (content?.type == 'post' || content?.type == 'blog') { | ||||||
| 					raw_button = html`<button class="w3-button w3-dark-grey" @click=${() => self.format = 'md'}>Markdown</button>`; | 					raw_button = html`<button | ||||||
|  | 						class="w3-button w3-dark-grey" | ||||||
|  | 						@click=${() => (self.format = 'md')} | ||||||
|  | 					> | ||||||
|  | 						Markdown | ||||||
|  | 					</button>`; | ||||||
| 				} else { | 				} else { | ||||||
| 					raw_button = html`<button class="w3-button w3-dark-grey" @click=${() => self.format = 'message'}>Message</button>`;  | 					raw_button = html`<button | ||||||
|  | 						class="w3-button w3-dark-grey" | ||||||
|  | 						@click=${() => (self.format = 'message')} | ||||||
|  | 					> | ||||||
|  | 						Message | ||||||
|  | 					</button>`; | ||||||
| 				} | 				} | ||||||
| 				break; | 				break; | ||||||
| 			case 'md': | 			case 'md': | ||||||
| 				raw_button = html`<button class="w3-button w3-dark-grey" @click=${() => self.format = 'message'}>Message</button>`; | 				raw_button = html`<button | ||||||
|  | 					class="w3-button w3-dark-grey" | ||||||
|  | 					@click=${() => (self.format = 'message')} | ||||||
|  | 				> | ||||||
|  | 					Message | ||||||
|  | 				</button>`; | ||||||
| 				break; | 				break; | ||||||
| 			case 'decrypted': | 			case 'decrypted': | ||||||
| 				raw_button = html`<button class="w3-button w3-dark-grey" @click=${() => self.format = 'raw'}>Raw</button>`; | 				raw_button = html`<button | ||||||
|  | 					class="w3-button w3-dark-grey" | ||||||
|  | 					@click=${() => (self.format = 'raw')} | ||||||
|  | 				> | ||||||
|  | 					Raw | ||||||
|  | 				</button>`; | ||||||
| 				break; | 				break; | ||||||
| 			default: | 			default: | ||||||
| 				if (this.message.decrypted) { | 				if (this.message.decrypted) { | ||||||
| 					raw_button = html`<button class="w3-button w3-dark-grey" @click=${() => self.format = 'decrypted'}>Decrypted</button>`; | 					raw_button = html`<button | ||||||
|  | 						class="w3-button w3-dark-grey" | ||||||
|  | 						@click=${() => (self.format = 'decrypted')} | ||||||
|  | 					> | ||||||
|  | 						Decrypted | ||||||
|  | 					</button>`; | ||||||
| 				} else { | 				} else { | ||||||
| 					raw_button = html`<button class="w3-button w3-dark-grey" @click=${() => self.format = 'raw'}>Raw</button>`; | 					raw_button = html`<button | ||||||
|  | 						class="w3-button w3-dark-grey" | ||||||
|  | 						@click=${() => (self.format = 'raw')} | ||||||
|  | 					> | ||||||
|  | 						Raw | ||||||
|  | 					</button>`; | ||||||
| 				} | 				} | ||||||
| 				break; | 				break; | ||||||
| 		} | 		} | ||||||
| 		function small_frame(inner) { | 		function small_frame(inner) { | ||||||
| 			let body; | 			let body; | ||||||
| 			return html` | 			return html` | ||||||
| 				<div class="w3-card-4" style="background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px; display: inline-block; overflow-wrap: anywhere"> | 				<div | ||||||
|  | 					class="w3-card-4" | ||||||
|  | 					style="background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px; display: inline-block; overflow-wrap: anywhere" | ||||||
|  | 				> | ||||||
| 					<tf-user id=${self.message.author} .users=${self.users}></tf-user> | 					<tf-user id=${self.message.author} .users=${self.users}></tf-user> | ||||||
| 					<span style="padding-right: 8px"><a tfarget="_top" href=${'#' + self.message.id}>%</a> ${new Date(self.message.timestamp).toLocaleString()}</span> | 					<span style="padding-right: 8px" | ||||||
| 					${raw_button} | 						><a tfarget="_top" href=${'#' + self.message.id}>%</a> ${new Date( | ||||||
| 					${self.format == 'raw' ? self.render_raw() : inner} | 							self.message.timestamp | ||||||
|  | 						).toLocaleString()}</span | ||||||
|  | 					> | ||||||
|  | 					${raw_button} ${self.format == 'raw' ? self.render_raw() : inner} | ||||||
| 					${self.render_votes()} | 					${self.render_votes()} | ||||||
| 				</div> | 				</div> | ||||||
| 			`; | 			`; | ||||||
| 		} | 		} | ||||||
| 		if (this.message?.type === 'contact_group') { | 		if (this.message?.type === 'contact_group') { | ||||||
| 			return html` | 			return html` <div | ||||||
| 				<div class="w3-card-4" style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px; overflow-wrap: anywhere"> | 				class="w3-card-4" | ||||||
| 					${this.message.messages.map(x =>  | 				style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px; overflow-wrap: anywhere" | ||||||
| 						html`<tf-message .message=${x} whoami=${this.whoami} .users=${this.users} .drafts=${this.drafts} .expanded=${this.expanded}></tf-message>` | 			> | ||||||
| 					)} | 				${this.message.messages.map( | ||||||
| 				</div>`; | 					(x) => | ||||||
|  | 						html`<tf-message | ||||||
|  | 							.message=${x} | ||||||
|  | 							whoami=${this.whoami} | ||||||
|  | 							.users=${this.users} | ||||||
|  | 							.drafts=${this.drafts} | ||||||
|  | 							.expanded=${this.expanded} | ||||||
|  | 						></tf-message>` | ||||||
|  | 				)} | ||||||
|  | 			</div>`; | ||||||
| 		} else if (this.message.placeholder) { | 		} else if (this.message.placeholder) { | ||||||
| 			return html` | 			return html` <div | ||||||
| 				<div class="w3-card-4" style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px; overflow-wrap: anywhere"> | 				class="w3-card-4" | ||||||
| 					<a target="_top" href=${'#' + this.message.id}>${this.message.id}</a> (placeholder) | 				style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px; overflow-wrap: anywhere" | ||||||
| 					<div>${this.render_votes()}</div> | 			> | ||||||
| 					${(this.message.child_messages || []).map(x => html` | 				<a target="_top" href=${'#' + this.message.id}>${this.message.id}</a> | ||||||
| 						<tf-message .message=${x} whoami=${this.whoami} .users=${this.users} .drafts=${this.drafts} .expanded=${this.expanded}></tf-message> | 				(placeholder) | ||||||
| 					`)} | 				<div>${this.render_votes()}</div> | ||||||
| 				</div>`; | 				${(this.message.child_messages || []).map( | ||||||
| 		} else if (typeof(content?.type === 'string')) { | 					(x) => html` | ||||||
|  | 						<tf-message | ||||||
|  | 							.message=${x} | ||||||
|  | 							whoami=${this.whoami} | ||||||
|  | 							.users=${this.users} | ||||||
|  | 							.drafts=${this.drafts} | ||||||
|  | 							.expanded=${this.expanded} | ||||||
|  | 						></tf-message> | ||||||
|  | 					` | ||||||
|  | 				)} | ||||||
|  | 			</div>`; | ||||||
|  | 		} else if (typeof (content?.type === 'string')) { | ||||||
| 			if (content.type == 'about') { | 			if (content.type == 'about') { | ||||||
| 				let name; | 				let name; | ||||||
| 				let image; | 				let image; | ||||||
| @@ -307,7 +449,7 @@ class TfMessageElement extends LitElement { | |||||||
| 				} | 				} | ||||||
| 				if (content.image !== undefined) { | 				if (content.image !== undefined) { | ||||||
| 					image = html` | 					image = html` | ||||||
| 						<div><img src=${'/' + (typeof(content.image?.link) == 'string' ? content.image.link : content.image) + '/view'} style="width: 256px; height: auto"></img></div> | 						<div><img src=${'/' + (typeof content.image?.link == 'string' ? content.image.link : content.image) + '/view'} style="width: 256px; height: auto"></img></div> | ||||||
| 					`; | 					`; | ||||||
| 				} | 				} | ||||||
| 				if (content.description !== undefined) { | 				if (content.description !== undefined) { | ||||||
| @@ -317,42 +459,55 @@ class TfMessageElement extends LitElement { | |||||||
| 						</div> | 						</div> | ||||||
| 					`; | 					`; | ||||||
| 				} | 				} | ||||||
| 				let update = content.about == this.message.author ? | 				let update = | ||||||
| 					html`<div style="font-weight: bold">Updated profile.</div>` : | 					content.about == this.message.author | ||||||
| 					html`<div style="font-weight: bold">Updated profile for <tf-user id=${content.about} .users=${this.users}></tf-user>.</div>`; | 						? html`<div style="font-weight: bold">Updated profile.</div>` | ||||||
| 				return small_frame(html` | 						: html`<div style="font-weight: bold"> | ||||||
| 					${update} | 								Updated profile for | ||||||
| 					${name} | 								<tf-user id=${content.about} .users=${this.users}></tf-user>. | ||||||
| 					${image} | 							</div>`; | ||||||
| 					${description} | 				return small_frame(html` ${update} ${name} ${image} ${description} `); | ||||||
| 				`); |  | ||||||
| 			} else if (content.type == 'contact') { | 			} else if (content.type == 'contact') { | ||||||
| 				return html` | 				return html` | ||||||
| 					<div> | 					<div> | ||||||
| 						<tf-user id=${this.message.author} .users=${this.users}></tf-user> | 						<tf-user id=${this.message.author} .users=${this.users}></tf-user> | ||||||
| 						is | 						is | ||||||
| 						${ | 						${content.blocking === true | ||||||
| 							content.blocking === true ? 'blocking' : | 							? 'blocking' | ||||||
| 							content.blocking === false ? 'no longer blocking' : | 							: content.blocking === false | ||||||
| 							content.following === true ? 'following' : | 								? 'no longer blocking' | ||||||
| 							content.following === false ? 'no longer following' : | 								: content.following === true | ||||||
| 							'?' | 									? 'following' | ||||||
| 						} | 									: content.following === false | ||||||
| 						<tf-user id=${this.message.content.contact} .users=${this.users}></tf-user> | 										? 'no longer following' | ||||||
|  | 										: '?'} | ||||||
|  | 						<tf-user | ||||||
|  | 							id=${this.message.content.contact} | ||||||
|  | 							.users=${this.users} | ||||||
|  | 						></tf-user> | ||||||
| 					</div> | 					</div> | ||||||
| 				`; | 				`; | ||||||
| 			} else if (content.type == 'post') { | 			} else if (content.type == 'post') { | ||||||
| 				let reply = (this.drafts[this.message?.id] !== undefined) ? html` | 				let reply = | ||||||
| 					<tf-compose | 					this.drafts[this.message?.id] !== undefined | ||||||
| 						whoami=${this.whoami} | 						? html` | ||||||
| 						.users=${this.users} | 								<tf-compose | ||||||
| 						root=${this.message.content.root || this.message.id} | 									whoami=${this.whoami} | ||||||
| 						branch=${this.message.id} | 									.users=${this.users} | ||||||
| 						.drafts=${this.drafts} | 									root=${this.message.content.root || this.message.id} | ||||||
| 						@tf-discard=${this.discard_reply}></tf-compose> | 									branch=${this.message.id} | ||||||
| 				` : html` | 									.drafts=${this.drafts} | ||||||
| 					<button class="w3-button w3-dark-grey" @click=${this.show_reply}>Reply</button> | 									@tf-discard=${this.discard_reply} | ||||||
| 				`; | 								></tf-compose> | ||||||
|  | 							` | ||||||
|  | 						: html` | ||||||
|  | 								<button | ||||||
|  | 									class="w3-button w3-dark-grey" | ||||||
|  | 									@click=${this.show_reply} | ||||||
|  | 								> | ||||||
|  | 									Reply | ||||||
|  | 								</button> | ||||||
|  | 							`; | ||||||
| 				let self = this; | 				let self = this; | ||||||
| 				let body; | 				let body; | ||||||
| 				switch (this.format) { | 				switch (this.format) { | ||||||
| @@ -360,35 +515,47 @@ class TfMessageElement extends LitElement { | |||||||
| 						body = this.render_raw(); | 						body = this.render_raw(); | ||||||
| 						break; | 						break; | ||||||
| 					case 'md': | 					case 'md': | ||||||
| 						body = html`<code style="white-space: pre-wrap; overflow-wrap: anywhere">${content.text}</code>`; | 						body = html`<code | ||||||
|  | 							style="white-space: pre-wrap; overflow-wrap: anywhere" | ||||||
|  | 							>${content.text}</code | ||||||
|  | 						>`; | ||||||
| 						break; | 						break; | ||||||
| 					case 'message': | 					case 'message': | ||||||
| 						body = unsafeHTML(tfutils.markdown(content.text)); | 						body = unsafeHTML(tfutils.markdown(content.text)); | ||||||
| 						break; | 						break; | ||||||
| 					case 'decrypted': | 					case 'decrypted': | ||||||
| 						body = html`<pre style="white-space: pre-wrap; overflow-wrap: anywhere">${JSON.stringify(content, null, 2)}</pre>`; | 						body = html`<pre | ||||||
|  | 							style="white-space: pre-wrap; overflow-wrap: anywhere" | ||||||
|  | 						> | ||||||
|  | ${JSON.stringify(content, null, 2)}</pre | ||||||
|  | 						>`; | ||||||
| 						break; | 						break; | ||||||
| 				} | 				} | ||||||
| 				let content_warning = html` | 				let content_warning = html` | ||||||
| 					<div class="w3-panel w3-round-xlarge w3-blue" style="cursor: pointer" @click=${x => this.toggle_expanded(':cw')}><p>${content.contentWarning}</p></div> | 					<div | ||||||
| 					`; | 						class="w3-panel w3-round-xlarge w3-blue" | ||||||
| 				let content_html = | 						style="cursor: pointer" | ||||||
| 					html` | 						@click=${(x) => this.toggle_expanded(':cw')} | ||||||
| 						${this.render_channels()} | 					> | ||||||
| 						<div @click=${this.body_click}>${body}</div> | 						<p>${content.contentWarning}</p> | ||||||
| 						${this.render_mentions()} | 					</div> | ||||||
| 					`; | 				`; | ||||||
| 				let payload = | 				let content_html = html` | ||||||
| 					content.contentWarning ? | 					${this.render_channels()} | ||||||
| 						self.expanded[(this.message.id || '') + ':cw'] ? | 					<div @click=${this.body_click}>${body}</div> | ||||||
| 							html` | 					${this.render_mentions()} | ||||||
| 								${content_warning} | 				`; | ||||||
| 								${content_html} | 				let payload = content.contentWarning | ||||||
| 							` : | 					? self.expanded[(this.message.id || '') + ':cw'] | ||||||
| 							content_warning : | 						? html` ${content_warning} ${content_html} ` | ||||||
| 						content_html; | 						: content_warning | ||||||
| 				let is_encrypted = this.message?.decrypted ? html`<span style="align-self: center">🔓</span>` : undefined; | 					: content_html; | ||||||
| 				let style_background = this.message?.decrypted ? 'rgba(255, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.1)'; | 				let is_encrypted = this.message?.decrypted | ||||||
|  | 					? html`<span style="align-self: center">🔓</span>` | ||||||
|  | 					: undefined; | ||||||
|  | 				let style_background = this.message?.decrypted | ||||||
|  | 					? 'rgba(255, 0, 0, 0.2)' | ||||||
|  | 					: 'rgba(255, 255, 255, 0.1)'; | ||||||
| 				return html` | 				return html` | ||||||
| 					<style> | 					<style> | ||||||
| 						code { | 						code { | ||||||
| @@ -404,26 +571,37 @@ class TfMessageElement extends LitElement { | |||||||
| 							display: block; | 							display: block; | ||||||
| 						} | 						} | ||||||
| 					</style> | 					</style> | ||||||
| 					<div class="w3-card-4" style="border: 1px solid black; background-color: ${style_background}; margin-top: 8px; padding: 16px"> | 					<div | ||||||
|  | 						class="w3-card-4" | ||||||
|  | 						style="border: 1px solid black; background-color: ${style_background}; margin-top: 8px; padding: 16px" | ||||||
|  | 					> | ||||||
| 						<div style="display: flex; flex-direction: row"> | 						<div style="display: flex; flex-direction: row"> | ||||||
| 							<tf-user id=${this.message.author} .users=${this.users}></tf-user> | 							<tf-user id=${this.message.author} .users=${this.users}></tf-user> | ||||||
| 							${is_encrypted} | 							${is_encrypted} | ||||||
| 							<span style="flex: 1"></span> | 							<span style="flex: 1"></span> | ||||||
| 							<span style="padding-right: 8px"><a target="_top" href=${'#' + self.message.id}>%</a> ${new Date(this.message.timestamp).toLocaleString()}</span> | 							<span style="padding-right: 8px" | ||||||
|  | 								><a target="_top" href=${'#' + self.message.id}>%</a> | ||||||
|  | 								${new Date(this.message.timestamp).toLocaleString()}</span | ||||||
|  | 							> | ||||||
| 							<span>${raw_button}</span> | 							<span>${raw_button}</span> | ||||||
| 						</div> | 						</div> | ||||||
| 						${payload} | 						${payload} ${this.render_votes()} | ||||||
| 						${this.render_votes()} |  | ||||||
| 						<p> | 						<p> | ||||||
| 							${reply} | 							${reply} | ||||||
| 							<button class="w3-button w3-dark-grey" @click=${this.react}>React</button> | 							<button class="w3-button w3-dark-grey" @click=${this.react}> | ||||||
|  | 								React | ||||||
|  | 							</button> | ||||||
| 						</p> | 						</p> | ||||||
| 						${this.render_children()} | 						${this.render_children()} | ||||||
| 					</div> | 					</div> | ||||||
| 				`; | 				`; | ||||||
| 			} else if (content.type === 'issue') { | 			} else if (content.type === 'issue') { | ||||||
| 				let is_encrypted = this.message?.decrypted ? html`<span style="align-self: center">🔓</span>` : undefined; | 				let is_encrypted = this.message?.decrypted | ||||||
| 				let style_background = this.message?.decrypted ? 'rgba(255, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.1)'; | 					? html`<span style="align-self: center">🔓</span>` | ||||||
|  | 					: undefined; | ||||||
|  | 				let style_background = this.message?.decrypted | ||||||
|  | 					? 'rgba(255, 0, 0, 0.2)' | ||||||
|  | 					: 'rgba(255, 255, 255, 0.1)'; | ||||||
| 				return html` | 				return html` | ||||||
| 					<style> | 					<style> | ||||||
| 						code { | 						code { | ||||||
| @@ -439,31 +617,41 @@ class TfMessageElement extends LitElement { | |||||||
| 							display: block; | 							display: block; | ||||||
| 						} | 						} | ||||||
| 					</style> | 					</style> | ||||||
| 					<div class="w3-card-4" style="border: 1px solid black; background-color: ${style_background}; margin-top: 8px; padding: 16px"> | 					<div | ||||||
|  | 						class="w3-card-4" | ||||||
|  | 						style="border: 1px solid black; background-color: ${style_background}; margin-top: 8px; padding: 16px" | ||||||
|  | 					> | ||||||
| 						<div style="display: flex; flex-direction: row"> | 						<div style="display: flex; flex-direction: row"> | ||||||
| 							<tf-user id=${this.message.author} .users=${this.users}></tf-user> | 							<tf-user id=${this.message.author} .users=${this.users}></tf-user> | ||||||
| 							${is_encrypted} | 							${is_encrypted} | ||||||
| 							<span style="flex: 1"></span> | 							<span style="flex: 1"></span> | ||||||
| 							<span style="padding-right: 8px"><a target="_top" href=${'#' + self.message.id}>%</a> ${new Date(this.message.timestamp).toLocaleString()}</span> | 							<span style="padding-right: 8px" | ||||||
|  | 								><a target="_top" href=${'#' + self.message.id}>%</a> | ||||||
|  | 								${new Date(this.message.timestamp).toLocaleString()}</span | ||||||
|  | 							> | ||||||
| 							<span>${raw_button}</span> | 							<span>${raw_button}</span> | ||||||
| 						</div> | 						</div> | ||||||
| 						${content.text} | 						${content.text} ${this.render_votes()} | ||||||
| 						${this.render_votes()} |  | ||||||
| 						<p> | 						<p> | ||||||
| 							<button class="w3-button w3-dark-grey" @click=${this.react}>React</button> | 							<button class="w3-button w3-dark-grey" @click=${this.react}> | ||||||
|  | 								React | ||||||
|  | 							</button> | ||||||
| 						</p> | 						</p> | ||||||
| 						${this.render_children()} | 						${this.render_children()} | ||||||
| 					</div> | 					</div> | ||||||
| 				`; | 				`; | ||||||
| 			} else if (content.type === 'blog') { | 			} else if (content.type === 'blog') { | ||||||
| 				let self = this; | 				let self = this; | ||||||
| 				tfrpc.rpc.get_blob(content.blog).then(function(data) { | 				tfrpc.rpc.get_blob(content.blog).then(function (data) { | ||||||
| 					self.blog_data = data; | 					self.blog_data = data; | ||||||
| 				}); | 				}); | ||||||
| 				let payload = | 				let payload = this.expanded[(this.message.id || '') + ':blog'] | ||||||
| 						this.expanded[(this.message.id || '') + ':blog'] ? | 					? html`<div> | ||||||
| 						html`<div>${this.blog_data ? unsafeHTML(tfutils.markdown(this.blog_data)) : 'Loading...'}</div>` : | 							${this.blog_data | ||||||
| 						undefined; | 								? unsafeHTML(tfutils.markdown(this.blog_data)) | ||||||
|  | 								: 'Loading...'} | ||||||
|  | 						</div>` | ||||||
|  | 					: undefined; | ||||||
| 				let body; | 				let body; | ||||||
| 				switch (this.format) { | 				switch (this.format) { | ||||||
| 					case 'raw': | 					case 'raw': | ||||||
| @@ -476,7 +664,7 @@ class TfMessageElement extends LitElement { | |||||||
| 						body = html` | 						body = html` | ||||||
| 							<div | 							<div | ||||||
| 								style="border: 1px solid #fff; border-radius: 1em; padding: 8px; margin: 4px; cursor: pointer" | 								style="border: 1px solid #fff; border-radius: 1em; padding: 8px; margin: 4px; cursor: pointer" | ||||||
| 								@click=${x => self.toggle_expanded(':blog')}> | 								@click=${(x) => self.toggle_expanded(':blog')}> | ||||||
| 								<h2>${content.title}</h2> | 								<h2>${content.title}</h2> | ||||||
| 								<div style="display: flex; flex-direction: row"> | 								<div style="display: flex; flex-direction: row"> | ||||||
| 									<img src=/${content.thumbnail}/view></img> | 									<img src=/${content.thumbnail}/view></img> | ||||||
| @@ -487,17 +675,26 @@ class TfMessageElement extends LitElement { | |||||||
| 						`; | 						`; | ||||||
| 						break; | 						break; | ||||||
| 				} | 				} | ||||||
| 				let reply = (this.drafts[this.message?.id] !== undefined) ? html` | 				let reply = | ||||||
| 					<tf-compose | 					this.drafts[this.message?.id] !== undefined | ||||||
| 						whoami=${this.whoami} | 						? html` | ||||||
| 						.users=${this.users} | 								<tf-compose | ||||||
| 						root=${this.message.content.root || this.message.id} | 									whoami=${this.whoami} | ||||||
| 						branch=${this.message.id} | 									.users=${this.users} | ||||||
| 						.drafts=${this.drafts} | 									root=${this.message.content.root || this.message.id} | ||||||
| 						@tf-discard=${this.discard_reply}></tf-compose> | 									branch=${this.message.id} | ||||||
| 				` : html` | 									.drafts=${this.drafts} | ||||||
| 					<button class="w3-button w3-dark-grey" @click=${this.show_reply}>Reply</button> | 									@tf-discard=${this.discard_reply} | ||||||
| 				`; | 								></tf-compose> | ||||||
|  | 							` | ||||||
|  | 						: html` | ||||||
|  | 								<button | ||||||
|  | 									class="w3-button w3-dark-grey" | ||||||
|  | 									@click=${this.show_reply} | ||||||
|  | 								> | ||||||
|  | 									Reply | ||||||
|  | 								</button> | ||||||
|  | 							`; | ||||||
| 				return html` | 				return html` | ||||||
| 					<style> | 					<style> | ||||||
| 						code { | 						code { | ||||||
| @@ -513,11 +710,17 @@ class TfMessageElement extends LitElement { | |||||||
| 							display: block; | 							display: block; | ||||||
| 						} | 						} | ||||||
| 					</style> | 					</style> | ||||||
| 					<div class="w3-card-4" style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px"> | 					<div | ||||||
|  | 						class="w3-card-4" | ||||||
|  | 						style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px" | ||||||
|  | 					> | ||||||
| 						<div style="display: flex; flex-direction: row"> | 						<div style="display: flex; flex-direction: row"> | ||||||
| 							<tf-user id=${this.message.author} .users=${this.users}></tf-user> | 							<tf-user id=${this.message.author} .users=${this.users}></tf-user> | ||||||
| 							<span style="flex: 1"></span> | 							<span style="flex: 1"></span> | ||||||
| 							<span style="padding-right: 8px"><a target="_top" href=${'#' + self.message.id}>%</a> ${new Date(this.message.timestamp).toLocaleString()}</span> | 							<span style="padding-right: 8px" | ||||||
|  | 								><a target="_top" href=${'#' + self.message.id}>%</a> | ||||||
|  | 								${new Date(this.message.timestamp).toLocaleString()}</span | ||||||
|  | 							> | ||||||
| 							<span>${raw_button}</span> | 							<span>${raw_button}</span> | ||||||
| 						</div> | 						</div> | ||||||
|  |  | ||||||
| @@ -525,37 +728,52 @@ class TfMessageElement extends LitElement { | |||||||
| 						${this.render_mentions()} | 						${this.render_mentions()} | ||||||
| 						<div> | 						<div> | ||||||
| 							${reply} | 							${reply} | ||||||
| 							<button class="w3-button w3-dark-grey" @click=${this.react}>React</button> | 							<button class="w3-button w3-dark-grey" @click=${this.react}> | ||||||
|  | 								React | ||||||
|  | 							</button> | ||||||
| 						</div> | 						</div> | ||||||
| 						${this.render_votes()} | 						${this.render_votes()} ${this.render_children()} | ||||||
| 						${this.render_children()} |  | ||||||
| 					</div> | 					</div> | ||||||
| 				`; | 				`; | ||||||
| 			} else if (content.type === 'pub') { | 			} else if (content.type === 'pub') { | ||||||
| 				return small_frame(html` | 				return small_frame( | ||||||
| 				<style> | 					html` <style> | ||||||
| 					span { | 							span { | ||||||
| 						overflow-wrap: anywhere; | 								overflow-wrap: anywhere; | ||||||
| 					} | 							} | ||||||
| 				</style> | 						</style> | ||||||
| 				<span> | 						<span> | ||||||
| 					<div> | 							<div> | ||||||
| 						🍻 <tf-user .users=${this.users} id=${content.address.key}></tf-user> | 								🍻 | ||||||
| 					</div> | 								<tf-user | ||||||
| 					<pre>${content.address.host}:${content.address.port}</pre> | 									.users=${this.users} | ||||||
| 				</span>`); | 									id=${content.address.key} | ||||||
|  | 								></tf-user> | ||||||
|  | 							</div> | ||||||
|  | 							<pre>${content.address.host}:${content.address.port}</pre> | ||||||
|  | 						</span>` | ||||||
|  | 				); | ||||||
| 			} else if (content.type === 'channel') { | 			} else if (content.type === 'channel') { | ||||||
| 				return small_frame(html` | 				return small_frame(html` | ||||||
| 					<div> | 					<div> | ||||||
| 						${content.subscribed ? 'subscribed to' : 'unsubscribed from'} <a href=${'#q=' + encodeURIComponent('#' + content.channel)}>#${content.channel}</a> | 						${content.subscribed ? 'subscribed to' : 'unsubscribed from'} | ||||||
|  | 						<a href=${'#q=' + encodeURIComponent('#' + content.channel)} | ||||||
|  | 							>#${content.channel}</a | ||||||
|  | 						> | ||||||
| 					</div> | 					</div> | ||||||
| 				`); | 				`); | ||||||
| 			} else if (typeof(this.message.content) == 'string') { | 			} else if (typeof this.message.content == 'string') { | ||||||
| 				if (this.message?.decrypted) { | 				if (this.message?.decrypted) { | ||||||
| 					if (this.format == 'decrypted') { | 					if (this.format == 'decrypted') { | ||||||
| 						return small_frame(html`<span>🔓</span><pre>${JSON.stringify(this.message.decrypted, null, 2)}</pre>`); | 						return small_frame( | ||||||
|  | 							html`<span>🔓</span> | ||||||
|  | 								<pre>${JSON.stringify(this.message.decrypted, null, 2)}</pre>` | ||||||
|  | 						); | ||||||
| 					} else { | 					} else { | ||||||
| 						return small_frame(html`<span>🔓</span><div>${this.message.decrypted.type}</div>`); | 						return small_frame( | ||||||
|  | 							html`<span>🔓</span> | ||||||
|  | 								<div>${this.message.decrypted.type}</div>` | ||||||
|  | 						); | ||||||
| 					} | 					} | ||||||
| 				} else { | 				} else { | ||||||
| 					return small_frame(html`<span>🔒</span>`); | 					return small_frame(html`<span>🔒</span>`); | ||||||
|   | |||||||
| @@ -61,7 +61,7 @@ class TfNewsElement extends LitElement { | |||||||
| 				message.parent_message = message.content.vote.link; | 				message.parent_message = message.content.vote.link; | ||||||
| 			} else if (message.content.type == 'post') { | 			} else if (message.content.type == 'post') { | ||||||
| 				if (message.content.root) { | 				if (message.content.root) { | ||||||
| 					if (typeof(message.content.root) === 'string') { | 					if (typeof message.content.root === 'string') { | ||||||
| 						let m = ensure_message(message.content.root); | 						let m = ensure_message(message.content.root); | ||||||
| 						if (!m.child_messages) { | 						if (!m.child_messages) { | ||||||
| 							m.child_messages = []; | 							m.child_messages = []; | ||||||
| @@ -89,8 +89,7 @@ class TfNewsElement extends LitElement { | |||||||
| 		for (let message of messages) { | 		for (let message of messages) { | ||||||
| 			try { | 			try { | ||||||
| 				message.content = JSON.parse(message.content); | 				message.content = JSON.parse(message.content); | ||||||
| 			} catch { | 			} catch {} | ||||||
| 			} |  | ||||||
| 			if (!messages_by_id[message.id]) { | 			if (!messages_by_id[message.id]) { | ||||||
| 				messages_by_id[message.id] = message; | 				messages_by_id[message.id] = message; | ||||||
| 				link_message(message); | 				link_message(message); | ||||||
| @@ -100,8 +99,12 @@ class TfNewsElement extends LitElement { | |||||||
| 				message.parent_message = placeholder.parent_message; | 				message.parent_message = placeholder.parent_message; | ||||||
| 				message.child_messages = placeholder.child_messages; | 				message.child_messages = placeholder.child_messages; | ||||||
| 				message.votes = placeholder.votes; | 				message.votes = placeholder.votes; | ||||||
| 				if (placeholder.parent_message && messages_by_id[placeholder.parent_message]) { | 				if ( | ||||||
| 					let children = messages_by_id[placeholder.parent_message].child_messages; | 					placeholder.parent_message && | ||||||
|  | 					messages_by_id[placeholder.parent_message] | ||||||
|  | 				) { | ||||||
|  | 					let children = | ||||||
|  | 						messages_by_id[placeholder.parent_message].child_messages; | ||||||
| 					children.splice(children.indexOf(placeholder), 1); | 					children.splice(children.indexOf(placeholder), 1); | ||||||
| 					children.push(message); | 					children.push(message); | ||||||
| 				} | 				} | ||||||
| @@ -116,7 +119,10 @@ class TfNewsElement extends LitElement { | |||||||
| 		let latest = 0; | 		let latest = 0; | ||||||
| 		for (let message of messages || []) { | 		for (let message of messages || []) { | ||||||
| 			if (message.latest_subtree_timestamp === undefined) { | 			if (message.latest_subtree_timestamp === undefined) { | ||||||
| 				message.latest_subtree_timestamp = Math.max(message.timestamp ?? 0, this.update_latest_subtree_timestamp(message.child_messages)); | 				message.latest_subtree_timestamp = Math.max( | ||||||
|  | 					message.timestamp ?? 0, | ||||||
|  | 					this.update_latest_subtree_timestamp(message.child_messages) | ||||||
|  | 				); | ||||||
| 			} | 			} | ||||||
| 			latest = Math.max(latest, message.latest_subtree_timestamp); | 			latest = Math.max(latest, message.latest_subtree_timestamp); | ||||||
| 		} | 		} | ||||||
| @@ -127,20 +133,22 @@ class TfNewsElement extends LitElement { | |||||||
| 		function recursive_sort(messages, top) { | 		function recursive_sort(messages, top) { | ||||||
| 			if (messages) { | 			if (messages) { | ||||||
| 				if (top) { | 				if (top) { | ||||||
| 					messages.sort((a, b) => b.latest_subtree_timestamp - a.latest_subtree_timestamp); | 					messages.sort( | ||||||
|  | 						(a, b) => b.latest_subtree_timestamp - a.latest_subtree_timestamp | ||||||
|  | 					); | ||||||
| 				} else { | 				} else { | ||||||
| 					messages.sort((a, b) => a.timestamp - b.timestamp); | 					messages.sort((a, b) => a.timestamp - b.timestamp); | ||||||
| 				} | 				} | ||||||
| 				for (let message of messages) { | 				for (let message of messages) { | ||||||
| 					recursive_sort(message.child_messages, false); | 					recursive_sort(message.child_messages, false); | ||||||
| 				} | 				} | ||||||
| 				return messages.map(x => Object.assign({}, x)); | 				return messages.map((x) => Object.assign({}, x)); | ||||||
| 			} else { | 			} else { | ||||||
| 				return {}; | 				return {}; | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		let roots = Object.values(messages_by_id).filter(x => !x.parent_message); | 		let roots = Object.values(messages_by_id).filter((x) => !x.parent_message); | ||||||
| 		this.update_latest_subtree_timestamp(roots); | 		this.update_latest_subtree_timestamp(roots); | ||||||
| 		return recursive_sort(roots, true); | 		return recursive_sort(roots, true); | ||||||
| 	} | 	} | ||||||
| @@ -167,10 +175,22 @@ class TfNewsElement extends LitElement { | |||||||
|  |  | ||||||
| 	load_and_render(messages) { | 	load_and_render(messages) { | ||||||
| 		let messages_by_id = this.process_messages(messages); | 		let messages_by_id = this.process_messages(messages); | ||||||
| 		let final_messages = this.group_following(this.finalize_messages(messages_by_id)); | 		let final_messages = this.group_following( | ||||||
|  | 			this.finalize_messages(messages_by_id) | ||||||
|  | 		); | ||||||
| 		return html` | 		return html` | ||||||
| 			<div style="display: flex; flex-direction: column"> | 			<div style="display: flex; flex-direction: column"> | ||||||
| 				${final_messages.map(x => html`<tf-message .message=${x} whoami=${this.whoami} .users=${this.users} .drafts=${this.drafts} .expanded=${this.expanded} collapsed=true></tf-message>`)} | 				${final_messages.map( | ||||||
|  | 					(x) => | ||||||
|  | 						html`<tf-message | ||||||
|  | 							.message=${x} | ||||||
|  | 							whoami=${this.whoami} | ||||||
|  | 							.users=${this.users} | ||||||
|  | 							.drafts=${this.drafts} | ||||||
|  | 							.expanded=${this.expanded} | ||||||
|  | 							collapsed="true" | ||||||
|  | 						></tf-message>` | ||||||
|  | 				)} | ||||||
| 			</div> | 			</div> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -36,23 +36,29 @@ class TfProfileElement extends LitElement { | |||||||
| 			this.following = undefined; | 			this.following = undefined; | ||||||
| 			this.blocking = undefined; | 			this.blocking = undefined; | ||||||
|  |  | ||||||
| 			let result = await tfrpc.rpc.query(` | 			let result = await tfrpc.rpc.query( | ||||||
|  | 				` | ||||||
| 				SELECT json_extract(content, '$.following') AS following | 				SELECT json_extract(content, '$.following') AS following | ||||||
| 				FROM messages WHERE author = ? AND | 				FROM messages WHERE author = ? AND | ||||||
| 				json_extract(content, '$.type') = 'contact' AND | 				json_extract(content, '$.type') = 'contact' AND | ||||||
| 				json_extract(content, '$.contact') = ? AND | 				json_extract(content, '$.contact') = ? AND | ||||||
| 				following IS NOT NULL | 				following IS NOT NULL | ||||||
| 				ORDER BY sequence DESC LIMIT 1 | 				ORDER BY sequence DESC LIMIT 1 | ||||||
| 			`, [this.whoami, this.id]); | 			`, | ||||||
|  | 				[this.whoami, this.id] | ||||||
|  | 			); | ||||||
| 			this.following = result?.[0]?.following ?? false; | 			this.following = result?.[0]?.following ?? false; | ||||||
| 			result = await tfrpc.rpc.query(` | 			result = await tfrpc.rpc.query( | ||||||
|  | 				` | ||||||
| 				SELECT json_extract(content, '$.blocking') AS blocking | 				SELECT json_extract(content, '$.blocking') AS blocking | ||||||
| 				FROM messages WHERE author = ? AND | 				FROM messages WHERE author = ? AND | ||||||
| 				json_extract(content, '$.type') = 'contact' AND | 				json_extract(content, '$.type') = 'contact' AND | ||||||
| 				json_extract(content, '$.contact') = ? AND | 				json_extract(content, '$.contact') = ? AND | ||||||
| 				blocking IS NOT NULL | 				blocking IS NOT NULL | ||||||
| 				ORDER BY sequence DESC LIMIT 1 | 				ORDER BY sequence DESC LIMIT 1 | ||||||
| 			`, [this.whoami, this.id]); | 			`, | ||||||
|  | 				[this.whoami, this.id] | ||||||
|  | 			); | ||||||
| 			this.blocking = result?.[0]?.blocking ?? false; | 			this.blocking = result?.[0]?.blocking ?? false; | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| @@ -60,13 +66,16 @@ class TfProfileElement extends LitElement { | |||||||
| 	async initial_load() { | 	async initial_load() { | ||||||
| 		this.server_follows_me = undefined; | 		this.server_follows_me = undefined; | ||||||
| 		let server_id = await tfrpc.rpc.getServerIdentity(); | 		let server_id = await tfrpc.rpc.getServerIdentity(); | ||||||
| 		let followed = await tfrpc.rpc.query(` | 		let followed = await tfrpc.rpc.query( | ||||||
|  | 			` | ||||||
| 			SELECT json_extract(content, '$.following') AS following | 			SELECT json_extract(content, '$.following') AS following | ||||||
| 			FROM messages | 			FROM messages | ||||||
| 			WHERE author = ? AND | 			WHERE author = ? AND | ||||||
| 			json_extract(content, '$.type') = 'contact' AND | 			json_extract(content, '$.type') = 'contact' AND | ||||||
| 			json_extract(content, '$.contact') = ? ORDER BY sequence DESC LIMIT 1 | 			json_extract(content, '$.contact') = ? ORDER BY sequence DESC LIMIT 1 | ||||||
| 		`, [server_id, this.whoami]); | 		`, | ||||||
|  | 			[server_id, this.whoami] | ||||||
|  | 		); | ||||||
| 		let is_followed = false; | 		let is_followed = false; | ||||||
| 		for (let row of followed) { | 		for (let row of followed) { | ||||||
| 			is_followed = row.following != 0; | 			is_followed = row.following != 0; | ||||||
| @@ -75,11 +84,18 @@ class TfProfileElement extends LitElement { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	modify(change) { | 	modify(change) { | ||||||
| 		tfrpc.rpc.appendMessage(this.whoami, | 		tfrpc.rpc | ||||||
| 			Object.assign({ | 			.appendMessage( | ||||||
| 				type: 'contact', | 				this.whoami, | ||||||
| 				contact: this.id, | 				Object.assign( | ||||||
| 			}, change)).catch(function(error) { | 					{ | ||||||
|  | 						type: 'contact', | ||||||
|  | 						contact: this.id, | ||||||
|  | 					}, | ||||||
|  | 					change | ||||||
|  | 				) | ||||||
|  | 			) | ||||||
|  | 			.catch(function (error) { | ||||||
| 				alert(error?.message); | 				alert(error?.message); | ||||||
| 			}); | 			}); | ||||||
| 	} | 	} | ||||||
| @@ -122,11 +138,14 @@ class TfProfileElement extends LitElement { | |||||||
| 				message[key] = this.editing[key]; | 				message[key] = this.editing[key]; | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 		tfrpc.rpc.appendMessage(this.whoami, message).then(function() { | 		tfrpc.rpc | ||||||
| 			self.editing = null; | 			.appendMessage(this.whoami, message) | ||||||
| 		}).catch(function(error) { | 			.then(function () { | ||||||
| 			alert(error?.message); | 				self.editing = null; | ||||||
| 		}); | 			}) | ||||||
|  | 			.catch(function (error) { | ||||||
|  | 				alert(error?.message); | ||||||
|  | 			}); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	discard_edits() { | 	discard_edits() { | ||||||
| @@ -137,17 +156,21 @@ class TfProfileElement extends LitElement { | |||||||
| 		let self = this; | 		let self = this; | ||||||
| 		let input = document.createElement('input'); | 		let input = document.createElement('input'); | ||||||
| 		input.type = 'file'; | 		input.type = 'file'; | ||||||
| 		input.onchange = function(event) { | 		input.onchange = function (event) { | ||||||
| 			let file = event.target.files[0]; | 			let file = event.target.files[0]; | ||||||
| 			file.arrayBuffer().then(function(buffer) { | 			file | ||||||
| 				let bin = Array.from(new Uint8Array(buffer)); | 				.arrayBuffer() | ||||||
| 				return tfrpc.rpc.store_blob(bin); | 				.then(function (buffer) { | ||||||
| 			}).then(function(id) { | 					let bin = Array.from(new Uint8Array(buffer)); | ||||||
| 				self.editing = Object.assign({}, self.editing, {image: id}); | 					return tfrpc.rpc.store_blob(bin); | ||||||
| 				console.log(self.editing); | 				}) | ||||||
| 			}).catch(function(e) { | 				.then(function (id) { | ||||||
| 				alert(e.message); | 					self.editing = Object.assign({}, self.editing, {image: id}); | ||||||
| 			}); | 					console.log(self.editing); | ||||||
|  | 				}) | ||||||
|  | 				.catch(function (e) { | ||||||
|  | 					alert(e.message); | ||||||
|  | 				}); | ||||||
| 		}; | 		}; | ||||||
| 		input.click(); | 		input.click(); | ||||||
| 	} | 	} | ||||||
| @@ -166,15 +189,22 @@ class TfProfileElement extends LitElement { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render() { | 	render() { | ||||||
| 		if (this.id == this.whoami && this.editing && this.server_follows_me === undefined) { | 		if ( | ||||||
|  | 			this.id == this.whoami && | ||||||
|  | 			this.editing && | ||||||
|  | 			this.server_follows_me === undefined | ||||||
|  | 		) { | ||||||
| 			this.initial_load(); | 			this.initial_load(); | ||||||
| 		} | 		} | ||||||
| 		this.load(); | 		this.load(); | ||||||
| 		let self = this; | 		let self = this; | ||||||
| 		let profile = this.users[this.id] || {}; | 		let profile = this.users[this.id] || {}; | ||||||
| 		tfrpc.rpc.query( | 		tfrpc.rpc | ||||||
| 			`SELECT SUM(LENGTH(content)) AS size FROM messages WHERE author = ?`, | 			.query( | ||||||
| 			[this.id]).then(function(result) { | 				`SELECT SUM(LENGTH(content)) AS size FROM messages WHERE author = ?`, | ||||||
|  | 				[this.id] | ||||||
|  | 			) | ||||||
|  | 			.then(function (result) { | ||||||
| 				self.size = result[0].size; | 				self.size = result[0].size; | ||||||
| 			}); | 			}); | ||||||
| 		let edit; | 		let edit; | ||||||
| @@ -184,52 +214,75 @@ class TfProfileElement extends LitElement { | |||||||
| 			if (this.editing) { | 			if (this.editing) { | ||||||
| 				let server_follow; | 				let server_follow; | ||||||
| 				if (this.server_follows_me === true) { | 				if (this.server_follows_me === true) { | ||||||
| 					server_follow = html`<button class="w3-button w3-dark-grey" @click=${() => this.server_follow_me(false)}>Server, Stop Following Me</button>`; | 					server_follow = html`<button | ||||||
|  | 						class="w3-button w3-dark-grey" | ||||||
|  | 						@click=${() => this.server_follow_me(false)} | ||||||
|  | 					> | ||||||
|  | 						Server, Stop Following Me | ||||||
|  | 					</button>`; | ||||||
| 				} else if (this.server_follows_me === false) { | 				} else if (this.server_follows_me === false) { | ||||||
| 					server_follow = html`<button class="w3-button w3-dark-grey" @click=${() => this.server_follow_me(true)}>Server, Follow Me</button>`; | 					server_follow = html`<button | ||||||
|  | 						class="w3-button w3-dark-grey" | ||||||
|  | 						@click=${() => this.server_follow_me(true)} | ||||||
|  | 					> | ||||||
|  | 						Server, Follow Me | ||||||
|  | 					</button>`; | ||||||
| 				} | 				} | ||||||
| 				edit = html` | 				edit = html` | ||||||
| 					<button class="w3-button w3-dark-grey" @click=${this.save_edits}>Save Profile</button> | 					<button class="w3-button w3-dark-grey" @click=${this.save_edits}> | ||||||
| 					<button class="w3-button w3-dark-grey" @click=${this.discard_edits}>Discard</button> | 						Save Profile | ||||||
|  | 					</button> | ||||||
|  | 					<button class="w3-button w3-dark-grey" @click=${this.discard_edits}> | ||||||
|  | 						Discard | ||||||
|  | 					</button> | ||||||
| 					${server_follow} | 					${server_follow} | ||||||
| 				`; | 				`; | ||||||
| 			} else { | 			} else { | ||||||
| 				edit = html`<button class="w3-button w3-dark-grey" @click=${this.edit}>Edit Profile</button>`; | 				edit = html`<button class="w3-button w3-dark-grey" @click=${this.edit}> | ||||||
|  | 					Edit Profile | ||||||
|  | 				</button>`; | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 		if (this.id !== this.whoami && | 		if (this.id !== this.whoami && this.following !== undefined) { | ||||||
| 			this.following !== undefined) { | 			follow = this.following | ||||||
| 			follow = | 				? html`<button class="w3-button w3-dark-grey" @click=${this.unfollow}> | ||||||
| 				this.following ? | 						Unfollow | ||||||
| 				html`<button class="w3-button w3-dark-grey" @click=${this.unfollow}>Unfollow</button>` : | 					</button>` | ||||||
| 				html`<button class="w3-button w3-dark-grey" @click=${this.follow}>Follow</button>`; | 				: html`<button class="w3-button w3-dark-grey" @click=${this.follow}> | ||||||
|  | 						Follow | ||||||
|  | 					</button>`; | ||||||
| 		} | 		} | ||||||
| 		if (this.id !== this.whoami && | 		if (this.id !== this.whoami && this.blocking !== undefined) { | ||||||
| 			this.blocking !== undefined) { | 			block = this.blocking | ||||||
| 			block = | 				? html`<button class="w3-button w3-dark-grey" @click=${this.unblock}> | ||||||
| 				this.blocking ? | 						Unblock | ||||||
| 				html`<button class="w3-button w3-dark-grey" @click=${this.unblock}>Unblock</button>` : | 					</button>` | ||||||
| 				html`<button class="w3-button w3-dark-grey" @click=${this.block}>Block</button>`; | 				: html`<button class="w3-button w3-dark-grey" @click=${this.block}> | ||||||
|  | 						Block | ||||||
|  | 					</button>`; | ||||||
| 		} | 		} | ||||||
| 		let edit_profile = this.editing ? html` | 		let edit_profile = this.editing | ||||||
|  | 			? html` | ||||||
| 			<div style="flex: 1 0 50%; display: flex; flex-direction: column; gap: 8px"> | 			<div style="flex: 1 0 50%; display: flex; flex-direction: column; gap: 8px"> | ||||||
| 				<div class="w3-container"> | 				<div class="w3-container"> | ||||||
| 					<div> | 					<div> | ||||||
| 						<label for="name">Name:</label> | 						<label for="name">Name:</label> | ||||||
| 						<input class="w3-input w3-dark-grey" type="text" id="name" value=${this.editing.name} @input=${event => this.editing = Object.assign({}, this.editing, {name: event.srcElement.value})}></input> | 						<input class="w3-input w3-dark-grey" type="text" id="name" value=${this.editing.name} @input=${(event) => (this.editing = Object.assign({}, this.editing, {name: event.srcElement.value}))}></input> | ||||||
| 					</div> | 					</div> | ||||||
| 					<div><label for="description">Description:</label></div> | 					<div><label for="description">Description:</label></div> | ||||||
| 					<textarea class="w3-input w3-dark-grey" style="resize: vertical" rows="8" id="description" @input=${event => this.editing = Object.assign({}, this.editing, {description: event.srcElement.value})}>${this.editing.description}</textarea> | 					<textarea class="w3-input w3-dark-grey" style="resize: vertical" rows="8" id="description" @input=${(event) => (this.editing = Object.assign({}, this.editing, {description: event.srcElement.value}))}>${this.editing.description}</textarea> | ||||||
| 					<div> | 					<div> | ||||||
| 						<label for="public_web_hosting">Public Web Hosting:</label> | 						<label for="public_web_hosting">Public Web Hosting:</label> | ||||||
| 						<input class="w3-check w3-dark-grey" type="checkbox" id="public_web_hosting" ?checked=${this.editing.publicWebHosting} @input=${event => self.editing = Object.assign({}, self.editing, {publicWebHosting: event.srcElement.checked})}></input> | 						<input class="w3-check w3-dark-grey" type="checkbox" id="public_web_hosting" ?checked=${this.editing.publicWebHosting} @input=${(event) => (self.editing = Object.assign({}, self.editing, {publicWebHosting: event.srcElement.checked}))}></input> | ||||||
| 					</div> | 					</div> | ||||||
| 					<div> | 					<div> | ||||||
| 						<button class="w3-button w3-dark-grey" @click=${this.attach_image}>Attach Image</button> | 						<button class="w3-button w3-dark-grey" @click=${this.attach_image}>Attach Image</button> | ||||||
| 					</div> | 					</div> | ||||||
| 				</div> | 				</div> | ||||||
| 			</div>` : null; | 			</div>` | ||||||
| 		let image = typeof(profile.image) == 'string' ? profile.image : profile.image?.link; | 			: null; | ||||||
|  | 		let image = | ||||||
|  | 			typeof profile.image == 'string' ? profile.image : profile.image?.link; | ||||||
| 		image = this.editing?.image ?? image; | 		image = this.editing?.image ?? image; | ||||||
| 		let description = this.editing?.description ?? profile.description; | 		let description = this.editing?.description ?? profile.description; | ||||||
| 		return html`<div style="border: 2px solid black; background-color: rgba(255, 255, 255, 0.2); padding: 16px"> | 		return html`<div style="border: 2px solid black; background-color: rgba(255, 255, 255, 0.2); padding: 16px"> | ||||||
|   | |||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -23,10 +23,10 @@ class TfTabConnectionsElement extends LitElement { | |||||||
| 		this.connections = []; | 		this.connections = []; | ||||||
| 		this.stored_connections = []; | 		this.stored_connections = []; | ||||||
| 		this.users = {}; | 		this.users = {}; | ||||||
| 		tfrpc.rpc.getAllIdentities().then(function(identities) { | 		tfrpc.rpc.getAllIdentities().then(function (identities) { | ||||||
| 			self.identities = identities || []; | 			self.identities = identities || []; | ||||||
| 		}); | 		}); | ||||||
| 		tfrpc.rpc.getStoredConnections().then(function(connections) { | 		tfrpc.rpc.getStoredConnections().then(function (connections) { | ||||||
| 			self.stored_connections = connections || []; | 			self.stored_connections = connections || []; | ||||||
| 		}); | 		}); | ||||||
| 	} | 	} | ||||||
| @@ -43,10 +43,12 @@ class TfTabConnectionsElement extends LitElement { | |||||||
|  |  | ||||||
| 	render_room_peers(connection) { | 	render_room_peers(connection) { | ||||||
| 		let self = this; | 		let self = this; | ||||||
| 		let peers = this.broadcasts.filter(x => x.tunnel?.id == connection); | 		let peers = this.broadcasts.filter((x) => x.tunnel?.id == connection); | ||||||
| 		if (peers.length) { | 		if (peers.length) { | ||||||
| 			let connections = this.connections.map(x => x.id); | 			let connections = this.connections.map((x) => x.id); | ||||||
| 			return html`${peers.filter(x => connections.indexOf(x.pubkey) == -1).map(x => html`${self.render_room_peer(x)}`)}`; | 			return html`${peers | ||||||
|  | 				.filter((x) => connections.indexOf(x.pubkey) == -1) | ||||||
|  | 				.map((x) => html`${self.render_room_peer(x)}`)}`; | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -58,7 +60,12 @@ class TfTabConnectionsElement extends LitElement { | |||||||
| 		let self = this; | 		let self = this; | ||||||
| 		return html` | 		return html` | ||||||
| 			<li> | 			<li> | ||||||
| 				<button class="w3-button w3-dark-grey" @click=${() => self._tunnel(connection.tunnel.id, connection.pubkey)}>Connect</button> | 				<button | ||||||
|  | 					class="w3-button w3-dark-grey" | ||||||
|  | 					@click=${() => self._tunnel(connection.tunnel.id, connection.pubkey)} | ||||||
|  | 				> | ||||||
|  | 					Connect | ||||||
|  | 				</button> | ||||||
| 				<tf-user id=${connection.pubkey} .users=${this.users}></tf-user> 📡 | 				<tf-user id=${connection.pubkey} .users=${this.users}></tf-user> 📡 | ||||||
| 			</li> | 			</li> | ||||||
| 		`; | 		`; | ||||||
| @@ -67,7 +74,12 @@ class TfTabConnectionsElement extends LitElement { | |||||||
| 	render_broadcast(connection) { | 	render_broadcast(connection) { | ||||||
| 		return html` | 		return html` | ||||||
| 			<li> | 			<li> | ||||||
| 				<button class="w3-button w3-dark-grey" @click=${() => tfrpc.rpc.connect(connection)}>Connect</button> | 				<button | ||||||
|  | 					class="w3-button w3-dark-grey" | ||||||
|  | 					@click=${() => tfrpc.rpc.connect(connection)} | ||||||
|  | 				> | ||||||
|  | 					Connect | ||||||
|  | 				</button> | ||||||
| 				<tf-user id=${connection.pubkey} .users=${this.users}></tf-user> | 				<tf-user id=${connection.pubkey} .users=${this.users}></tf-user> | ||||||
| 				${this.render_connection_summary(connection)} | 				${this.render_connection_summary(connection)} | ||||||
| 			</li> | 			</li> | ||||||
| @@ -81,11 +93,20 @@ class TfTabConnectionsElement extends LitElement { | |||||||
|  |  | ||||||
| 	render_connection(connection) { | 	render_connection(connection) { | ||||||
| 		return html` | 		return html` | ||||||
| 			<button class="w3-button w3-dark-grey" @click=${() => tfrpc.rpc.closeConnection(connection.id)}>Close</button> | 			<button | ||||||
|  | 				class="w3-button w3-dark-grey" | ||||||
|  | 				@click=${() => tfrpc.rpc.closeConnection(connection.id)} | ||||||
|  | 			> | ||||||
|  | 				Close | ||||||
|  | 			</button> | ||||||
| 			<tf-user id=${connection.id} .users=${this.users}></tf-user> | 			<tf-user id=${connection.id} .users=${this.users}></tf-user> | ||||||
| 			${connection.tunnel !== undefined ? '🚇' : html`(${connection.host}:${connection.port})`} | 			${connection.tunnel !== undefined | ||||||
|  | 				? '🚇' | ||||||
|  | 				: html`(${connection.host}:${connection.port})`} | ||||||
| 			<ul> | 			<ul> | ||||||
| 				${this.connections.filter(x => x.tunnel === this.connections.indexOf(connection)).map(x => html`<li>${this.render_connection(x)}</li>`)} | 				${this.connections | ||||||
|  | 					.filter((x) => x.tunnel === this.connections.indexOf(connection)) | ||||||
|  | 					.map((x) => html`<li>${this.render_connection(x)}</li>`)} | ||||||
| 				${this.render_room_peers(connection.id)} | 				${this.render_room_peers(connection.id)} | ||||||
| 			</ul> | 			</ul> | ||||||
| 		`; | 		`; | ||||||
| @@ -97,30 +118,54 @@ class TfTabConnectionsElement extends LitElement { | |||||||
| 			<div class="w3-container"> | 			<div class="w3-container"> | ||||||
| 				<h2>New Connection</h2> | 				<h2>New Connection</h2> | ||||||
| 				<textarea class="w3-input w3-dark-grey" id="code"></textarea> | 				<textarea class="w3-input w3-dark-grey" id="code"></textarea> | ||||||
| 				<button class="w3-button w3-dark-grey" @click=${() => tfrpc.rpc.connect(self.renderRoot.getElementById('code').value)}>Connect</button> | 				<button | ||||||
|  | 					class="w3-button w3-dark-grey" | ||||||
|  | 					@click=${() => | ||||||
|  | 						tfrpc.rpc.connect(self.renderRoot.getElementById('code').value)} | ||||||
|  | 				> | ||||||
|  | 					Connect | ||||||
|  | 				</button> | ||||||
| 				<h2>Broadcasts</h2> | 				<h2>Broadcasts</h2> | ||||||
| 				<ul> | 				<ul> | ||||||
| 					${this.broadcasts.filter(x => x.address).map(x => self.render_broadcast(x))} | 					${this.broadcasts | ||||||
|  | 						.filter((x) => x.address) | ||||||
|  | 						.map((x) => self.render_broadcast(x))} | ||||||
| 				</ul> | 				</ul> | ||||||
| 				<h2>Connections</h2> | 				<h2>Connections</h2> | ||||||
| 				<ul> | 				<ul> | ||||||
| 					${this.connections.filter(x => x.tunnel === undefined).map(x => html` | 					${this.connections | ||||||
| 						<li>${this.render_connection(x)}</li> | 						.filter((x) => x.tunnel === undefined) | ||||||
| 					`)} | 						.map((x) => html` <li>${this.render_connection(x)}</li> `)} | ||||||
| 				</ul> | 				</ul> | ||||||
| 				<h2>Stored Connections (WIP)</h2> | 				<h2>Stored Connections (WIP)</h2> | ||||||
| 				<ul> | 				<ul> | ||||||
| 					${this.stored_connections.map(x => html` | 					${this.stored_connections.map( | ||||||
| 						<li> | 						(x) => html` | ||||||
| 							<button class="w3-button w3-dark-grey" @click=${() => self.forget_stored_connection(x)}>Forget</button> | 							<li> | ||||||
| 							<button class="w3-button w3-dark-grey" @click=${() => tfrpc.rpc.connect(x)}>Connect</button> | 								<button | ||||||
| 							${x.address}:${x.port} <tf-user id=${x.pubkey} .users=${self.users}></tf-user> | 									class="w3-button w3-dark-grey" | ||||||
| 						</li> | 									@click=${() => self.forget_stored_connection(x)} | ||||||
| 					`)} | 								> | ||||||
|  | 									Forget | ||||||
|  | 								</button> | ||||||
|  | 								<button | ||||||
|  | 									class="w3-button w3-dark-grey" | ||||||
|  | 									@click=${() => tfrpc.rpc.connect(x)} | ||||||
|  | 								> | ||||||
|  | 									Connect | ||||||
|  | 								</button> | ||||||
|  | 								${x.address}:${x.port} | ||||||
|  | 								<tf-user id=${x.pubkey} .users=${self.users}></tf-user> | ||||||
|  | 							</li> | ||||||
|  | 						` | ||||||
|  | 					)} | ||||||
| 				</ul> | 				</ul> | ||||||
| 				<h2>Local Accounts</h2> | 				<h2>Local Accounts</h2> | ||||||
| 				<ul> | 				<ul> | ||||||
| 					${this.identities.map(x => html`<li><tf-user id=${x} .users=${this.users}></tf-user></li>`)} | 					${this.identities.map( | ||||||
|  | 						(x) => | ||||||
|  | 							html`<li><tf-user id=${x} .users=${this.users}></tf-user></li>` | ||||||
|  | 					)} | ||||||
| 				</ul> | 				</ul> | ||||||
| 			</div> | 			</div> | ||||||
| 		`; | 		`; | ||||||
|   | |||||||
| @@ -27,7 +27,8 @@ class TfTabMentionsElement extends LitElement { | |||||||
|  |  | ||||||
| 	async load() { | 	async load() { | ||||||
| 		console.log('Loading...', this.whoami); | 		console.log('Loading...', this.whoami); | ||||||
| 		let results = await tfrpc.rpc.query(` | 		let results = await tfrpc.rpc.query( | ||||||
|  | 			` | ||||||
| 				SELECT messages.* | 				SELECT messages.* | ||||||
| 				FROM messages_fts(?) | 				FROM messages_fts(?) | ||||||
| 				JOIN messages ON messages.rowid = messages_fts.rowid | 				JOIN messages ON messages.rowid = messages_fts.rowid | ||||||
| @@ -35,7 +36,12 @@ class TfTabMentionsElement extends LitElement { | |||||||
| 				WHERE messages.author != ? | 				WHERE messages.author != ? | ||||||
| 				ORDER BY timestamp DESC limit 20 | 				ORDER BY timestamp DESC limit 20 | ||||||
| 			`, | 			`, | ||||||
| 			['"' + this.whoami.replace('"', '""') + '"', JSON.stringify(this.following), this.whoami]); | 			[ | ||||||
|  | 				'"' + this.whoami.replace('"', '""') + '"', | ||||||
|  | 				JSON.stringify(this.following), | ||||||
|  | 				this.whoami, | ||||||
|  | 			] | ||||||
|  | 		); | ||||||
| 		console.log('Done.'); | 		console.log('Done.'); | ||||||
| 		this.messages = results; | 		this.messages = results; | ||||||
| 	} | 	} | ||||||
| @@ -58,7 +64,14 @@ class TfTabMentionsElement extends LitElement { | |||||||
| 			this.load(); | 			this.load(); | ||||||
| 		} | 		} | ||||||
| 		return html` | 		return html` | ||||||
| 			<tf-news id="news" whoami=${this.whoami} .messages=${this.messages} .users=${this.users} .expanded=${this.expanded} @tf-expand=${this.on_expand}></tf-news> | 			<tf-news | ||||||
|  | 				id="news" | ||||||
|  | 				whoami=${this.whoami} | ||||||
|  | 				.messages=${this.messages} | ||||||
|  | 				.users=${this.users} | ||||||
|  | 				.expanded=${this.expanded} | ||||||
|  | 				@tf-expand=${this.on_expand} | ||||||
|  | 			></tf-news> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|   | |||||||
| @@ -45,9 +45,8 @@ class TfTabNewsFeedElement extends LitElement { | |||||||
| 					UNION | 					UNION | ||||||
| 					SELECT * FROM mine | 					SELECT * FROM mine | ||||||
| 				`, | 				`, | ||||||
| 				[ | 				[this.hash.substring(1)] | ||||||
| 					this.hash.substring(1), | 			); | ||||||
| 				]); |  | ||||||
| 			return r; | 			return r; | ||||||
| 		} else if (this.hash.startsWith('#%')) { | 		} else if (this.hash.startsWith('#%')) { | ||||||
| 			return await tfrpc.rpc.query( | 			return await tfrpc.rpc.query( | ||||||
| @@ -61,15 +60,15 @@ class TfTabNewsFeedElement extends LitElement { | |||||||
| 					ON messages.id = messages_refs.message | 					ON messages.id = messages_refs.message | ||||||
| 					WHERE messages_refs.ref = ?1 | 					WHERE messages_refs.ref = ?1 | ||||||
| 				`, | 				`, | ||||||
| 				[ | 				[this.hash.substring(1)] | ||||||
| 					this.hash.substring(1), | 			); | ||||||
| 				]); |  | ||||||
| 		} else { | 		} else { | ||||||
| 			let promises = []; | 			let promises = []; | ||||||
| 			const k_following_limit = 256; | 			const k_following_limit = 256; | ||||||
| 			for (let i = 0; i < this.following.length; i += k_following_limit) { | 			for (let i = 0; i < this.following.length; i += k_following_limit) { | ||||||
| 				promises.push(tfrpc.rpc.query( | 				promises.push( | ||||||
| 					` | 					tfrpc.rpc.query( | ||||||
|  | 						` | ||||||
| 						WITH news AS (SELECT messages.* | 						WITH news AS (SELECT messages.* | ||||||
| 						FROM messages | 						FROM messages | ||||||
| 						JOIN json_each(?) AS following ON messages.author = following.value | 						JOIN json_each(?) AS following ON messages.author = following.value | ||||||
| @@ -87,15 +86,17 @@ class TfTabNewsFeedElement extends LitElement { | |||||||
| 						UNION | 						UNION | ||||||
| 						SELECT news.* FROM news | 						SELECT news.* FROM news | ||||||
| 					`, | 					`, | ||||||
| 					[ | 						[ | ||||||
| 						JSON.stringify(this.following.slice(i, i + k_following_limit)), | 							JSON.stringify(this.following.slice(i, i + k_following_limit)), | ||||||
| 						this.start_time, | 							this.start_time, | ||||||
| 						/* | 							/* | ||||||
| 						** Don't show messages more than a day into the future to prevent | 							 ** Don't show messages more than a day into the future to prevent | ||||||
| 						** messages with far-future timestamps from staying at the top forever. | 							 ** messages with far-future timestamps from staying at the top forever. | ||||||
| 						*/ | 							 */ | ||||||
| 						new Date().valueOf() + 24 * 60 * 60 * 1000, | 							new Date().valueOf() + 24 * 60 * 60 * 1000, | ||||||
| 					])); | 						] | ||||||
|  | 					) | ||||||
|  | 				); | ||||||
| 			} | 			} | ||||||
| 			return [].concat(...(await Promise.all(promises))); | 			return [].concat(...(await Promise.all(promises))); | ||||||
| 		} | 		} | ||||||
| @@ -124,11 +125,8 @@ class TfTabNewsFeedElement extends LitElement { | |||||||
| 				UNION | 				UNION | ||||||
| 				SELECT news.* FROM news | 				SELECT news.* FROM news | ||||||
| 			`, | 			`, | ||||||
| 			[ | 			[JSON.stringify(this.following), this.start_time, last_start_time] | ||||||
| 				JSON.stringify(this.following), | 		); | ||||||
| 				this.start_time, |  | ||||||
| 				last_start_time, |  | ||||||
| 			]); |  | ||||||
| 		this.messages = await this.decrypt([...more, ...this.messages]); | 		this.messages = await this.decrypt([...more, ...this.messages]); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -139,14 +137,12 @@ class TfTabNewsFeedElement extends LitElement { | |||||||
| 			let content; | 			let content; | ||||||
| 			try { | 			try { | ||||||
| 				content = JSON.parse(message?.content); | 				content = JSON.parse(message?.content); | ||||||
| 			} catch { | 			} catch {} | ||||||
| 			} | 			if (typeof content === 'string') { | ||||||
| 			if (typeof(content) === 'string') { |  | ||||||
| 				let decrypted; | 				let decrypted; | ||||||
| 				try { | 				try { | ||||||
| 					decrypted = await tfrpc.rpc.try_decrypt(this.whoami, content); | 					decrypted = await tfrpc.rpc.try_decrypt(this.whoami, content); | ||||||
| 				} catch { | 				} catch {} | ||||||
| 				} |  | ||||||
| 				if (decrypted) { | 				if (decrypted) { | ||||||
| 					try { | 					try { | ||||||
| 						message.decrypted = JSON.parse(decrypted); | 						message.decrypted = JSON.parse(decrypted); | ||||||
| @@ -165,31 +161,48 @@ class TfTabNewsFeedElement extends LitElement { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render() { | 	render() { | ||||||
| 		if (!this.messages || | 		if ( | ||||||
|  | 			!this.messages || | ||||||
| 			this._messages_hash !== this.hash || | 			this._messages_hash !== this.hash || | ||||||
| 			this._messages_following !== this.following) { | 			this._messages_following !== this.following | ||||||
| 			console.log(`loading messages for ${this.whoami} (following ${this.following.length})`); | 		) { | ||||||
|  | 			console.log( | ||||||
|  | 				`loading messages for ${this.whoami} (following ${this.following.length})` | ||||||
|  | 			); | ||||||
| 			let self = this; | 			let self = this; | ||||||
| 			this.messages = []; | 			this.messages = []; | ||||||
| 			this._messages_hash = this.hash; | 			this._messages_hash = this.hash; | ||||||
| 			this._messages_following = this.following; | 			this._messages_following = this.following; | ||||||
| 			this.fetch_messages().then(this.decrypt.bind(this)).then(function(messages) { | 			this.fetch_messages() | ||||||
| 				self.messages = messages; | 				.then(this.decrypt.bind(this)) | ||||||
| 				console.log(`loading mesages done for ${self.whoami}`); | 				.then(function (messages) { | ||||||
| 			}).catch(function(error) { | 					self.messages = messages; | ||||||
| 				alert(JSON.stringify(error, null, 2)); | 					console.log(`loading mesages done for ${self.whoami}`); | ||||||
| 			}); | 				}) | ||||||
|  | 				.catch(function (error) { | ||||||
|  | 					alert(JSON.stringify(error, null, 2)); | ||||||
|  | 				}); | ||||||
| 		} | 		} | ||||||
| 		let more; | 		let more; | ||||||
| 		if (!this.hash.startsWith('#@') && !this.hash.startsWith('#%')) { | 		if (!this.hash.startsWith('#@') && !this.hash.startsWith('#%')) { | ||||||
| 			more = html` | 			more = html` | ||||||
| 				<p> | 				<p> | ||||||
| 					<button class="w3-button w3-dark-grey" @click=${this.load_more}>Load More</button> | 					<button class="w3-button w3-dark-grey" @click=${this.load_more}> | ||||||
|  | 						Load More | ||||||
|  | 					</button> | ||||||
| 				</p> | 				</p> | ||||||
| 			`; | 			`; | ||||||
| 		} | 		} | ||||||
| 		return html` | 		return html` | ||||||
| 			<tf-news id="news" whoami=${this.whoami} .users=${this.users} .messages=${this.messages} .following=${this.following} .drafts=${this.drafts} .expanded=${this.expanded}></tf-news> | 			<tf-news | ||||||
|  | 				id="news" | ||||||
|  | 				whoami=${this.whoami} | ||||||
|  | 				.users=${this.users} | ||||||
|  | 				.messages=${this.messages} | ||||||
|  | 				.following=${this.following} | ||||||
|  | 				.drafts=${this.drafts} | ||||||
|  | 				.expanded=${this.expanded} | ||||||
|  | 			></tf-news> | ||||||
| 			${more} | 			${more} | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -28,7 +28,7 @@ class TfTabNewsElement extends LitElement { | |||||||
| 		this.cache = {}; | 		this.cache = {}; | ||||||
| 		this.drafts = {}; | 		this.drafts = {}; | ||||||
| 		this.expanded = {}; | 		this.expanded = {}; | ||||||
| 		tfrpc.rpc.localStorageGet('drafts').then(function(d) { | 		tfrpc.rpc.localStorageGet('drafts').then(function (d) { | ||||||
| 			self.drafts = JSON.parse(d || '{}'); | 			self.drafts = JSON.parse(d || '{}'); | ||||||
| 		}); | 		}); | ||||||
| 	} | 	} | ||||||
| @@ -48,7 +48,9 @@ class TfTabNewsElement extends LitElement { | |||||||
| 		let news = this.shadowRoot?.getElementById('news'); | 		let news = this.shadowRoot?.getElementById('news'); | ||||||
| 		if (news) { | 		if (news) { | ||||||
| 			console.log('injecting messages', news.messages); | 			console.log('injecting messages', news.messages); | ||||||
| 			news.add_messages(Object.values(Object.fromEntries(this.unread.map(x => [x.id, x])))); | 			news.add_messages( | ||||||
|  | 				Object.values(Object.fromEntries(this.unread.map((x) => [x.id, x]))) | ||||||
|  | 			); | ||||||
| 			this.dispatchEvent(new CustomEvent('refresh')); | 			this.dispatchEvent(new CustomEvent('refresh')); | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| @@ -62,11 +64,16 @@ class TfTabNewsElement extends LitElement { | |||||||
| 			let type = 'private'; | 			let type = 'private'; | ||||||
| 			try { | 			try { | ||||||
| 				type = JSON.parse(message.content).type || type; | 				type = JSON.parse(message.content).type || type; | ||||||
| 			} catch { | 			} catch {} | ||||||
| 			} |  | ||||||
| 			counts[type] = (counts[type] || 0) + 1; | 			counts[type] = (counts[type] || 0) + 1; | ||||||
| 		} | 		} | ||||||
| 		return '↻ Show New: ' + Object.keys(counts).sort().map(x => (counts[x].toString() + ' ' + x + 's')).join(', '); | 		return ( | ||||||
|  | 			'↻ Show New: ' + | ||||||
|  | 			Object.keys(counts) | ||||||
|  | 				.sort() | ||||||
|  | 				.map((x) => counts[x].toString() + ' ' + x + 's') | ||||||
|  | 				.join(', ') | ||||||
|  | 		); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	draft(event) { | 	draft(event) { | ||||||
| @@ -96,23 +103,52 @@ class TfTabNewsElement extends LitElement { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	on_keypress(event) { | 	on_keypress(event) { | ||||||
| 		if (event.target === document.body && | 		if (event.target === document.body && event.key == '.') { | ||||||
| 			event.key == '.') { |  | ||||||
| 			this.show_more(); | 			this.show_more(); | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render() { | 	render() { | ||||||
| 		let profile = this.hash.startsWith('#@') ? | 		let profile = this.hash.startsWith('#@') | ||||||
| 			html`<tf-profile id=${this.hash.substring(1)} whoami=${this.whoami} .users=${this.users}></tf-profile>` : undefined; | 			? html`<tf-profile | ||||||
|  | 					id=${this.hash.substring(1)} | ||||||
|  | 					whoami=${this.whoami} | ||||||
|  | 					.users=${this.users} | ||||||
|  | 				></tf-profile>` | ||||||
|  | 			: undefined; | ||||||
| 		return html` | 		return html` | ||||||
| 			<p class="w3-bar"> | 			<p class="w3-bar"> | ||||||
| 				<button class="w3-bar-item w3-button w3-dark-grey" @click=${this.show_more}>${this.new_messages_text()}</button> | 				<button | ||||||
|  | 					class="w3-bar-item w3-button w3-dark-grey" | ||||||
|  | 					@click=${this.show_more} | ||||||
|  | 				> | ||||||
|  | 					${this.new_messages_text()} | ||||||
|  | 				</button> | ||||||
| 			</p> | 			</p> | ||||||
| 			<div>Welcome, <tf-user id=${this.whoami} .users=${this.users}></tf-user>!</div> | 			<div> | ||||||
| 			<div><tf-compose id="tf-compose" whoami=${this.whoami} .users=${this.users} .drafts=${this.drafts} @tf-draft=${this.draft}></tf-compose></div> | 				Welcome, <tf-user id=${this.whoami} .users=${this.users}></tf-user>! | ||||||
|  | 			</div> | ||||||
|  | 			<div> | ||||||
|  | 				<tf-compose | ||||||
|  | 					id="tf-compose" | ||||||
|  | 					whoami=${this.whoami} | ||||||
|  | 					.users=${this.users} | ||||||
|  | 					.drafts=${this.drafts} | ||||||
|  | 					@tf-draft=${this.draft} | ||||||
|  | 				></tf-compose> | ||||||
|  | 			</div> | ||||||
| 			${profile} | 			${profile} | ||||||
| 			<tf-tab-news-feed id="news" whoami=${this.whoami} .users=${this.users} .following=${this.following} hash=${this.hash} .drafts=${this.drafts} .expanded=${this.expanded} @tf-draft=${this.draft} @tf-expand=${this.on_expand}></tf-tab-news-feed> | 			<tf-tab-news-feed | ||||||
|  | 				id="news" | ||||||
|  | 				whoami=${this.whoami} | ||||||
|  | 				.users=${this.users} | ||||||
|  | 				.following=${this.following} | ||||||
|  | 				hash=${this.hash} | ||||||
|  | 				.drafts=${this.drafts} | ||||||
|  | 				.expanded=${this.expanded} | ||||||
|  | 				@tf-draft=${this.draft} | ||||||
|  | 				@tf-expand=${this.on_expand} | ||||||
|  | 			></tf-tab-news-feed> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|   | |||||||
| @@ -41,7 +41,7 @@ class TfTabQueryElement extends LitElement { | |||||||
| 		await tfrpc.rpc.setHash('#sql=' + encodeURIComponent(query)); | 		await tfrpc.rpc.setHash('#sql=' + encodeURIComponent(query)); | ||||||
| 		let start_time = new Date(); | 		let start_time = new Date(); | ||||||
| 		try { | 		try { | ||||||
| 			this.results = await tfrpc.rpc.query(query, []) | 			this.results = await tfrpc.rpc.query(query, []); | ||||||
| 		} catch (error) { | 		} catch (error) { | ||||||
| 			this.error = error; | 			this.error = error; | ||||||
| 		} | 		} | ||||||
| @@ -79,8 +79,15 @@ class TfTabQueryElement extends LitElement { | |||||||
| 		} else { | 		} else { | ||||||
| 			let keys = Object.keys(this.results[0]).sort(); | 			let keys = Object.keys(this.results[0]).sort(); | ||||||
| 			return html`<table style="width: 100%; max-width: 100%"> | 			return html`<table style="width: 100%; max-width: 100%"> | ||||||
| 				<tr>${keys.map(key => html`<th>${key}</th>`)}</tr> | 				<tr> | ||||||
| 				${this.results.map(row => html`<tr>${keys.map(key => html`<td>${row[key]}</td>`)}</tr>`)} | 					${keys.map((key) => html`<th>${key}</th>`)} | ||||||
|  | 				</tr> | ||||||
|  | 				${this.results.map( | ||||||
|  | 					(row) => | ||||||
|  | 						html`<tr> | ||||||
|  | 							${keys.map((key) => html`<td>${row[key]}</td>`)} | ||||||
|  | 						</tr>` | ||||||
|  | 				)} | ||||||
| 			</table>`; | 			</table>`; | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| @@ -100,13 +107,28 @@ class TfTabQueryElement extends LitElement { | |||||||
| 		let self = this; | 		let self = this; | ||||||
| 		return html` | 		return html` | ||||||
| 			<div style="display: flex; flex-direction: row; gap: 4px"> | 			<div style="display: flex; flex-direction: row; gap: 4px"> | ||||||
| 				<textarea id="search" rows=8 class="w3-input w3-dark-grey" style="flex: 1; resize: vertical" @keydown=${this.search_keydown}>${this.query}</textarea> | 				<textarea | ||||||
| 				<button class="w3-button w3-dark-grey" @click=${(event) => self.search(self.renderRoot.getElementById('search').value)}>Execute</button> | 					id="search" | ||||||
|  | 					rows="8" | ||||||
|  | 					class="w3-input w3-dark-grey" | ||||||
|  | 					style="flex: 1; resize: vertical" | ||||||
|  | 					@keydown=${this.search_keydown} | ||||||
|  | 				> | ||||||
|  | ${this.query}</textarea | ||||||
|  | 				> | ||||||
|  | 				<button | ||||||
|  | 					class="w3-button w3-dark-grey" | ||||||
|  | 					@click=${(event) => | ||||||
|  | 						self.search(self.renderRoot.getElementById('search').value)} | ||||||
|  | 				> | ||||||
|  | 					Execute | ||||||
|  | 				</button> | ||||||
|  | 			</div> | ||||||
|  | 			<div ?hidden=${this.duration === undefined}> | ||||||
|  | 				Took ${this.duration / 1000.0} seconds. | ||||||
| 			</div> | 			</div> | ||||||
| 			<div ?hidden=${this.duration === undefined}>Took ${this.duration / 1000.0} seconds.</div> |  | ||||||
| 			<div ?hidden=${this.duration !== undefined}>Executing...</div> | 			<div ?hidden=${this.duration !== undefined}>Executing...</div> | ||||||
| 			${this.render_error()} | 			${this.render_error()} ${this.render_results()} | ||||||
| 			${this.render_results()} |  | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|   | |||||||
| @@ -27,23 +27,25 @@ class TfTabSearchElement extends LitElement { | |||||||
| 	async search(query) { | 	async search(query) { | ||||||
| 		console.log('Searching...', this.whoami, query); | 		console.log('Searching...', this.whoami, query); | ||||||
| 		let search = this.renderRoot.getElementById('search'); | 		let search = this.renderRoot.getElementById('search'); | ||||||
| 		if (search ) { | 		if (search) { | ||||||
| 			search.value = query; | 			search.value = query; | ||||||
| 			search.focus(); | 			search.focus(); | ||||||
| 			search.select(); | 			search.select(); | ||||||
| 		} | 		} | ||||||
| 		await tfrpc.rpc.setHash('#q=' + encodeURIComponent(query)); | 		await tfrpc.rpc.setHash('#q=' + encodeURIComponent(query)); | ||||||
| 		let results = await tfrpc.rpc.query(` | 		let results = await tfrpc.rpc.query( | ||||||
|  | 			` | ||||||
| 				SELECT messages.* | 				SELECT messages.* | ||||||
| 				FROM messages_fts(?) | 				FROM messages_fts(?) | ||||||
| 				JOIN messages ON messages.rowid = messages_fts.rowid | 				JOIN messages ON messages.rowid = messages_fts.rowid | ||||||
| 				JOIN json_each(?) AS following ON messages.author = following.value | 				JOIN json_each(?) AS following ON messages.author = following.value | ||||||
| 				ORDER BY timestamp DESC limit 100 | 				ORDER BY timestamp DESC limit 100 | ||||||
| 			`, | 			`, | ||||||
| 			['"' + query.replace('"', '""') + '"', JSON.stringify(this.following)]); | 			['"' + query.replace('"', '""') + '"', JSON.stringify(this.following)] | ||||||
|  | 		); | ||||||
| 		console.log('Done.'); | 		console.log('Done.'); | ||||||
| 		search = this.renderRoot.getElementById('search'); | 		search = this.renderRoot.getElementById('search'); | ||||||
| 		if (search ) { | 		if (search) { | ||||||
| 			search.value = query; | 			search.value = query; | ||||||
| 			search.focus(); | 			search.focus(); | ||||||
| 			search.select(); | 			search.select(); | ||||||
|   | |||||||
| @@ -17,7 +17,11 @@ class TfTagElement extends LitElement { | |||||||
|  |  | ||||||
| 	render() { | 	render() { | ||||||
| 		let number = this.count ? html` (${this.count})` : undefined; | 		let number = this.count ? html` (${this.count})` : undefined; | ||||||
| 		return html`<a href="#q=${this.tag}" style="display: inline-block; margin: 3px; border: 1px solid black; background-color: #444; padding: 4px; border-radius: 3px">${this.tag}${number}</a>`; | 		return html`<a | ||||||
|  | 			href="#q=${this.tag}" | ||||||
|  | 			style="display: inline-block; margin: 3px; border: 1px solid black; background-color: #444; padding: 4px; border-radius: 3px" | ||||||
|  | 			>${this.tag}${number}</a | ||||||
|  | 		>`; | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -20,23 +20,26 @@ class TfUserElement extends LitElement { | |||||||
|  |  | ||||||
| 	render() { | 	render() { | ||||||
| 		let name = this.users?.[this.id]?.name; | 		let name = this.users?.[this.id]?.name; | ||||||
| 		name = name !== undefined ? | 		name = | ||||||
| 			html`<a target="_top" href=${'#' + this.id}>${name}</a>` : | 			name !== undefined | ||||||
| 			html`<a target="_top" href=${'#' + this.id}>${this.id}</a>`; | 				? html`<a target="_top" href=${'#' + this.id}>${name}</a>` | ||||||
|  | 				: html`<a target="_top" href=${'#' + this.id}>${this.id}</a>`; | ||||||
|  |  | ||||||
| 		if (this.users[this.id]) { | 		if (this.users[this.id]) { | ||||||
| 			let image = this.users[this.id].image; | 			let image = this.users[this.id].image; | ||||||
| 			image = typeof(image) == 'string' ? image : image?.link; | 			image = typeof image == 'string' ? image : image?.link; | ||||||
| 			return html` | 			return html` <div style="display: inline-block; font-weight: bold"> | ||||||
| 				<div style="display: inline-block; font-weight: bold"> | 				<img | ||||||
| 						<img style="width: 2em; height: 2em; vertical-align: middle; border-radius: 50%" ?hidden=${image === undefined} src="${image ? '/' + image + '/view' : undefined}"> | 					style="width: 2em; height: 2em; vertical-align: middle; border-radius: 50%" | ||||||
| 						${name} | 					?hidden=${image === undefined} | ||||||
| 				</div>`; | 					src="${image ? '/' + image + '/view' : undefined}" | ||||||
|  | 				/> | ||||||
|  | 				${name} | ||||||
|  | 			</div>`; | ||||||
| 		} else { | 		} else { | ||||||
| 			return html` | 			return html` <div style="display: inline-block; font-weight: bold"> | ||||||
| 				<div style="display: inline-block; font-weight: bold"> | 				${name} | ||||||
| 					${name} | 			</div>`; | ||||||
| 				</div>`; |  | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|   | |||||||
| @@ -2,20 +2,32 @@ import * as linkify from './commonmark-linkify.js'; | |||||||
| import * as hashtagify from './commonmark-hashtag.js'; | import * as hashtagify from './commonmark-hashtag.js'; | ||||||
|  |  | ||||||
| function image(node, entering) { | function image(node, entering) { | ||||||
| 	if (node.firstChild?.type === 'text' && | 	if ( | ||||||
| 		node.firstChild.literal.startsWith('video:')) { | 		node.firstChild?.type === 'text' && | ||||||
|  | 		node.firstChild.literal.startsWith('video:') | ||||||
|  | 	) { | ||||||
| 		if (entering) { | 		if (entering) { | ||||||
| 			this.lit('<video style="max-width: 100%; max-height: 480px" title="' + this.esc(node.firstChild?.literal) + '" controls>'); | 			this.lit( | ||||||
|  | 				'<video style="max-width: 100%; max-height: 480px" title="' + | ||||||
|  | 					this.esc(node.firstChild?.literal) + | ||||||
|  | 					'" controls>' | ||||||
|  | 			); | ||||||
| 			this.lit('<source src="' + this.esc(node.destination) + '"></source>'); | 			this.lit('<source src="' + this.esc(node.destination) + '"></source>'); | ||||||
| 			this.disableTags += 1; | 			this.disableTags += 1; | ||||||
| 		} else { | 		} else { | ||||||
| 			this.disableTags -= 1; | 			this.disableTags -= 1; | ||||||
| 			this.lit('</video>'); | 			this.lit('</video>'); | ||||||
| 		} | 		} | ||||||
| 	} else if (node.firstChild?.type === 'text' && | 	} else if ( | ||||||
| 		node.firstChild.literal.startsWith('audio:')) { | 		node.firstChild?.type === 'text' && | ||||||
|  | 		node.firstChild.literal.startsWith('audio:') | ||||||
|  | 	) { | ||||||
| 		if (entering) { | 		if (entering) { | ||||||
| 			this.lit('<audio style="height: 32px; max-width: 100%" title="' + this.esc(node.firstChild?.literal) + '" controls>'); | 			this.lit( | ||||||
|  | 				'<audio style="height: 32px; max-width: 100%" title="' + | ||||||
|  | 					this.esc(node.firstChild?.literal) + | ||||||
|  | 					'" controls>' | ||||||
|  | 			); | ||||||
| 			this.lit('<source src="' + this.esc(node.destination) + '"></source>'); | 			this.lit('<source src="' + this.esc(node.destination) + '"></source>'); | ||||||
| 			this.disableTags += 1; | 			this.disableTags += 1; | ||||||
| 		} else { | 		} else { | ||||||
| @@ -25,7 +37,11 @@ function image(node, entering) { | |||||||
| 	} else { | 	} else { | ||||||
| 		if (entering) { | 		if (entering) { | ||||||
| 			if (this.disableTags === 0) { | 			if (this.disableTags === 0) { | ||||||
| 				this.lit('<div class="img_caption">' + this.esc(node.firstChild?.literal || node.destination) + '</div>'); | 				this.lit( | ||||||
|  | 					'<div class="img_caption">' + | ||||||
|  | 						this.esc(node.firstChild?.literal || node.destination) + | ||||||
|  | 						'</div>' | ||||||
|  | 				); | ||||||
| 				if (this.options.safe && potentiallyUnsafe(node.destination)) { | 				if (this.options.safe && potentiallyUnsafe(node.destination)) { | ||||||
| 					this.lit('<img src="" alt="'); | 					this.lit('<img src="" alt="'); | ||||||
| 				} else { | 				} else { | ||||||
| @@ -58,14 +74,20 @@ export function markdown(md) { | |||||||
| 		node = event.node; | 		node = event.node; | ||||||
| 		if (event.entering) { | 		if (event.entering) { | ||||||
| 			if (node.type == 'link') { | 			if (node.type == 'link') { | ||||||
| 				if (node.destination.startsWith('@') && | 				if ( | ||||||
| 					node.destination.endsWith('.ed25519')) { | 					node.destination.startsWith('@') && | ||||||
|  | 					node.destination.endsWith('.ed25519') | ||||||
|  | 				) { | ||||||
| 					node.destination = '#' + node.destination; | 					node.destination = '#' + node.destination; | ||||||
| 				} else if (node.destination.startsWith('%') && | 				} else if ( | ||||||
| 					node.destination.endsWith('.sha256')) { | 					node.destination.startsWith('%') && | ||||||
|  | 					node.destination.endsWith('.sha256') | ||||||
|  | 				) { | ||||||
| 					node.destination = '#' + node.destination; | 					node.destination = '#' + node.destination; | ||||||
| 				} else if (node.destination.startsWith('&') && | 				} else if ( | ||||||
| 					node.destination.endsWith('.sha256')) { | 					node.destination.startsWith('&') && | ||||||
|  | 					node.destination.endsWith('.sha256') | ||||||
|  | 				) { | ||||||
| 					node.destination = '/' + node.destination + '/view'; | 					node.destination = '/' + node.destination + '/view'; | ||||||
| 				} | 				} | ||||||
| 			} else if (node.type == 'image') { | 			} else if (node.type == 'image') { | ||||||
|   | |||||||
| @@ -1,32 +1,32 @@ | |||||||
| .tribute-container { | .tribute-container { | ||||||
|   position: absolute; | 	position: absolute; | ||||||
|   top: 0; | 	top: 0; | ||||||
|   left: 0; | 	left: 0; | ||||||
|   height: auto; | 	height: auto; | ||||||
|   overflow: auto; | 	overflow: auto; | ||||||
|   display: block; | 	display: block; | ||||||
|   z-index: 999999; | 	z-index: 999999; | ||||||
| } | } | ||||||
| .tribute-container ul { | .tribute-container ul { | ||||||
|   margin: 0; | 	margin: 0; | ||||||
|   margin-top: 2px; | 	margin-top: 2px; | ||||||
|   padding: 0; | 	padding: 0; | ||||||
|   list-style: none; | 	list-style: none; | ||||||
|   background: #efefef; | 	background: #efefef; | ||||||
| } | } | ||||||
| .tribute-container li { | .tribute-container li { | ||||||
|   padding: 5px 5px; | 	padding: 5px 5px; | ||||||
|   cursor: pointer; | 	cursor: pointer; | ||||||
| } | } | ||||||
| .tribute-container li.highlight { | .tribute-container li.highlight { | ||||||
|   background: #ddd; | 	background: #ddd; | ||||||
| } | } | ||||||
| .tribute-container li span { | .tribute-container li span { | ||||||
|   font-weight: bold; | 	font-weight: bold; | ||||||
| } | } | ||||||
| .tribute-container li.no-match { | .tribute-container li.no-match { | ||||||
|   cursor: default; | 	cursor: default; | ||||||
| } | } | ||||||
| .tribute-container .menu-highlighted { | .tribute-container .menu-highlighted { | ||||||
|   font-weight: bold; | 	font-weight: bold; | ||||||
| } | } | ||||||
| @@ -27,7 +27,8 @@ async function todo_add(list) { | |||||||
| 		let set = new Set(names); | 		let set = new Set(names); | ||||||
| 		set.add(list); | 		set.add(list); | ||||||
| 		names = JSON.stringify([...set].sort()); | 		names = JSON.stringify([...set].sort()); | ||||||
| 		exchanged = original === names || await g_db.exchange('files', original, names); | 		exchanged = | ||||||
|  | 			original === names || (await g_db.exchange('files', original, names)); | ||||||
| 	} | 	} | ||||||
| 	return exchanged; | 	return exchanged; | ||||||
| } | } | ||||||
| @@ -42,7 +43,8 @@ async function todo_remove(list) { | |||||||
| 		let set = new Set(names); | 		let set = new Set(names); | ||||||
| 		set.delete(list); | 		set.delete(list); | ||||||
| 		names = JSON.stringify([...set].sort()); | 		names = JSON.stringify([...set].sort()); | ||||||
| 		exchanged = original === names || await g_db.exchange('files', original, names); | 		exchanged = | ||||||
|  | 			original === names || (await g_db.exchange('files', original, names)); | ||||||
| 	} | 	} | ||||||
| 	await g_db.remove('list:' + list); | 	await g_db.remove('list:' + list); | ||||||
| 	return exchanged; | 	return exchanged; | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| <!DOCTYPE html> | <!doctype html> | ||||||
| <html> | <html> | ||||||
| 	<head> | 	<head> | ||||||
| 		<title>TODO</title> | 		<title>TODO</title> | ||||||
|   | |||||||
| @@ -4,7 +4,7 @@ import * as tfrpc from '/static/tfrpc.js'; | |||||||
| class TodosElement extends LitElement { | class TodosElement extends LitElement { | ||||||
| 	static get properties() { | 	static get properties() { | ||||||
| 		return { | 		return { | ||||||
| 			lists: {type: Array} | 			lists: {type: Array}, | ||||||
| 		}; | 		}; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -12,11 +12,14 @@ class TodosElement extends LitElement { | |||||||
| 		super(); | 		super(); | ||||||
| 		this.lists = []; | 		this.lists = []; | ||||||
| 		let self = this; | 		let self = this; | ||||||
| 		tfrpc.rpc.todo_get_all().then(function(lists) { | 		tfrpc.rpc | ||||||
| 			self.lists = lists; | 			.todo_get_all() | ||||||
| 		}).catch(function(error) { | 			.then(function (lists) { | ||||||
| 			console.log(error); | 				self.lists = lists; | ||||||
| 		}); | 			}) | ||||||
|  | 			.catch(function (error) { | ||||||
|  | 				console.log(error); | ||||||
|  | 			}); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	async new_list() { | 	async new_list() { | ||||||
| @@ -32,9 +35,15 @@ class TodosElement extends LitElement { | |||||||
| 		return html` | 		return html` | ||||||
| 			<div> | 			<div> | ||||||
| 				<div style="display: flex"> | 				<div style="display: flex"> | ||||||
| 					${this.lists.map(x => html` | 					${this.lists.map( | ||||||
| 						<tf-todo-list name=${x.name} .items=${x.items} @change=${this.refresh}></tf-todo-list> | 						(x) => html` | ||||||
| 					`)} | 							<tf-todo-list | ||||||
|  | 								name=${x.name} | ||||||
|  | 								.items=${x.items} | ||||||
|  | 								@change=${this.refresh} | ||||||
|  | 							></tf-todo-list> | ||||||
|  | 						` | ||||||
|  | 					)} | ||||||
| 				</div> | 				</div> | ||||||
| 				<input type="button" @click=${this.new_list} value="+ List"></input> | 				<input type="button" @click=${this.new_list} value="+ List"></input> | ||||||
| 			</div>`; | 			</div>`; | ||||||
| @@ -59,16 +68,22 @@ class TodoListElement extends LitElement { | |||||||
| 	save() { | 	save() { | ||||||
| 		let self = this; | 		let self = this; | ||||||
| 		console.log('saving', self.name, self.items); | 		console.log('saving', self.name, self.items); | ||||||
| 		tfrpc.rpc.todo_set(self.name, self.items).then(function() { | 		tfrpc.rpc | ||||||
| 			console.log('saved', self.name, self.items); | 			.todo_set(self.name, self.items) | ||||||
| 		}).catch(function(error) { | 			.then(function () { | ||||||
| 			console.log(error); | 				console.log('saved', self.name, self.items); | ||||||
| 		}); | 			}) | ||||||
|  | 			.catch(function (error) { | ||||||
|  | 				console.log(error); | ||||||
|  | 			}); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	remove_item(item) { | 	remove_item(item) { | ||||||
| 		let index = this.items.indexOf(item); | 		let index = this.items.indexOf(item); | ||||||
| 		this.items = [].concat(this.items.slice(0, index), this.items.slice(index + 1)); | 		this.items = [].concat( | ||||||
|  | 			this.items.slice(0, index), | ||||||
|  | 			this.items.slice(index + 1) | ||||||
|  | 		); | ||||||
| 		this.save(); | 		this.save(); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -106,20 +121,20 @@ class TodoListElement extends LitElement { | |||||||
| 		let self = this; | 		let self = this; | ||||||
| 		if (index === this.editing) { | 		if (index === this.editing) { | ||||||
| 			return html` | 			return html` | ||||||
| 				<div><input type="checkbox" ?checked=${item.x} @change=${x => self.handle_check(x, item)}></input> | 				<div><input type="checkbox" ?checked=${item.x} @change=${(x) => self.handle_check(x, item)}></input> | ||||||
| 				<input | 				<input | ||||||
| 					id="edit" | 					id="edit" | ||||||
| 					type="text" | 					type="text" | ||||||
| 					value=${item.text} | 					value=${item.text} | ||||||
| 					@change=${event => self.input_change(event, item)} | 					@change=${(event) => self.input_change(event, item)} | ||||||
| 					@keydown=${event => self.input_keydown(event, item)} | 					@keydown=${(event) => self.input_keydown(event, item)} | ||||||
| 					@blur=${x => self.input_blur(item)}></input> | 					@blur=${(x) => self.input_blur(item)}></input> | ||||||
| 				<span @click=${x => self.remove_item(item)} style="cursor: pointer">❎</span></div> | 				<span @click=${(x) => self.remove_item(item)} style="cursor: pointer">❎</span></div> | ||||||
| 			`; | 			`; | ||||||
| 		} else { | 		} else { | ||||||
| 			return html` | 			return html` | ||||||
| 				<div><input type="checkbox" ?checked=${item.x} @change=${x => self.handle_check(x, item)}></input> | 				<div><input type="checkbox" ?checked=${item.x} @change=${(x) => self.handle_check(x, item)}></input> | ||||||
| 				<span @click=${x => self.editing = index}>${item.text || '(empty)'}</span> | 				<span @click=${(x) => (self.editing = index)}>${item.text || '(empty)'}</span> | ||||||
| 			`; | 			`; | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| @@ -139,14 +154,17 @@ class TodoListElement extends LitElement { | |||||||
|  |  | ||||||
| 	rename(new_name) { | 	rename(new_name) { | ||||||
| 		let self = this; | 		let self = this; | ||||||
| 		return tfrpc.rpc.todo_rename(this.name, new_name).then(function() { | 		return tfrpc.rpc | ||||||
| 			self.dispatchEvent(new Event('change')); | 			.todo_rename(this.name, new_name) | ||||||
| 			self.editing_name = false; | 			.then(function () { | ||||||
| 		}).catch(function(error) { | 				self.dispatchEvent(new Event('change')); | ||||||
| 			console.log(error); | 				self.editing_name = false; | ||||||
| 			alert(error.message); | 			}) | ||||||
| 			self.editing_name = false; | 			.catch(function (error) { | ||||||
| 		}); | 				console.log(error); | ||||||
|  | 				alert(error.message); | ||||||
|  | 				self.editing_name = false; | ||||||
|  | 			}); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	name_blur(new_name) { | 	name_blur(new_name) { | ||||||
| @@ -163,19 +181,25 @@ class TodoListElement extends LitElement { | |||||||
|  |  | ||||||
| 	render() { | 	render() { | ||||||
| 		let self = this; | 		let self = this; | ||||||
| 		let name = this.editing_name ? | 		let name = this.editing_name | ||||||
| 			html`<input | 			? html`<input | ||||||
| 				type="text" | 				type="text" | ||||||
| 				id="edit" | 				id="edit" | ||||||
| 				@keydown=${event => self.name_keydown(event)} | 				@keydown=${(event) => self.name_keydown(event)} | ||||||
| 				@blur=${event => self.name_blur(event.srcElement.value)} | 				@blur=${(event) => self.name_blur(event.srcElement.value)} | ||||||
| 				value=${this.name}></input>` : | 				value=${this.name}></input>` | ||||||
| 			html`<h2 @click=${x => this.editing_name = true}>${this.name}</h2>`; | 			: html`<h2 @click=${(x) => (this.editing_name = true)}>${this.name}</h2>`; | ||||||
| 		return html` | 		return html` | ||||||
| 			<div style="border: 3px solid black; padding: 8px; margin: 8px; border-radius: 8px; background-color: #444"> | 			<div | ||||||
|  | 				style="border: 3px solid black; padding: 8px; margin: 8px; border-radius: 8px; background-color: #444" | ||||||
|  | 			> | ||||||
| 				${name} | 				${name} | ||||||
| 				${(this.items || []).filter(item => !item.x).map(x => self.render_item(x))} | 				${(this.items || []) | ||||||
| 				${(this.items || []).filter(item => item.x).map(x => self.render_item(x))} | 					.filter((item) => !item.x) | ||||||
|  | 					.map((x) => self.render_item(x))} | ||||||
|  | 				${(this.items || []) | ||||||
|  | 					.filter((item) => item.x) | ||||||
|  | 					.map((x) => self.render_item(x))} | ||||||
| 				<button @click=${self.add_item}>+ Item</button> | 				<button @click=${self.add_item}>+ Item</button> | ||||||
| 				<button @click=${self.remove_list}>- List</button> | 				<button @click=${self.remove_list}>- List</button> | ||||||
| 			</div> | 			</div> | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| { | { | ||||||
|   "type": "tildefriends-app", | 	"type": "tildefriends-app", | ||||||
|   "emoji": "👋", | 	"emoji": "👋", | ||||||
|   "previous": "&zFISmRDAv+SXFonfZ9/sHNhrmMe+poTU22gwZzuSkT4=.sha256" | 	"previous": "&zFISmRDAv+SXFonfZ9/sHNhrmMe+poTU22gwZzuSkT4=.sha256" | ||||||
| } | } | ||||||
| @@ -1,23 +1,36 @@ | |||||||
| <!DOCTYPE html> | <!doctype html> | ||||||
| <html> | <html> | ||||||
| 	<head> | 	<head> | ||||||
| 		<meta charset="UTF-8"> | 		<meta charset="UTF-8" /> | ||||||
| 		<meta name="viewport" content="width=device-width, initial-scale=1"> | 		<meta name="viewport" content="width=device-width, initial-scale=1" /> | ||||||
| 		<link rel="stylesheet" href="w3.css"> | 		<link rel="stylesheet" href="w3.css" /> | ||||||
| 		<link rel="stylesheet" href="fontawesome.min.css"> | 		<link rel="stylesheet" href="fontawesome.min.css" /> | ||||||
| 		<link rel="stylesheet" href="regular.min.css"> | 		<link rel="stylesheet" href="regular.min.css" /> | ||||||
| 		<link rel="stylesheet" href="solid.min.css"> | 		<link rel="stylesheet" href="solid.min.css" /> | ||||||
| 		<link rel="stylesheet" href="brands.min.css"> | 		<link rel="stylesheet" href="brands.min.css" /> | ||||||
|  |  | ||||||
| 		<style> | 		<style> | ||||||
| 			body,h1,h2,h3,h4,h5 {font-family: "Poppins", sans-serif} | 			body, | ||||||
| 			body {font-size: 16px;} | 			h1, | ||||||
| 			img {margin-bottom: -8px;} | 			h2, | ||||||
| 			.mySlides {display: none;} | 			h3, | ||||||
|  | 			h4, | ||||||
|  | 			h5 { | ||||||
|  | 				font-family: 'Poppins', sans-serif; | ||||||
|  | 			} | ||||||
|  | 			body { | ||||||
|  | 				font-size: 16px; | ||||||
|  | 			} | ||||||
|  | 			img { | ||||||
|  | 				margin-bottom: -8px; | ||||||
|  | 			} | ||||||
|  | 			.mySlides { | ||||||
|  | 				display: none; | ||||||
|  | 			} | ||||||
| 		</style> | 		</style> | ||||||
| 		<base target="_top"> | 		<base target="_top" /> | ||||||
| 	</head> | 	</head> | ||||||
| 	<body class="w3-content w3-black" style="max-width:1500px;"> | 	<body class="w3-content w3-black" style="max-width: 1500px"> | ||||||
| 		<!-- The App Section --> | 		<!-- The App Section --> | ||||||
| 		<div class="w3-padding-64 w3-white"> | 		<div class="w3-padding-64 w3-white"> | ||||||
| 			<div class="w3-row-padding"> | 			<div class="w3-row-padding"> | ||||||
| @@ -25,41 +38,64 @@ | |||||||
| 					<h1 class="w3-jumbo"> | 					<h1 class="w3-jumbo"> | ||||||
| 						<b>😎 Tilde Friends</b> | 						<b>😎 Tilde Friends</b> | ||||||
| 					</h1> | 					</h1> | ||||||
| 					<h1 class="w3-xxlarge w3-text-green"><b>Make apps and friends from the comfort of your web browser.</b></h1> | 					<h1 class="w3-xxlarge w3-text-green"> | ||||||
| 					<p>Tilde Friends is a platform for building, running, and sharing web applications.</p> | 						<b>Make apps and friends from the comfort of your web browser.</b> | ||||||
| 					<p>Available for lots of devices: | 					</h1> | ||||||
|  | 					<p> | ||||||
|  | 						Tilde Friends is a platform for building, running, and sharing web | ||||||
|  | 						applications. | ||||||
|  | 					</p> | ||||||
|  | 					<p> | ||||||
|  | 						Available for lots of devices: | ||||||
| 						<i class="fa-brands fa-linux w3-xlarge"></i> | 						<i class="fa-brands fa-linux w3-xlarge"></i> | ||||||
| 						<i class="fa-brands fa-android w3-xlarge"></i> | 						<i class="fa-brands fa-android w3-xlarge"></i> | ||||||
| 						<i class="fa-brands fa-apple w3-xlarge"></i> | 						<i class="fa-brands fa-apple w3-xlarge"></i> | ||||||
| 						<i class="fa fa-mobile-screen w3-xlarge"></i> | 						<i class="fa fa-mobile-screen w3-xlarge"></i> | ||||||
| 						<i class="fa-brands fa-windows w3-xlarge"></i> | 						<i class="fa-brands fa-windows w3-xlarge"></i> | ||||||
| 					</p> | 					</p> | ||||||
| 					<a class="w3-button w3-black w3-padding-large" href="https://www.tildefriends.net/~cory/releases/"><i class="fa fa-download"></i> Download</a> | 					<a | ||||||
| 					<a class="w3-button w3-black w3-padding-large" href="https://www.tildefriends.net/~cory/apps/"><i class="fa fa-link"></i> Try It</a> | 						class="w3-button w3-black w3-padding-large" | ||||||
|  | 						href="https://www.tildefriends.net/~cory/releases/" | ||||||
|  | 						><i class="fa fa-download"></i> Download</a | ||||||
|  | 					> | ||||||
|  | 					<a | ||||||
|  | 						class="w3-button w3-black w3-padding-large" | ||||||
|  | 						href="https://www.tildefriends.net/~cory/apps/" | ||||||
|  | 						><i class="fa fa-link"></i> Try It</a | ||||||
|  | 					> | ||||||
| 				</div> | 				</div> | ||||||
| 				<div class="w3-col l4 m6"> | 				<div class="w3-col l4 m6"> | ||||||
| 					<img src="tildefriends.png" class="w3-image w3-right w3-hide-small"> | 					<img src="tildefriends.png" class="w3-image w3-right w3-hide-small" /> | ||||||
| 				</div> | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
| 		</div> | 		</div> | ||||||
|  |  | ||||||
| 		<!-- SSB Section --> | 		<!-- SSB Section --> | ||||||
| 		<div class="w3-light-grey"> | 		<div class="w3-light-grey"> | ||||||
| 			<div class="w3-row-padding w3-padding-64 "> | 			<div class="w3-row-padding w3-padding-64"> | ||||||
| 				<div class="w3-col l4 m6 s4"> | 				<div class="w3-col l4 m6 s4"> | ||||||
| 					<a href="https://scuttlebutt.nz/"><img class="w3-image w3-round-large" src="ssb.png" alt="Secure Scuttlebutt"></a> | 					<a href="https://scuttlebutt.nz/" | ||||||
|  | 						><img | ||||||
|  | 							class="w3-image w3-round-large" | ||||||
|  | 							src="ssb.png" | ||||||
|  | 							alt="Secure Scuttlebutt" | ||||||
|  | 					/></a> | ||||||
| 				</div> | 				</div> | ||||||
| 				<div class="w3-col l8 m6" style="height: auto"> | 				<div class="w3-col l8 m6" style="height: auto"> | ||||||
| 					<h1 class="w3-jumbo"><b>Built for Sharing</b></h1> | 					<h1 class="w3-jumbo"><b>Built for Sharing</b></h1> | ||||||
| 					<p> | 					<p> | ||||||
| 						Tilde Friends participates in the <a href="https://scuttlebutt.nz/">Secure Scuttlebutt</a> distributed social network. | 						Tilde Friends participates in the | ||||||
|  | 						<a href="https://scuttlebutt.nz/">Secure Scuttlebutt</a> distributed | ||||||
|  | 						social network. | ||||||
| 					</p> | 					</p> | ||||||
| 					<p> | 					<p> | ||||||
| 						Share apps with friends.  Discover new apps made by enemies.  Post pictures of your coffee.  Or just lurk. | 						Share apps with friends. Discover new apps made by enemies. Post | ||||||
|  | 						pictures of your coffee. Or just lurk. | ||||||
| 					</p> | 					</p> | ||||||
| 					<p> | 					<p> | ||||||
| 						The social network integration provides tools for connecting with other people world-wide | 						The social network integration provides tools for connecting with | ||||||
| 						while still allowing apps and everything to operate offline. | 						other people world-wide while still allowing apps and everything to | ||||||
|  | 						operate offline. | ||||||
| 					</p> | 					</p> | ||||||
| 				</div> | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
| @@ -70,14 +106,16 @@ | |||||||
| 			<div class="w3-row-padding"> | 			<div class="w3-row-padding"> | ||||||
| 				<div class="w3-col l8 m6"> | 				<div class="w3-col l8 m6"> | ||||||
| 					<h1 class="w3-jumbo"><b>Edit Anything</b></h1> | 					<h1 class="w3-jumbo"><b>Edit Anything</b></h1> | ||||||
| 					<i class="fa fa-pen-to-square w3-left w3-jumbo w3-text-gray" style="padding: 32px"></i> | 					<i | ||||||
|  | 						class="fa fa-pen-to-square w3-left w3-jumbo w3-text-gray" | ||||||
|  | 						style="padding: 32px" | ||||||
|  | 					></i> | ||||||
| 					<p> | 					<p> | ||||||
| 						See that <code><b>edit</b></code> link near the top left corner of this page?  It's there for | 						See that <code><b>edit</b></code> link near the top left corner of | ||||||
| 						every Tilde Friends app, so you can modify and see your changes right away. | 						this page? It's there for every Tilde Friends app, so you can modify | ||||||
| 					</p> | 						and see your changes right away. | ||||||
| 					<p> |  | ||||||
| 						It's kind of like a wiki, but for code! |  | ||||||
| 					</p> | 					</p> | ||||||
|  | 					<p>It's kind of like a wiki, but for code!</p> | ||||||
| 				</div> | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
| 		</div> | 		</div> | ||||||
| @@ -86,16 +124,22 @@ | |||||||
| 		<div class="w3-padding-64 w3-grey"> | 		<div class="w3-padding-64 w3-grey"> | ||||||
| 			<div class="w3-row-padding"> | 			<div class="w3-row-padding"> | ||||||
| 				<div class="w3-col"> | 				<div class="w3-col"> | ||||||
| 					<h1 class="w3-jumbo" style="text-align: right"><b>Sandbox Security</b></h1> | 					<h1 class="w3-jumbo" style="text-align: right"> | ||||||
| 					<i class="fa fa-road-barrier w3-right w3-jumbo w3-text-yellow" style="padding: 32px"></i> | 						<b>Sandbox Security</b> | ||||||
|  | 					</h1> | ||||||
|  | 					<i | ||||||
|  | 						class="fa fa-road-barrier w3-right w3-jumbo w3-text-yellow" | ||||||
|  | 						style="padding: 32px" | ||||||
|  | 					></i> | ||||||
| 					<p> | 					<p> | ||||||
| 						Tilde Friends tries to make sure apps can be trusted using similar techniques to how web | 						Tilde Friends tries to make sure apps can be trusted using similar | ||||||
| 						browsers and operating systems do it. | 						techniques to how web browsers and operating systems do it. | ||||||
| 					</p> | 					</p> | ||||||
| 					<p> | 					<p> | ||||||
| 						This is all a work in progress, and it varies by platform, so don't give it all your | 						This is all a work in progress, and it varies by platform, so don't | ||||||
| 						innermost secrets yet, but do kick its tires and | 						give it all your innermost secrets yet, but do kick its tires and | ||||||
| 						<a href="mailto:cory@tildefriends.net">share</a> any surprises you find. | 						<a href="mailto:cory@tildefriends.net">share</a> any surprises you | ||||||
|  | 						find. | ||||||
| 					</p> | 					</p> | ||||||
| 				</div> | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
| @@ -105,10 +149,16 @@ | |||||||
| 		<div class="w3-container w3-padding-64 w3-light-grey w3-center"> | 		<div class="w3-container w3-padding-64 w3-light-grey w3-center"> | ||||||
| 			<h1 class="w3-jumbo"><b>Trusted Technology</b></h1> | 			<h1 class="w3-jumbo"><b>Trusted Technology</b></h1> | ||||||
| 			<p>Tilde Friends is built using boring, trusted tech.</p> | 			<p>Tilde Friends is built using boring, trusted tech.</p> | ||||||
| 			<p>Though of course for building Tilde Friends apps, you are free to use whatever fits.</p> | 			<p> | ||||||
|  | 				Though of course for building Tilde Friends apps, you are free to use | ||||||
|  | 				whatever fits. | ||||||
|  | 			</p> | ||||||
|  |  | ||||||
| 			<div class="w3-row" style="margin-top:64px"> | 			<div class="w3-row" style="margin-top: 64px"> | ||||||
| 				<a href="https://en.wikipedia.org/wiki/C_(programming_language)" class="w3-col s3"> | 				<a | ||||||
|  | 					href="https://en.wikipedia.org/wiki/C_(programming_language)" | ||||||
|  | 					class="w3-col s3" | ||||||
|  | 				> | ||||||
| 					<i class="fa fa-c w3-text-blue w3-jumbo"></i> | 					<i class="fa fa-c w3-text-blue w3-jumbo"></i> | ||||||
| 					<p>C</p> | 					<p>C</p> | ||||||
| 				</a> | 				</a> | ||||||
| @@ -126,7 +176,7 @@ | |||||||
| 				</a> | 				</a> | ||||||
| 			</div> | 			</div> | ||||||
|  |  | ||||||
| 			<div class="w3-row" style="margin-top:64px"> | 			<div class="w3-row" style="margin-top: 64px"> | ||||||
| 				<a href="https://www.zlib.net/" class="w3-col s3"> | 				<a href="https://www.zlib.net/" class="w3-col s3"> | ||||||
| 					<i class="fa fa-file-zipper w3-text-cyan w3-jumbo"></i> | 					<i class="fa fa-file-zipper w3-text-cyan w3-jumbo"></i> | ||||||
| 					<p>zlib</p> | 					<p>zlib</p> | ||||||
| @@ -137,15 +187,18 @@ | |||||||
| 				</a> | 				</a> | ||||||
| 				<a href="https://www.openssl.org/" class="w3-col s3"> | 				<a href="https://www.openssl.org/" class="w3-col s3"> | ||||||
| 					<i class="fa fa-shield-halved w3-text-green w3-jumbo"></i> | 					<i class="fa fa-shield-halved w3-text-green w3-jumbo"></i> | ||||||
| 					<p>OpenSSL	</p> | 					<p>OpenSSL</p> | ||||||
| 				</a> | 				</a> | ||||||
| 				<a href="https://github.com/ianlancetaylor/libbacktrace" class="w3-col s3"> | 				<a | ||||||
|  | 					href="https://github.com/ianlancetaylor/libbacktrace" | ||||||
|  | 					class="w3-col s3" | ||||||
|  | 				> | ||||||
| 					<i class="fa fa-burst w3-text-pink w3-jumbo"></i> | 					<i class="fa fa-burst w3-text-pink w3-jumbo"></i> | ||||||
| 					<p>libbacktrace</p> | 					<p>libbacktrace</p> | ||||||
| 				</a> | 				</a> | ||||||
| 			</div> | 			</div> | ||||||
|  |  | ||||||
| 			<div class="w3-row" style="margin-top:64px"> | 			<div class="w3-row" style="margin-top: 64px"> | ||||||
| 				<a href="https://codemirror.net/5/" class="w3-col s3"> | 				<a href="https://codemirror.net/5/" class="w3-col s3"> | ||||||
| 					<i class="fa fa-keyboard w3-text-indigo w3-jumbo"></i> | 					<i class="fa fa-keyboard w3-text-indigo w3-jumbo"></i> | ||||||
| 					<p>CodeMirror</p> | 					<p>CodeMirror</p> | ||||||
| @@ -167,7 +220,10 @@ | |||||||
|  |  | ||||||
| 		<!-- Footer --> | 		<!-- Footer --> | ||||||
| 		<footer class="w3-container w3-padding-32 w3-blue-grey w3-center w3-xlarge"> | 		<footer class="w3-container w3-padding-32 w3-blue-grey w3-center w3-xlarge"> | ||||||
| 			<p class="w3-medium">This page and Tilde Friends itself was made by Cory mostly in coffee shops and a local pizza place.</p> | 			<p class="w3-medium"> | ||||||
|  | 				This page and Tilde Friends itself was made by Cory mostly in coffee | ||||||
|  | 				shops and a local pizza place. | ||||||
|  | 			</p> | ||||||
| 		</footer> | 		</footer> | ||||||
| 	</body> | 	</body> | ||||||
| </html> | </html> | ||||||
| @@ -1,5 +1,5 @@ | |||||||
| { | { | ||||||
|   "type": "tildefriends-app", | 	"type": "tildefriends-app", | ||||||
|   "emoji": "📝", | 	"emoji": "📝", | ||||||
|   "previous": "&/wl8HE2jZShRXTYEVYRrK3pjHwi41Wbxl9HoSJaQP6Y=.sha256" | 	"previous": "&/wl8HE2jZShRXTYEVYRrK3pjHwi41Wbxl9HoSJaQP6Y=.sha256" | ||||||
| } | } | ||||||
| @@ -11,10 +11,13 @@ function markdown(md) { | |||||||
| 		let node = event.node; | 		let node = event.node; | ||||||
| 		if (event.entering) { | 		if (event.entering) { | ||||||
| 			if (node.destination?.startsWith('&')) { | 			if (node.destination?.startsWith('&')) { | ||||||
| 				node.destination = '/' + node.destination + '/view?filename=' + node.firstChild?.literal; | 				node.destination = | ||||||
|  | 					'/' + node.destination + '/view?filename=' + node.firstChild?.literal; | ||||||
| 			} else if (node.type === 'link') { | 			} else if (node.type === 'link') { | ||||||
| 				if (node.destination.indexOf(':') == -1 && | 				if ( | ||||||
| 					node.destination.indexOf('/') == -1) { | 					node.destination.indexOf(':') == -1 && | ||||||
|  | 					node.destination.indexOf('/') == -1 | ||||||
|  | 				) { | ||||||
| 					node.destination = `${node.destination}`; | 					node.destination = `${node.destination}`; | ||||||
| 				} | 				} | ||||||
| 			} | 			} | ||||||
| @@ -29,7 +32,9 @@ async function main() { | |||||||
| 		let wiki_name = request.path.substring(0, slash); | 		let wiki_name = request.path.substring(0, slash); | ||||||
| 		let wiki_doc_name = request.path.substring(slash + 1); | 		let wiki_doc_name = request.path.substring(slash + 1); | ||||||
|  |  | ||||||
| 		let ids = Object.keys(await ssb.following(await ssb.getOwnerIdentities(), 1)); | 		let ids = Object.keys( | ||||||
|  | 			await ssb.following(await ssb.getOwnerIdentities(), 1) | ||||||
|  | 		); | ||||||
| 		let [max_row_id, wikis] = await utils.collection(ids, 'wiki', null, -1, {}); | 		let [max_row_id, wikis] = await utils.collection(ids, 'wiki', null, -1, {}); | ||||||
| 		let wiki; | 		let wiki; | ||||||
| 		for (let w of Object.values(wikis)) { | 		for (let w of Object.values(wikis)) { | ||||||
| @@ -40,7 +45,13 @@ async function main() { | |||||||
| 		} | 		} | ||||||
| 		let wiki_doc; | 		let wiki_doc; | ||||||
| 		if (wiki) { | 		if (wiki) { | ||||||
| 			let [max_row_id, wiki_docs] = await utils.collection(ids, 'wiki-doc', wiki.id, -1, {}); | 			let [max_row_id, wiki_docs] = await utils.collection( | ||||||
|  | 				ids, | ||||||
|  | 				'wiki-doc', | ||||||
|  | 				wiki.id, | ||||||
|  | 				-1, | ||||||
|  | 				{} | ||||||
|  | 			); | ||||||
| 			for (let w of Object.values(wiki_docs)) { | 			for (let w of Object.values(wiki_docs)) { | ||||||
| 				if (w.name === wiki_doc_name && !w.tombstone) { | 				if (w.name === wiki_doc_name && !w.tombstone) { | ||||||
| 					wiki_doc = w; | 					wiki_doc = w; | ||||||
|   | |||||||
| @@ -1,11 +1,13 @@ | |||||||
| <!DOCTYPE html> | <!doctype html> | ||||||
| <html> | <html> | ||||||
| 	<head> | 	<head> | ||||||
| 		<base target="_top"> | 		<base target="_top" /> | ||||||
| 	</head> | 	</head> | ||||||
| 	<body style="color: #fff"> | 	<body style="color: #fff"> | ||||||
| 		<tf-collections-app></tf-collections-app> | 		<tf-collections-app></tf-collections-app> | ||||||
| 		<script>window.litDisableBundleWarning = true;</script> | 		<script> | ||||||
|  | 			window.litDisableBundleWarning = true; | ||||||
|  | 		</script> | ||||||
| 		<script src="tf-collection.js" type="module"></script> | 		<script src="tf-collection.js" type="module"></script> | ||||||
| 		<script src="tf-id-picker.js" type="module"></script> | 		<script src="tf-id-picker.js" type="module"></script> | ||||||
| 		<script src="tf-wiki-doc.js" type="module"></script> | 		<script src="tf-wiki-doc.js" type="module"></script> | ||||||
|   | |||||||
| @@ -14,52 +14,62 @@ class TfCollectionElement extends LitElement { | |||||||
|  |  | ||||||
| 	on_create(event) { | 	on_create(event) { | ||||||
| 		let name = this.shadowRoot.getElementById('create_name').value; | 		let name = this.shadowRoot.getElementById('create_name').value; | ||||||
| 		this.dispatchEvent(new CustomEvent('create', { | 		this.dispatchEvent( | ||||||
| 			bubbles: true, | 			new CustomEvent('create', { | ||||||
| 			detail: { | 				bubbles: true, | ||||||
| 				name: name, | 				detail: { | ||||||
| 			}, | 					name: name, | ||||||
| 		})); | 				}, | ||||||
|  | 			}) | ||||||
|  | 		); | ||||||
| 		this.is_creating = false; | 		this.is_creating = false; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	on_rename(event) { | 	on_rename(event) { | ||||||
| 		let id = this.shadowRoot.getElementById('select').value; | 		let id = this.shadowRoot.getElementById('select').value; | ||||||
| 		let name = this.shadowRoot.getElementById('rename_name').value; | 		let name = this.shadowRoot.getElementById('rename_name').value; | ||||||
| 		this.dispatchEvent(new CustomEvent('rename', { | 		this.dispatchEvent( | ||||||
| 			bubbles: true, | 			new CustomEvent('rename', { | ||||||
| 			detail: { | 				bubbles: true, | ||||||
| 				id: id, | 				detail: { | ||||||
| 				value: this.collection[id], | 					id: id, | ||||||
| 				name: name, | 					value: this.collection[id], | ||||||
| 			}, | 					name: name, | ||||||
| 		})); | 				}, | ||||||
|  | 			}) | ||||||
|  | 		); | ||||||
| 		this.is_renaming = false; | 		this.is_renaming = false; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	on_tombstone(event) { | 	on_tombstone(event) { | ||||||
| 		let id = this.shadowRoot.getElementById('select').value; | 		let id = this.shadowRoot.getElementById('select').value; | ||||||
| 		if (confirm(`Are you sure you want to delete '${this.collection[id].name}'?`)) { | 		if ( | ||||||
| 			this.dispatchEvent(new CustomEvent('tombstone', { | 			confirm(`Are you sure you want to delete '${this.collection[id].name}'?`) | ||||||
| 				bubbles: true, | 		) { | ||||||
| 				detail: { | 			this.dispatchEvent( | ||||||
| 					id: id, | 				new CustomEvent('tombstone', { | ||||||
| 					value: this.collection[id], | 					bubbles: true, | ||||||
| 				}, | 					detail: { | ||||||
| 			})); | 						id: id, | ||||||
|  | 						value: this.collection[id], | ||||||
|  | 					}, | ||||||
|  | 				}) | ||||||
|  | 			); | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	on_selected(event) { | 	on_selected(event) { | ||||||
| 		let id = event.srcElement.value; | 		let id = event.srcElement.value; | ||||||
| 		this.selected_id = id != '' ? id : undefined; | 		this.selected_id = id != '' ? id : undefined; | ||||||
| 		this.dispatchEvent(new CustomEvent('change', { | 		this.dispatchEvent( | ||||||
| 			bubbles: true, | 			new CustomEvent('change', { | ||||||
| 			detail: { | 				bubbles: true, | ||||||
| 				id: id, | 				detail: { | ||||||
| 				value: this.collection[id], | 					id: id, | ||||||
| 			}, | 					value: this.collection[id], | ||||||
| 		})); | 				}, | ||||||
|  | 			}) | ||||||
|  | 		); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render() { | 	render() { | ||||||
| @@ -68,25 +78,35 @@ class TfCollectionElement extends LitElement { | |||||||
| 			<span style="display: inline-flex; flex-direction: row"> | 			<span style="display: inline-flex; flex-direction: row"> | ||||||
| 				<select @change=${this.on_selected} id="select" value=${this.selected_id}> | 				<select @change=${this.on_selected} id="select" value=${this.selected_id}> | ||||||
| 					<option value="" ?selected=${this.selected_id === ''} disabled hidden>(select)</option> | 					<option value="" ?selected=${this.selected_id === ''} disabled hidden>(select)</option> | ||||||
| 					${Object.values(this.collection ?? {}).sort((x, y) => x.name.localeCompare(y.name)).map(x => html`<option value=${x.id} ?selected=${this.selected_id === x.id}>${x.name}</option>`)} | 					${Object.values(this.collection ?? {}) | ||||||
|  | 						.sort((x, y) => x.name.localeCompare(y.name)) | ||||||
|  | 						.map( | ||||||
|  | 							(x) => | ||||||
|  | 								html`<option | ||||||
|  | 									value=${x.id} | ||||||
|  | 									?selected=${this.selected_id === x.id} | ||||||
|  | 								> | ||||||
|  | 									${x.name} | ||||||
|  | 								</option>` | ||||||
|  | 						)} | ||||||
| 				</select> | 				</select> | ||||||
| 				<span ?hidden=${!this.is_renaming || !this.whoami}> | 				<span ?hidden=${!this.is_renaming || !this.whoami}> | ||||||
| 					<span style="display: inline-flex; flex-direction: row; margin-left: 8px; margin-right: 8px"> | 					<span style="display: inline-flex; flex-direction: row; margin-left: 8px; margin-right: 8px"> | ||||||
| 						<label for="rename_name">🏷Rename to:</label> | 						<label for="rename_name">🏷Rename to:</label> | ||||||
| 						<input type="text" id="rename_name"></input> | 						<input type="text" id="rename_name"></input> | ||||||
| 						<button @click=${this.on_rename}>Rename ${this.type}</button> | 						<button @click=${this.on_rename}>Rename ${this.type}</button> | ||||||
| 						<button @click=${() => self.is_renaming = false}>x</button> | 						<button @click=${() => (self.is_renaming = false)}>x</button> | ||||||
| 					</span> | 					</span> | ||||||
| 				</span> | 				</span> | ||||||
| 				<button @click=${() => self.is_renaming = true} ?disabled=${this.is_renaming || !this.selected_id} ?hidden=${!this.whoami}>🏷</button> | 				<button @click=${() => (self.is_renaming = true)} ?disabled=${this.is_renaming || !this.selected_id} ?hidden=${!this.whoami}>🏷</button> | ||||||
| 				<button @click=${self.on_tombstone} ?disabled=${!this.selected_id} ?hidden=${!this.whoami}>🪦</button> | 				<button @click=${self.on_tombstone} ?disabled=${!this.selected_id} ?hidden=${!this.whoami}>🪦</button> | ||||||
| 				<span ?hidden=${!this.is_creating || !this.whoami}> | 				<span ?hidden=${!this.is_creating || !this.whoami}> | ||||||
| 					<label for="create_name">New ${this.type} name:</label> | 					<label for="create_name">New ${this.type} name:</label> | ||||||
| 					<input type="text" id="create_name"></input> | 					<input type="text" id="create_name"></input> | ||||||
| 					<button @click=${this.on_create}>Create ${this.type}</button> | 					<button @click=${this.on_create}>Create ${this.type}</button> | ||||||
| 					<button @click=${() => self.is_creating = false}>x</button> | 					<button @click=${() => (self.is_creating = false)}>x</button> | ||||||
| 				</span> | 				</span> | ||||||
| 				<button @click=${() => self.is_creating = true} ?hidden=${this.is_creating || !this.whoami}>+</button> | 				<button @click=${() => (self.is_creating = true)} ?hidden=${this.is_creating || !this.whoami}>+</button> | ||||||
| 			</span> | 			</span> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -2,8 +2,8 @@ import {LitElement, html} from './lit-all.min.js'; | |||||||
| import * as tfrpc from '/static/tfrpc.js'; | import * as tfrpc from '/static/tfrpc.js'; | ||||||
|  |  | ||||||
| /* | /* | ||||||
| ** Provide a list of IDs, and this lets the user pick one. |  ** Provide a list of IDs, and this lets the user pick one. | ||||||
| */ |  */ | ||||||
| class TfIdentityPickerElement extends LitElement { | class TfIdentityPickerElement extends LitElement { | ||||||
| 	static get properties() { | 	static get properties() { | ||||||
| 		return { | 		return { | ||||||
| @@ -19,15 +19,22 @@ class TfIdentityPickerElement extends LitElement { | |||||||
|  |  | ||||||
| 	changed(event) { | 	changed(event) { | ||||||
| 		this.selected = event.srcElement.value; | 		this.selected = event.srcElement.value; | ||||||
| 		this.dispatchEvent(new Event('change', { | 		this.dispatchEvent( | ||||||
| 			srcElement: this, | 			new Event('change', { | ||||||
| 		})); | 				srcElement: this, | ||||||
|  | 			}) | ||||||
|  | 		); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render() { | 	render() { | ||||||
| 		return html` | 		return html` | ||||||
| 			<select @change=${this.changed} style="max-width: 100%"> | 			<select @change=${this.changed} style="max-width: 100%"> | ||||||
| 				${(this.ids ?? []).map(id => html`<option ?selected=${id == this.selected} value=${id}>${id}</option>`)} | 				${(this.ids ?? []).map( | ||||||
|  | 					(id) => | ||||||
|  | 						html`<option ?selected=${id == this.selected} value=${id}> | ||||||
|  | 							${id} | ||||||
|  | 						</option>` | ||||||
|  | 				)} | ||||||
| 			</select> | 			</select> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -31,7 +31,7 @@ class TfCollectionsAppElement extends LitElement { | |||||||
| 		tfrpc.register(function hash_changed(hash) { | 		tfrpc.register(function hash_changed(hash) { | ||||||
| 			self.notify_hash_changed(hash); | 			self.notify_hash_changed(hash); | ||||||
| 		}); | 		}); | ||||||
| 		tfrpc.rpc.get_hash().then(hash => self.notify_hash_changed(hash)); | 		tfrpc.rpc.get_hash().then((hash) => self.notify_hash_changed(hash)); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	async load() { | 	async load() { | ||||||
| @@ -49,10 +49,16 @@ class TfCollectionsAppElement extends LitElement { | |||||||
| 		let max_rowid; | 		let max_rowid; | ||||||
| 		let wikis; | 		let wikis; | ||||||
| 		let start_whoami = this.whoami; | 		let start_whoami = this.whoami; | ||||||
| 		while (true) | 		while (true) { | ||||||
| 		{ |  | ||||||
| 			console.log('read_wikis', this.whoami); | 			console.log('read_wikis', this.whoami); | ||||||
| 			[max_rowid, wikis] = await tfrpc.rpc.collection(this.following, 'wiki', undefined, max_rowid, wikis, false); | 			[max_rowid, wikis] = await tfrpc.rpc.collection( | ||||||
|  | 				this.following, | ||||||
|  | 				'wiki', | ||||||
|  | 				undefined, | ||||||
|  | 				max_rowid, | ||||||
|  | 				wikis, | ||||||
|  | 				false | ||||||
|  | 			); | ||||||
| 			console.log('read ->', wikis); | 			console.log('read ->', wikis); | ||||||
| 			if (this.whoami !== start_whoami) { | 			if (this.whoami !== start_whoami) { | ||||||
| 				break; | 				break; | ||||||
| @@ -70,9 +76,14 @@ class TfCollectionsAppElement extends LitElement { | |||||||
| 		let start_id = this.wiki.id; | 		let start_id = this.wiki.id; | ||||||
| 		let max_rowid; | 		let max_rowid; | ||||||
| 		let wiki_docs; | 		let wiki_docs; | ||||||
| 		while (true) | 		while (true) { | ||||||
| 		{ | 			[max_rowid, wiki_docs] = await tfrpc.rpc.collection( | ||||||
| 			[max_rowid, wiki_docs] = await tfrpc.rpc.collection(this.wiki?.editors, 'wiki-doc', this.wiki?.id, max_rowid, wiki_docs); | 				this.wiki?.editors, | ||||||
|  | 				'wiki-doc', | ||||||
|  | 				this.wiki?.id, | ||||||
|  | 				max_rowid, | ||||||
|  | 				wiki_docs | ||||||
|  | 			); | ||||||
| 			if (this.wiki?.id !== start_id) { | 			if (this.wiki?.id !== start_id) { | ||||||
| 				break; | 				break; | ||||||
| 			} | 			} | ||||||
| @@ -128,7 +139,11 @@ class TfCollectionsAppElement extends LitElement { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	update_hash() { | 	update_hash() { | ||||||
| 		tfrpc.rpc.set_hash(this.wiki_doc ? `${this.wiki.name}/${this.wiki_doc.name}` : `${this.wiki.name}`); | 		tfrpc.rpc.set_hash( | ||||||
|  | 			this.wiki_doc | ||||||
|  | 				? `${this.wiki.name}/${this.wiki_doc.name}` | ||||||
|  | 				: `${this.wiki.name}` | ||||||
|  | 		); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	async on_wiki_changed(event) { | 	async on_wiki_changed(event) { | ||||||
| @@ -174,7 +189,7 @@ class TfCollectionsAppElement extends LitElement { | |||||||
| 		if (confirm(`Are you sure you want to remove ${id} as an editor?`)) { | 		if (confirm(`Are you sure you want to remove ${id} as an editor?`)) { | ||||||
| 			let editors = [...this.wiki.editors]; | 			let editors = [...this.wiki.editors]; | ||||||
| 			if (editors.indexOf(id) != -1) { | 			if (editors.indexOf(id) != -1) { | ||||||
| 				editors = editors.filter(x => x !== id); | 				editors = editors.filter((x) => x !== id); | ||||||
| 			} | 			} | ||||||
| 			await tfrpc.rpc.appendMessage(this.whoami, { | 			await tfrpc.rpc.appendMessage(this.whoami, { | ||||||
| 				type: 'wiki', | 				type: 'wiki', | ||||||
| @@ -252,34 +267,45 @@ class TfCollectionsAppElement extends LitElement { | |||||||
| 				<tf-id-picker .ids=${this.ids} selected=${this.whoami} @change=${this.on_whoami_changed} ?hidden=${!this.ids?.length}></tf-id-picker> | 				<tf-id-picker .ids=${this.ids} selected=${this.whoami} @change=${this.on_whoami_changed} ?hidden=${!this.ids?.length}></tf-id-picker> | ||||||
| 			</div> | 			</div> | ||||||
| 			<div> | 			<div> | ||||||
| 				${keyed(this.whoami, html`<tf-collection | 				${keyed( | ||||||
| 					.collection=${this.wikis} | 					this.whoami, | ||||||
| 					whoami=${this.whoami} | 					html`<tf-collection | ||||||
| 					selected_id=${this.wiki?.id} | 						.collection=${this.wikis} | ||||||
| 					@create=${this.on_wiki_create} | 						whoami=${this.whoami} | ||||||
| 					@rename=${this.on_wiki_rename} | 						selected_id=${this.wiki?.id} | ||||||
| 					@tombstone=${this.on_wiki_tombstone} | 						@create=${this.on_wiki_create} | ||||||
| 					@change=${this.on_wiki_changed}></tf-collection>`)} | 						@rename=${this.on_wiki_rename} | ||||||
| 				${keyed(this.wiki_doc?.id, html`<tf-collection | 						@tombstone=${this.on_wiki_tombstone} | ||||||
| 					.collection=${this.wiki_docs} | 						@change=${this.on_wiki_changed} | ||||||
| 					whoami=${this.whoami} | 					></tf-collection>` | ||||||
| 					selected_id=${(this.wiki_doc && this.wiki_doc?.parent == this.wiki?.id) ? this.wiki_doc?.id : ''} | 				)} | ||||||
| 					@create=${this.on_wiki_doc_create} | 				${keyed( | ||||||
| 					@rename=${this.on_wiki_doc_rename} | 					this.wiki_doc?.id, | ||||||
| 					@tombstone=${this.on_wiki_doc_tombstone} | 					html`<tf-collection | ||||||
| 					@change=${this.on_wiki_doc_changed}></tf-collection>`)} | 						.collection=${this.wiki_docs} | ||||||
| 				<button @click=${() => self.expand_editors = !self.expand_editors}>${this.wiki?.editors?.length} editor${this.wiki?.editors?.length > 1 ? 's' : ''}</button> | 						whoami=${this.whoami} | ||||||
|  | 						selected_id=${this.wiki_doc && | ||||||
|  | 						this.wiki_doc?.parent == this.wiki?.id | ||||||
|  | 							? this.wiki_doc?.id | ||||||
|  | 							: ''} | ||||||
|  | 						@create=${this.on_wiki_doc_create} | ||||||
|  | 						@rename=${this.on_wiki_doc_rename} | ||||||
|  | 						@tombstone=${this.on_wiki_doc_tombstone} | ||||||
|  | 						@change=${this.on_wiki_doc_changed} | ||||||
|  | 					></tf-collection>` | ||||||
|  | 				)} | ||||||
|  | 				<button @click=${() => (self.expand_editors = !self.expand_editors)}>${this.wiki?.editors?.length} editor${this.wiki?.editors?.length > 1 ? 's' : ''}</button> | ||||||
| 				<div ?hidden=${!this.wiki?.editors || !this.expand_editors}> | 				<div ?hidden=${!this.wiki?.editors || !this.expand_editors}> | ||||||
| 					<div> | 					<div> | ||||||
| 						<ul> | 						<ul> | ||||||
| 							${this.wiki?.editors.map(id => html`<li><button ?hidden=${id == this.whoami} @click=${() => self.on_remove_editor(id)}>x</button> ${id}</li>`)} | 							${this.wiki?.editors.map((id) => html`<li><button ?hidden=${id == this.whoami} @click=${() => self.on_remove_editor(id)}>x</button> ${id}</li>`)} | ||||||
| 							<li> | 							<li> | ||||||
| 								<button @click=${() => self.adding_editor = true} ?hidden=${this.wiki?.editors?.indexOf(this.whoami) == -1 || this.adding_editor}>+</button> | 								<button @click=${() => (self.adding_editor = true)} ?hidden=${this.wiki?.editors?.indexOf(this.whoami) == -1 || this.adding_editor}>+</button> | ||||||
| 								<div ?hidden=${!this.adding_editor}> | 								<div ?hidden=${!this.adding_editor}> | ||||||
| 									<label for="add_editor">Add Editor:</label> | 									<label for="add_editor">Add Editor:</label> | ||||||
| 									<input type="text" id="add_editor"></input> | 									<input type="text" id="add_editor"></input> | ||||||
| 									<button @click=${this.on_add_editor}>Add Editor</button> | 									<button @click=${this.on_add_editor}>Add Editor</button> | ||||||
| 									<button @click=${() => self.adding_editor = false}>x</button> | 									<button @click=${() => (self.adding_editor = false)}>x</button> | ||||||
| 								</div> | 								</div> | ||||||
| 							</li> | 							</li> | ||||||
| 						</ul> | 						</ul> | ||||||
| @@ -288,22 +314,51 @@ class TfCollectionsAppElement extends LitElement { | |||||||
| 			</div> | 			</div> | ||||||
| 			<div style="display: flex; flex-direction: row"> | 			<div style="display: flex; flex-direction: row"> | ||||||
| 				<div style="flex: 0 0"> | 				<div style="flex: 0 0"> | ||||||
| 					${Object.values(this.wikis || {}).sort((x, y) => x.name.localeCompare(y.name)).map(wiki => html` | 					${Object.values(this.wikis || {}) | ||||||
| 						<div class="toc ${self.wiki?.id === wiki.id ? 'selected' : ''}" style="white-space: nowrap; cursor: pointer" @click=${() => self.on_wiki_changed({detail: {value: wiki}})}>${wiki.name}</div> | 						.sort((x, y) => x.name.localeCompare(y.name)) | ||||||
| 						<ul> | 						.map( | ||||||
| 							${Object.values(self.wiki_docs || {}).filter(doc => doc.parent === wiki?.id).sort((x, y) => x.name.localeCompare(y.name)).map(doc => html` | 							(wiki) => html` | ||||||
| 								<li class="toc ${self.wiki_doc?.id === doc.id ? 'selected' : ''}" style="white-space: nowrap; cursor: pointer; list-style: none; text-indent: -1rem" @click=${() => self.on_wiki_doc_changed({detail: {value: doc}})}>${doc?.private ? '🔒' : '📄'} ${doc.name}</li> | 								<div | ||||||
| 							`)} | 									class="toc ${self.wiki?.id === wiki.id ? 'selected' : ''}" | ||||||
| 						</ul> | 									style="white-space: nowrap; cursor: pointer" | ||||||
| 					`)} | 									@click=${() => self.on_wiki_changed({detail: {value: wiki}})} | ||||||
|  | 								> | ||||||
|  | 									${wiki.name} | ||||||
|  | 								</div> | ||||||
|  | 								<ul> | ||||||
|  | 									${Object.values(self.wiki_docs || {}) | ||||||
|  | 										.filter((doc) => doc.parent === wiki?.id) | ||||||
|  | 										.sort((x, y) => x.name.localeCompare(y.name)) | ||||||
|  | 										.map( | ||||||
|  | 											(doc) => html` | ||||||
|  | 												<li | ||||||
|  | 													class="toc ${self.wiki_doc?.id === doc.id | ||||||
|  | 														? 'selected' | ||||||
|  | 														: ''}" | ||||||
|  | 													style="white-space: nowrap; cursor: pointer; list-style: none; text-indent: -1rem" | ||||||
|  | 													@click=${() => | ||||||
|  | 														self.on_wiki_doc_changed({detail: {value: doc}})} | ||||||
|  | 												> | ||||||
|  | 													${doc?.private ? '🔒' : '📄'} ${doc.name} | ||||||
|  | 												</li> | ||||||
|  | 											` | ||||||
|  | 										)} | ||||||
|  | 								</ul> | ||||||
|  | 							` | ||||||
|  | 						)} | ||||||
| 				</div> | 				</div> | ||||||
| 				${this.wiki_doc && this.wiki_doc.parent === this.wiki?.id ? html` | 				${ | ||||||
| 					<tf-wiki-doc | 					this.wiki_doc && this.wiki_doc.parent === this.wiki?.id | ||||||
| 						style="width: 100%" | 						? html` | ||||||
| 						whoami=${this.whoami} | 								<tf-wiki-doc | ||||||
| 						.wiki=${this.wiki} | 									style="width: 100%" | ||||||
| 						.value=${this.wiki_doc}></tf-wiki-doc> | 									whoami=${this.whoami} | ||||||
| 				` : undefined} | 									.wiki=${this.wiki} | ||||||
|  | 									.value=${this.wiki_doc} | ||||||
|  | 								></tf-wiki-doc> | ||||||
|  | 							` | ||||||
|  | 						: undefined | ||||||
|  | 				} | ||||||
| 			</div> | 			</div> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -29,10 +29,16 @@ class TfWikiDocElement extends LitElement { | |||||||
| 			let node = event.node; | 			let node = event.node; | ||||||
| 			if (event.entering) { | 			if (event.entering) { | ||||||
| 				if (node.destination?.startsWith('&')) { | 				if (node.destination?.startsWith('&')) { | ||||||
| 					node.destination = '/' + node.destination + '/view?filename=' + node.firstChild?.literal; | 					node.destination = | ||||||
|  | 						'/' + | ||||||
|  | 						node.destination + | ||||||
|  | 						'/view?filename=' + | ||||||
|  | 						node.firstChild?.literal; | ||||||
| 				} else if (node.type === 'link') { | 				} else if (node.type === 'link') { | ||||||
| 					if (node.destination.indexOf(':') == -1 && | 					if ( | ||||||
| 						node.destination.indexOf('/') == -1) { | 						node.destination.indexOf(':') == -1 && | ||||||
|  | 						node.destination.indexOf('/') == -1 | ||||||
|  | 					) { | ||||||
| 						node.destination = `#${this.wiki?.name}/${node.destination}`; | 						node.destination = `#${this.wiki?.name}/${node.destination}`; | ||||||
| 					} | 					} | ||||||
| 				} | 				} | ||||||
| @@ -70,7 +76,9 @@ class TfWikiDocElement extends LitElement { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	thumbnail(md) { | 	thumbnail(md) { | ||||||
| 		let m = md ? md.match(/\!\[image:[^\]]+\]\((\&.{44}\.sha256)\).*/) : undefined; | 		let m = md | ||||||
|  | 			? md.match(/\!\[image:[^\]]+\]\((\&.{44}\.sha256)\).*/) | ||||||
|  | 			: undefined; | ||||||
| 		return m ? m[1] : undefined; | 		return m ? m[1] : undefined; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -106,12 +114,16 @@ class TfWikiDocElement extends LitElement { | |||||||
| 			key: this.value.id, | 			key: this.value.id, | ||||||
| 			parent: this.value.parent, | 			parent: this.value.parent, | ||||||
| 			blob: id, | 			blob: id, | ||||||
| 			mentions: this.blob.match(/(&.{44}.sha256)/g)?.map(x => ({link: x})), | 			mentions: this.blob.match(/(&.{44}.sha256)/g)?.map((x) => ({link: x})), | ||||||
| 			private: this.value?.private, | 			private: this.value?.private, | ||||||
| 		}; | 		}; | ||||||
| 		if (draft) { | 		if (draft) { | ||||||
| 			message.recps = this.value.editors; | 			message.recps = this.value.editors; | ||||||
| 			message = await tfrpc.rpc.encrypt(this.whoami, this.value.editors, JSON.stringify(message)); | 			message = await tfrpc.rpc.encrypt( | ||||||
|  | 				this.whoami, | ||||||
|  | 				this.value.editors, | ||||||
|  | 				JSON.stringify(message) | ||||||
|  | 			); | ||||||
| 		} | 		} | ||||||
| 		await tfrpc.rpc.appendMessage(this.whoami, message); | 		await tfrpc.rpc.appendMessage(this.whoami, message); | ||||||
| 		this.is_editing = false; | 		this.is_editing = false; | ||||||
| @@ -136,16 +148,16 @@ class TfWikiDocElement extends LitElement { | |||||||
| 			summary: this.summary(blob), | 			summary: this.summary(blob), | ||||||
| 			thumbnail: this.thumbnail(blob), | 			thumbnail: this.thumbnail(blob), | ||||||
| 			blog: id, | 			blog: id, | ||||||
| 			mentions: this.blob.match(/(&.{44}.sha256)/g)?.map(x => ({link: x})), | 			mentions: this.blob.match(/(&.{44}.sha256)/g)?.map((x) => ({link: x})), | ||||||
| 		}; | 		}; | ||||||
| 		await tfrpc.rpc.appendMessage(this.whoami, message); | 		await tfrpc.rpc.appendMessage(this.whoami, message); | ||||||
| 		this.is_editing = false; | 		this.is_editing = false; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	convert_to_format(buffer, type, mime_type) { | 	convert_to_format(buffer, type, mime_type) { | ||||||
| 		return new Promise(function(resolve, reject) { | 		return new Promise(function (resolve, reject) { | ||||||
| 			let img = new Image(); | 			let img = new Image(); | ||||||
| 			img.onload = function() { | 			img.onload = function () { | ||||||
| 				let canvas = document.createElement('canvas'); | 				let canvas = document.createElement('canvas'); | ||||||
| 				let width_scale = Math.min(img.width, 1024) / img.width; | 				let width_scale = Math.min(img.width, 1024) / img.width; | ||||||
| 				let height_scale = Math.min(img.height, 1024) / img.height; | 				let height_scale = Math.min(img.height, 1024) / img.height; | ||||||
| @@ -155,13 +167,17 @@ class TfWikiDocElement extends LitElement { | |||||||
| 				let context = canvas.getContext('2d'); | 				let context = canvas.getContext('2d'); | ||||||
| 				context.drawImage(img, 0, 0, canvas.width, canvas.height); | 				context.drawImage(img, 0, 0, canvas.width, canvas.height); | ||||||
| 				let data_url = canvas.toDataURL(mime_type); | 				let data_url = canvas.toDataURL(mime_type); | ||||||
| 				let result = atob(data_url.split(',')[1]).split('').map(x => x.charCodeAt(0)); | 				let result = atob(data_url.split(',')[1]) | ||||||
|  | 					.split('') | ||||||
|  | 					.map((x) => x.charCodeAt(0)); | ||||||
| 				resolve(result); | 				resolve(result); | ||||||
| 			}; | 			}; | ||||||
| 			img.onerror = function(event) { | 			img.onerror = function (event) { | ||||||
| 				reject(new Error('Failed to load image.')); | 				reject(new Error('Failed to load image.')); | ||||||
| 			}; | 			}; | ||||||
| 			let raw = Array.from(new Uint8Array(buffer)).map(b => String.fromCharCode(b)).join(''); | 			let raw = Array.from(new Uint8Array(buffer)) | ||||||
|  | 				.map((b) => String.fromCharCode(b)) | ||||||
|  | 				.join(''); | ||||||
| 			let original = `data:${type};base64,${btoa(raw)}`; | 			let original = `data:${type};base64,${btoa(raw)}`; | ||||||
| 			img.src = original; | 			img.src = original; | ||||||
| 		}); | 		}); | ||||||
| @@ -187,7 +203,11 @@ class TfWikiDocElement extends LitElement { | |||||||
| 				let best_buffer; | 				let best_buffer; | ||||||
| 				let best_type; | 				let best_type; | ||||||
| 				for (let format of ['image/png', 'image/jpeg', 'image/webp']) { | 				for (let format of ['image/png', 'image/jpeg', 'image/webp']) { | ||||||
| 					let test_buffer = await self.convert_to_format(buffer, file.type, format); | 					let test_buffer = await self.convert_to_format( | ||||||
|  | 						buffer, | ||||||
|  | 						file.type, | ||||||
|  | 						format | ||||||
|  | 					); | ||||||
| 					if (!best_buffer || test_buffer.length < best_buffer.length) { | 					if (!best_buffer || test_buffer.length < best_buffer.length) { | ||||||
| 						best_buffer = test_buffer; | 						best_buffer = test_buffer; | ||||||
| 						best_type = format; | 						best_type = format; | ||||||
| @@ -206,7 +226,7 @@ class TfWikiDocElement extends LitElement { | |||||||
| 			} | 			} | ||||||
| 			document.execCommand('insertText', false, insert); | 			document.execCommand('insertText', false, insert); | ||||||
| 			self.on_edit({srcElement: editor}); | 			self.on_edit({srcElement: editor}); | ||||||
| 		} catch(e) { | 		} catch (e) { | ||||||
| 			alert(e?.message); | 			alert(e?.message); | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| @@ -234,31 +254,84 @@ class TfWikiDocElement extends LitElement { | |||||||
| 		let thumbnail_ref = this.thumbnail(this.blob); | 		let thumbnail_ref = this.thumbnail(this.blob); | ||||||
| 		return html` | 		return html` | ||||||
| 			<style> | 			<style> | ||||||
| 				a:link { color: #268bd2 } | 				a:link { | ||||||
| 				a:visited { color: #6c71c4 } | 					color: #268bd2; | ||||||
| 				a:hover { color: #859900 } | 				} | ||||||
| 				a:active { color: #2aa198 } | 				a:visited { | ||||||
|  | 					color: #6c71c4; | ||||||
|  | 				} | ||||||
|  | 				a:hover { | ||||||
|  | 					color: #859900; | ||||||
|  | 				} | ||||||
|  | 				a:active { | ||||||
|  | 					color: #2aa198; | ||||||
|  | 				} | ||||||
| 			</style> | 			</style> | ||||||
| 			<div style="display: inline-flex; flex-direction: row"> | 			<div style="display: inline-flex; flex-direction: row"> | ||||||
| 				<button ?disabled=${!this.whoami || this.is_editing} @click=${() => self.is_editing = true}>Edit</button> | 				<button | ||||||
| 				<button ?disabled=${this.blob == this.blob_original} @click=${this.on_save_draft}>Save Draft</button> | 					?disabled=${!this.whoami || this.is_editing} | ||||||
| 				<button ?disabled=${this.blob == this.blob_original && !this.value?.draft} @click=${this.on_publish}>Publish</button> | 					@click=${() => (self.is_editing = true)} | ||||||
| 				<button ?disabled=${!this.is_editing} @click=${this.on_discard}>Discard</button> | 				> | ||||||
| 				<button ?disabled=${!this.is_editing} @click=${() => self.value = Object.assign({}, self.value, {private: !self.value.private})}>${this.value?.private ? 'Make Public' : 'Make Private'}</button> | 					Edit | ||||||
| 				<button ?disabled=${!this.is_editing} @click=${this.on_blog_publish}>Publish Blog</button> | 				</button> | ||||||
|  | 				<button | ||||||
|  | 					?disabled=${this.blob == this.blob_original} | ||||||
|  | 					@click=${this.on_save_draft} | ||||||
|  | 				> | ||||||
|  | 					Save Draft | ||||||
|  | 				</button> | ||||||
|  | 				<button | ||||||
|  | 					?disabled=${this.blob == this.blob_original && !this.value?.draft} | ||||||
|  | 					@click=${this.on_publish} | ||||||
|  | 				> | ||||||
|  | 					Publish | ||||||
|  | 				</button> | ||||||
|  | 				<button ?disabled=${!this.is_editing} @click=${this.on_discard}> | ||||||
|  | 					Discard | ||||||
|  | 				</button> | ||||||
|  | 				<button | ||||||
|  | 					?disabled=${!this.is_editing} | ||||||
|  | 					@click=${() => | ||||||
|  | 						(self.value = Object.assign({}, self.value, { | ||||||
|  | 							private: !self.value.private, | ||||||
|  | 						}))} | ||||||
|  | 				> | ||||||
|  | 					${this.value?.private ? 'Make Public' : 'Make Private'} | ||||||
|  | 				</button> | ||||||
|  | 				<button ?disabled=${!this.is_editing} @click=${this.on_blog_publish}> | ||||||
|  | 					Publish Blog | ||||||
|  | 				</button> | ||||||
| 			</div> | 			</div> | ||||||
| 			<div ?hidden=${!this.value?.private} style="color: #800">🔒 document is private</div> | 			<div ?hidden=${!this.value?.private} style="color: #800"> | ||||||
| 			<div style="display: flex; flex-direction: row; ${this.value?.private ? 'border-top: 4px solid #800' : ''}"> | 				🔒 document is private | ||||||
|  | 			</div> | ||||||
|  | 			<div | ||||||
|  | 				style="display: flex; flex-direction: row; ${this.value?.private | ||||||
|  | 					? 'border-top: 4px solid #800' | ||||||
|  | 					: ''}" | ||||||
|  | 			> | ||||||
| 				<textarea | 				<textarea | ||||||
| 					?hidden=${!this.is_editing} | 					?hidden=${!this.is_editing} | ||||||
| 					style="flex: 1 1; min-height: 10em; ${this.value?.private ? 'border: 4px solid #800' : ''}" | 					style="flex: 1 1; min-height: 10em; ${this.value?.private | ||||||
|  | 						? 'border: 4px solid #800' | ||||||
|  | 						: ''}" | ||||||
| 					@input=${this.on_edit} | 					@input=${this.on_edit} | ||||||
| 					@paste=${this.paste} | 					@paste=${this.paste} | ||||||
| 					.value=${this.blob ?? ''}></textarea> | 					.value=${this.blob ?? ''} | ||||||
|  | 				></textarea> | ||||||
| 				<div style="flex: 1 1"> | 				<div style="flex: 1 1"> | ||||||
| 					<div ?hidden=${!this.is_editing} style="border: 1px solid #fff; border-radius: 1em; padding: 0.5em"> | 					<div | ||||||
| 						<img ?hidden=${!thumbnail_ref} style="max-width: 128px; max-height: 128px; float: right" src="/${thumbnail_ref}/view"> | 						?hidden=${!this.is_editing} | ||||||
| 						<h1 ?hidden=${!this.title(this.blob)}>${unsafeHTML(this.markdown(this.title(this.blob)))}</h1> | 						style="border: 1px solid #fff; border-radius: 1em; padding: 0.5em" | ||||||
|  | 					> | ||||||
|  | 						<img | ||||||
|  | 							?hidden=${!thumbnail_ref} | ||||||
|  | 							style="max-width: 128px; max-height: 128px; float: right" | ||||||
|  | 							src="/${thumbnail_ref}/view" | ||||||
|  | 						/> | ||||||
|  | 						<h1 ?hidden=${!this.title(this.blob)}> | ||||||
|  | 							${unsafeHTML(this.markdown(this.title(this.blob)))} | ||||||
|  | 						</h1> | ||||||
| 						${unsafeHTML(this.markdown(this.summary(this.blob)))} | 						${unsafeHTML(this.markdown(this.summary(this.blob)))} | ||||||
| 					</div> | 					</div> | ||||||
| 					${unsafeHTML(this.markdown(this.blob))} | 					${unsafeHTML(this.markdown(this.blob))} | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ async function process_message(whoami, collection, message, kind, parent) { | |||||||
| 	let content = JSON.parse(message.content); | 	let content = JSON.parse(message.content); | ||||||
| 	if (typeof content == 'string') { | 	if (typeof content == 'string') { | ||||||
| 		let x; | 		let x; | ||||||
| 		for (let id of (whoami || [])) { | 		for (let id of whoami || []) { | ||||||
| 			x = await ssb.privateMessageDecrypt(id, content); | 			x = await ssb.privateMessageDecrypt(id, content); | ||||||
| 			if (x) { | 			if (x) { | ||||||
| 				try { | 				try { | ||||||
| @@ -17,8 +17,7 @@ async function process_message(whoami, collection, message, kind, parent) { | |||||||
| 		if (!x) { | 		if (!x) { | ||||||
| 			return; | 			return; | ||||||
| 		} | 		} | ||||||
| 		if (content.type !== kind || | 		if (content.type !== kind || (parent && content.parent !== parent)) { | ||||||
| 			(parent && content.parent !== parent)) { |  | ||||||
| 			return; | 			return; | ||||||
| 		} | 		} | ||||||
| 	} else { | 	} else { | ||||||
| @@ -28,7 +27,10 @@ async function process_message(whoami, collection, message, kind, parent) { | |||||||
| 		if (content?.tombstone) { | 		if (content?.tombstone) { | ||||||
| 			delete collection[content.key]; | 			delete collection[content.key]; | ||||||
| 		} else { | 		} else { | ||||||
| 			collection[content.key] = Object.assign(collection[content.key] || {}, content); | 			collection[content.key] = Object.assign( | ||||||
|  | 				collection[content.key] || {}, | ||||||
|  | 				content | ||||||
|  | 			); | ||||||
| 		} | 		} | ||||||
| 	} else { | 	} else { | ||||||
| 		collection[message.id] = Object.assign(content, {id: message.id}); | 		collection[message.id] = Object.assign(content, {id: message.id}); | ||||||
| @@ -40,7 +42,7 @@ async function process_message(whoami, collection, message, kind, parent) { | |||||||
| } | } | ||||||
|  |  | ||||||
| let g_new_message_resolve; | let g_new_message_resolve; | ||||||
| let g_new_message_promise = new Promise(function(resolve, reject) { | let g_new_message_promise = new Promise(function (resolve, reject) { | ||||||
| 	g_new_message_resolve = resolve; | 	g_new_message_resolve = resolve; | ||||||
| }); | }); | ||||||
|  |  | ||||||
| @@ -48,9 +50,9 @@ function new_message() { | |||||||
| 	return g_new_message_promise; | 	return g_new_message_promise; | ||||||
| } | } | ||||||
|  |  | ||||||
| ssb.addEventListener('message', function(id) { | ssb.addEventListener('message', function (id) { | ||||||
| 	let resolve = g_new_message_resolve; | 	let resolve = g_new_message_resolve; | ||||||
| 	g_new_message_promise = new Promise(function(resolve, reject) { | 	g_new_message_promise = new Promise(function (resolve, reject) { | ||||||
| 		g_new_message_resolve = resolve; | 		g_new_message_resolve = resolve; | ||||||
| 	}); | 	}); | ||||||
| 	if (resolve) { | 	if (resolve) { | ||||||
| @@ -58,26 +60,42 @@ ssb.addEventListener('message', function(id) { | |||||||
| 	} | 	} | ||||||
| }); | }); | ||||||
|  |  | ||||||
| export async function collection(ids, kind, parent, max_rowid, data, include_private) { | export async function collection( | ||||||
|  | 	ids, | ||||||
|  | 	kind, | ||||||
|  | 	parent, | ||||||
|  | 	max_rowid, | ||||||
|  | 	data, | ||||||
|  | 	include_private | ||||||
|  | ) { | ||||||
| 	let whoami = await ssb.getIdentities(); | 	let whoami = await ssb.getIdentities(); | ||||||
| 	data = data ?? {}; | 	data = data ?? {}; | ||||||
| 	let rowid = 0; | 	let rowid = 0; | ||||||
| 	let first = true; | 	let first = true; | ||||||
| 	await ssb.sqlAsync('SELECT MAX(rowid) AS rowid FROM messages', [], function(row) { | 	await ssb.sqlAsync( | ||||||
| 		rowid = row.rowid; | 		'SELECT MAX(rowid) AS rowid FROM messages', | ||||||
| 	}); | 		[], | ||||||
|  | 		function (row) { | ||||||
|  | 			rowid = row.rowid; | ||||||
|  | 		} | ||||||
|  | 	); | ||||||
| 	while (true) { | 	while (true) { | ||||||
| 		if (rowid == max_rowid) { | 		if (rowid == max_rowid) { | ||||||
| 			await new_message(); | 			await new_message(); | ||||||
| 			await ssb.sqlAsync('SELECT MAX(rowid) AS rowid FROM messages', [], function(row) { | 			await ssb.sqlAsync( | ||||||
| 				rowid = row.rowid; | 				'SELECT MAX(rowid) AS rowid FROM messages', | ||||||
| 			}); | 				[], | ||||||
|  | 				function (row) { | ||||||
|  | 					rowid = row.rowid; | ||||||
|  | 				} | ||||||
|  | 			); | ||||||
| 			first = false; | 			first = false; | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		let modified = false; | 		let modified = false; | ||||||
| 		let rows = []; | 		let rows = []; | ||||||
| 		await ssb.sqlAsync(` | 		await ssb.sqlAsync( | ||||||
|  | 			` | ||||||
| 				SELECT messages.id, author, content, timestamp | 				SELECT messages.id, author, content, timestamp | ||||||
| 				FROM messages | 				FROM messages | ||||||
| 				JOIN json_each(?1) AS id ON messages.author = id.value | 				JOIN json_each(?1) AS id ON messages.author = id.value | ||||||
| @@ -88,9 +106,19 @@ export async function collection(ids, kind, parent, max_rowid, data, include_pri | |||||||
| 					(?5 IS NULL OR json_extract(messages.content, '$.parent') = ?5)) OR | 					(?5 IS NULL OR json_extract(messages.content, '$.parent') = ?5)) OR | ||||||
| 					(?6 AND content LIKE '"%')) | 					(?6 AND content LIKE '"%')) | ||||||
| 				ORDER BY timestamp | 				ORDER BY timestamp | ||||||
| 		`, [JSON.stringify(ids), max_rowid ?? -1, rowid, kind, parent, include_private ? true : false], function(row) { | 		`, | ||||||
| 			rows.push(row); | 			[ | ||||||
| 		}); | 				JSON.stringify(ids), | ||||||
|  | 				max_rowid ?? -1, | ||||||
|  | 				rowid, | ||||||
|  | 				kind, | ||||||
|  | 				parent, | ||||||
|  | 				include_private ? true : false, | ||||||
|  | 			], | ||||||
|  | 			function (row) { | ||||||
|  | 				rows.push(row); | ||||||
|  | 			} | ||||||
|  | 		); | ||||||
| 		max_rowid = rowid; | 		max_rowid = rowid; | ||||||
| 		for (let row of rows) { | 		for (let row of rows) { | ||||||
| 			if (await process_message(whoami, data, row, kind, parent)) { | 			if (await process_message(whoami, data, row, kind, parent)) { | ||||||
|   | |||||||
							
								
								
									
										96
									
								
								core/app.js
									
									
									
									
									
								
							
							
						
						
									
										96
									
								
								core/app.js
									
									
									
									
									
								
							| @@ -28,23 +28,23 @@ function App() { | |||||||
|  * TODOC |  * TODOC | ||||||
|  * @param {*} callback |  * @param {*} callback | ||||||
|  */ |  */ | ||||||
| App.prototype.readOutput = function(callback) { | App.prototype.readOutput = function (callback) { | ||||||
| 	this._on_output = callback; | 	this._on_output = callback; | ||||||
| } | }; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * TODOC |  * TODOC | ||||||
|  * @param {*} api |  * @param {*} api | ||||||
|  * @returns |  * @returns | ||||||
|  */ |  */ | ||||||
| App.prototype.makeFunction = function(api) { | App.prototype.makeFunction = function (api) { | ||||||
| 	let self = this; | 	let self = this; | ||||||
| 	let result = function() { | 	let result = function () { | ||||||
| 		let id = g_next_id++; | 		let id = g_next_id++; | ||||||
| 		while (!id || g_calls[id]) { | 		while (!id || g_calls[id]) { | ||||||
| 			id = g_next_id++; | 			id = g_next_id++; | ||||||
| 		} | 		} | ||||||
| 		let promise = new Promise(function(resolve, reject) { | 		let promise = new Promise(function (resolve, reject) { | ||||||
| 			g_calls[id] = {resolve: resolve, reject: reject}; | 			g_calls[id] = {resolve: resolve, reject: reject}; | ||||||
| 		}); | 		}); | ||||||
| 		let message = { | 		let message = { | ||||||
| @@ -58,16 +58,16 @@ App.prototype.makeFunction = function(api) { | |||||||
| 	}; | 	}; | ||||||
| 	Object.defineProperty(result, 'name', {value: api[0], writable: false}); | 	Object.defineProperty(result, 'name', {value: api[0], writable: false}); | ||||||
| 	return result; | 	return result; | ||||||
| } | }; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * TODOC |  * TODOC | ||||||
|  * @param {*} message |  * @param {*} message | ||||||
|  */ |  */ | ||||||
| App.prototype.send = function(message) { | App.prototype.send = function (message) { | ||||||
| 	if (this._send_queue) { | 	if (this._send_queue) { | ||||||
| 		if (this._on_output) { | 		if (this._on_output) { | ||||||
| 			this._send_queue.forEach(x => this._on_output(x)); | 			this._send_queue.forEach((x) => this._on_output(x)); | ||||||
| 			this._send_queue = null; | 			this._send_queue = null; | ||||||
| 		} else if (message) { | 		} else if (message) { | ||||||
| 			this._send_queue.push(message); | 			this._send_queue.push(message); | ||||||
| @@ -90,43 +90,48 @@ function socket(request, response, client) { | |||||||
| 	let credentials = auth.query(request.headers); | 	let credentials = auth.query(request.headers); | ||||||
| 	let refresh = auth.makeRefresh(credentials); | 	let refresh = auth.makeRefresh(credentials); | ||||||
|  |  | ||||||
| 	response.onClose = async function() { | 	response.onClose = async function () { | ||||||
| 		if (process && process.task) { | 		if (process && process.task) { | ||||||
| 			process.task.kill(); | 			process.task.kill(); | ||||||
| 		} | 		} | ||||||
| 		if (process) { | 		if (process) { | ||||||
| 			process.timeout = 0; | 			process.timeout = 0; | ||||||
| 		} | 		} | ||||||
| 	} | 	}; | ||||||
|  |  | ||||||
| 	response.onMessage = async function(event) { | 	response.onMessage = async function (event) { | ||||||
| 		if (event.opCode == 0x1 || event.opCode == 0x2) { | 		if (event.opCode == 0x1 || event.opCode == 0x2) { | ||||||
| 			let message; | 			let message; | ||||||
| 			try { | 			try { | ||||||
| 				message = JSON.parse(event.data); | 				message = JSON.parse(event.data); | ||||||
| 			} catch (error) { | 			} catch (error) { | ||||||
| 				print("ERROR", error, event.data, event.data.length, event.opCode); | 				print('ERROR', error, event.data, event.data.length, event.opCode); | ||||||
| 				return; | 				return; | ||||||
| 			} | 			} | ||||||
| 			if (message.action == "hello") { | 			if (message.action == 'hello') { | ||||||
| 				let packageOwner; | 				let packageOwner; | ||||||
| 				let packageName; | 				let packageName; | ||||||
| 				let blobId; | 				let blobId; | ||||||
| 				let match; | 				let match; | ||||||
| 				let parentApp; | 				let parentApp; | ||||||
| 				if (match = /^\/([&%][^\.]{44}(?:\.\w+)?)(\/?.*)/.exec(message.path)) { | 				if ( | ||||||
|  | 					(match = /^\/([&%][^\.]{44}(?:\.\w+)?)(\/?.*)/.exec(message.path)) | ||||||
|  | 				) { | ||||||
| 					blobId = match[1]; | 					blobId = match[1]; | ||||||
| 				} else if (match = /^\/\~([^\/]+)\/([^\/]+)\/$/.exec(message.path)) { | 				} else if ((match = /^\/\~([^\/]+)\/([^\/]+)\/$/.exec(message.path))) { | ||||||
| 					packageOwner = match[1]; | 					packageOwner = match[1]; | ||||||
| 					packageName = match[2]; | 					packageName = match[2]; | ||||||
| 					blobId = await new Database(packageOwner).get('path:' + packageName); | 					blobId = await new Database(packageOwner).get('path:' + packageName); | ||||||
| 					if (!blobId) { | 					if (!blobId) { | ||||||
| 						response.send(JSON.stringify({ | 						response.send( | ||||||
| 							message: 'tfrpc', | 							JSON.stringify({ | ||||||
| 							method: "error", | 								message: 'tfrpc', | ||||||
| 							params: [message.path + ' not found'], | 								method: 'error', | ||||||
| 							id: -1, | 								params: [message.path + ' not found'], | ||||||
| 						}), 0x1); | 								id: -1, | ||||||
|  | 							}), | ||||||
|  | 							0x1 | ||||||
|  | 						); | ||||||
| 						return; | 						return; | ||||||
| 					} | 					} | ||||||
| 					if (packageOwner != 'core') { | 					if (packageOwner != 'core') { | ||||||
| @@ -137,12 +142,15 @@ function socket(request, response, client) { | |||||||
| 						}; | 						}; | ||||||
| 					} | 					} | ||||||
| 				} | 				} | ||||||
| 				response.send(JSON.stringify({ | 				response.send( | ||||||
| 					action: "session", | 					JSON.stringify({ | ||||||
| 					credentials: credentials, | 						action: 'session', | ||||||
| 					parentApp: parentApp, | 						credentials: credentials, | ||||||
| 					id: blobId, | 						parentApp: parentApp, | ||||||
| 				}), 0x1); | 						id: blobId, | ||||||
|  | 					}), | ||||||
|  | 					0x1 | ||||||
|  | 				); | ||||||
|  |  | ||||||
| 				options.api = message.api || []; | 				options.api = message.api || []; | ||||||
| 				options.credentials = credentials; | 				options.credentials = credentials; | ||||||
| @@ -152,19 +160,26 @@ function socket(request, response, client) { | |||||||
| 				let sessionId = makeSessionId(); | 				let sessionId = makeSessionId(); | ||||||
| 				if (blobId) { | 				if (blobId) { | ||||||
| 					if (message.edit_only) { | 					if (message.edit_only) { | ||||||
| 						response.send(JSON.stringify({action: 'ready', edit_only: true}), 0x1); | 						response.send( | ||||||
|  | 							JSON.stringify({action: 'ready', edit_only: true}), | ||||||
|  | 							0x1 | ||||||
|  | 						); | ||||||
| 					} else { | 					} else { | ||||||
| 						process = await core.getSessionProcessBlob(blobId, sessionId, options); | 						process = await core.getSessionProcessBlob( | ||||||
|  | 							blobId, | ||||||
|  | 							sessionId, | ||||||
|  | 							options | ||||||
|  | 						); | ||||||
| 					} | 					} | ||||||
| 				} | 				} | ||||||
| 				if (process) { | 				if (process) { | ||||||
| 					process.app.readOutput(function(message) { | 					process.app.readOutput(function (message) { | ||||||
| 						response.send(JSON.stringify(message), 0x1); | 						response.send(JSON.stringify(message), 0x1); | ||||||
| 					}); | 					}); | ||||||
| 					process.app.send(); | 					process.app.send(); | ||||||
| 				} | 				} | ||||||
|  |  | ||||||
| 				let ping = function() { | 				let ping = function () { | ||||||
| 					let now = Date.now(); | 					let now = Date.now(); | ||||||
| 					let again = true; | 					let again = true; | ||||||
| 					if (now - process.lastActive < process.timeout) { | 					if (now - process.lastActive < process.timeout) { | ||||||
| @@ -177,14 +192,14 @@ function socket(request, response, client) { | |||||||
| 						again = false; | 						again = false; | ||||||
| 					} else { | 					} else { | ||||||
| 						// Idle.  Ping them. | 						// Idle.  Ping them. | ||||||
| 						response.send("", 0x9); | 						response.send('', 0x9); | ||||||
| 						process.lastPing = now; | 						process.lastPing = now; | ||||||
| 					} | 					} | ||||||
|  |  | ||||||
| 					if (again && process.timeout) { | 					if (again && process.timeout) { | ||||||
| 						setTimeout(ping, process.timeout); | 						setTimeout(ping, process.timeout); | ||||||
| 					} | 					} | ||||||
| 				} | 				}; | ||||||
|  |  | ||||||
| 				if (process && process.timeout > 0) { | 				if (process && process.timeout > 0) { | ||||||
| 					setTimeout(ping, process.timeout); | 					setTimeout(ping, process.timeout); | ||||||
| @@ -224,11 +239,16 @@ function socket(request, response, client) { | |||||||
| 		if (process) { | 		if (process) { | ||||||
| 			process.lastActive = Date.now(); | 			process.lastActive = Date.now(); | ||||||
| 		} | 		} | ||||||
| 	} | 	}; | ||||||
|  |  | ||||||
| 	response.upgrade(100, refresh ? { | 	response.upgrade( | ||||||
| 		'Set-Cookie': `session=${refresh.token}; path=/; Max-Age=${refresh.interval}; Secure; SameSite=Strict`, | 		100, | ||||||
| 	} : {}); | 		refresh | ||||||
|  | 			? { | ||||||
|  | 					'Set-Cookie': `session=${refresh.token}; path=/; Max-Age=${refresh.interval}; Secure; SameSite=Strict`, | ||||||
|  | 				} | ||||||
|  | 			: {} | ||||||
|  | 	); | ||||||
| } | } | ||||||
|  |  | ||||||
| export { socket, App }; | export {socket, App}; | ||||||
|   | |||||||
| @@ -1,15 +1,17 @@ | |||||||
| <!DOCTYPE html> | <!doctype html> | ||||||
| <html> | <html> | ||||||
| 	<head> | 	<head> | ||||||
| 		<title>Tilde Friends Sign-in</title> | 		<title>Tilde Friends Sign-in</title> | ||||||
| 		<link type="text/css" rel="stylesheet" href="/static/style.css"> | 		<link type="text/css" rel="stylesheet" href="/static/style.css" /> | ||||||
| 		<link type="image/png" rel="shortcut icon" href="/static/favicon.png"> | 		<link type="image/png" rel="shortcut icon" href="/static/favicon.png" /> | ||||||
| 		<meta name="viewport" content="width=device-width, initial-scale=1"> | 		<meta name="viewport" content="width=device-width, initial-scale=1" /> | ||||||
| 	</head> | 	</head> | ||||||
| 	<body> | 	<body> | ||||||
| 		<h1 style="text-align: center">Tilde Friends Sign-in</h1> | 		<h1 style="text-align: center">Tilde Friends Sign-in</h1> | ||||||
| 		<tf-auth id="auth"></tf-auth> | 		<tf-auth id="auth"></tf-auth> | ||||||
| 		<script>window.litDisableBundleWarning = true;</script> | 		<script> | ||||||
|  | 			window.litDisableBundleWarning = true; | ||||||
|  | 		</script> | ||||||
| 		<script type="module"> | 		<script type="module"> | ||||||
| 			import {LitElement, html} from '/lit/lit-all.min.js'; | 			import {LitElement, html} from '/lit/lit-all.min.js'; | ||||||
| 			let g_data = $AUTH_DATA; | 			let g_data = $AUTH_DATA; | ||||||
|   | |||||||
							
								
								
									
										153
									
								
								core/auth.js
									
									
									
									
									
								
							
							
						
						
									
										153
									
								
								core/auth.js
									
									
									
									
									
								
							| @@ -1,7 +1,7 @@ | |||||||
| import * as core from './core.js'; | import * as core from './core.js'; | ||||||
| import * as form from './form.js'; | import * as form from './form.js'; | ||||||
|  |  | ||||||
| let gDatabase = new Database("auth"); | let gDatabase = new Database('auth'); | ||||||
|  |  | ||||||
| const kRefreshInterval = 1 * 7 * 24 * 60 * 60 * 1000; | const kRefreshInterval = 1 * 7 * 24 * 60 * 60 * 1000; | ||||||
|  |  | ||||||
| @@ -54,8 +54,20 @@ function makeJwt(payload) { | |||||||
| 		id = ssb.createIdentity(':auth'); | 		id = ssb.createIdentity(':auth'); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	const final_payload = b64url(base64Encode(JSON.stringify(Object.assign({}, payload, {exp: (new Date().valueOf()) + kRefreshInterval})))); | 	const final_payload = b64url( | ||||||
| 	const jwt = [b64url(base64Encode(JSON.stringify({alg: 'HS256', typ: 'JWT'}))), final_payload, b64url(ssb.hmacsha256sign(final_payload, ':auth', id))].join('.'); | 		base64Encode( | ||||||
|  | 			JSON.stringify( | ||||||
|  | 				Object.assign({}, payload, { | ||||||
|  | 					exp: new Date().valueOf() + kRefreshInterval, | ||||||
|  | 				}) | ||||||
|  | 			) | ||||||
|  | 		) | ||||||
|  | 	); | ||||||
|  | 	const jwt = [ | ||||||
|  | 		b64url(base64Encode(JSON.stringify({alg: 'HS256', typ: 'JWT'}))), | ||||||
|  | 		final_payload, | ||||||
|  | 		b64url(ssb.hmacsha256sign(final_payload, ':auth', id)), | ||||||
|  | 	].join('.'); | ||||||
| 	return jwt; | 	return jwt; | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -77,7 +89,7 @@ function readSession(session) { | |||||||
|  |  | ||||||
| 			if (id?.length && ssb.hmacsha256verify(id[0], payload, signature)) { | 			if (id?.length && ssb.hmacsha256verify(id[0], payload, signature)) { | ||||||
| 				const result = JSON.parse(utf8Decode(base64Decode(unb64url(payload)))); | 				const result = JSON.parse(utf8Decode(base64Decode(unb64url(payload)))); | ||||||
| 				const now = new Date().valueOf() | 				const now = new Date().valueOf(); | ||||||
|  |  | ||||||
| 				if (now < result.exp) { | 				if (now < result.exp) { | ||||||
| 					print(`JWT valid for another ${(result.exp - now) / 1000} seconds.`); | 					print(`JWT valid for another ${(result.exp - now) / 1000} seconds.`); | ||||||
| @@ -141,8 +153,8 @@ function makeAdministrator(name) { | |||||||
| 	if (!core.globalSettings.permissions[name]) { | 	if (!core.globalSettings.permissions[name]) { | ||||||
| 		core.globalSettings.permissions[name] = []; | 		core.globalSettings.permissions[name] = []; | ||||||
| 	} | 	} | ||||||
| 	if (core.globalSettings.permissions[name].indexOf("administration") == -1) { | 	if (core.globalSettings.permissions[name].indexOf('administration') == -1) { | ||||||
| 		core.globalSettings.permissions[name].push("administration"); | 		core.globalSettings.permissions[name].push('administration'); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	core.setGlobalSettings(core.globalSettings); | 	core.setGlobalSettings(core.globalSettings); | ||||||
| @@ -159,7 +171,7 @@ function getCookies(headers) { | |||||||
| 	if (headers.cookie) { | 	if (headers.cookie) { | ||||||
| 		let parts = headers.cookie.split(/,|;/); | 		let parts = headers.cookie.split(/,|;/); | ||||||
| 		for (let i in parts) { | 		for (let i in parts) { | ||||||
| 			let equals = parts[i].indexOf("="); | 			let equals = parts[i].indexOf('='); | ||||||
| 			let name = parts[i].substring(0, equals).trim(); | 			let name = parts[i].substring(0, equals).trim(); | ||||||
| 			let value = parts[i].substring(equals + 1).trim(); | 			let value = parts[i].substring(equals + 1).trim(); | ||||||
| 			cookies[name] = value; | 			cookies[name] = value; | ||||||
| @@ -177,7 +189,17 @@ function getCookies(headers) { | |||||||
| function isNameValid(name) { | function isNameValid(name) { | ||||||
| 	// TODO(tasiaiso): convert this into a regex | 	// TODO(tasiaiso): convert this into a regex | ||||||
| 	let c = name.charAt(0); | 	let c = name.charAt(0); | ||||||
| 	return ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) && name.split().map(x => x >= ('a' && x <= 'z') || x >= ('A' && x <= 'Z') || x >= ('0' && x <= '9')); | 	return ( | ||||||
|  | 		((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) && | ||||||
|  | 		name | ||||||
|  | 			.split() | ||||||
|  | 			.map( | ||||||
|  | 				(x) => | ||||||
|  | 					x >= ('a' && x <= 'z') || | ||||||
|  | 					x >= ('A' && x <= 'Z') || | ||||||
|  | 					x >= ('0' && x <= '9') | ||||||
|  | 			) | ||||||
|  | 	); | ||||||
| } | } | ||||||
|  |  | ||||||
| /** | /** | ||||||
| @@ -189,13 +211,19 @@ function isNameValid(name) { | |||||||
| function handler(request, response) { | function handler(request, response) { | ||||||
| 	// TODO(tasiaiso): split this function | 	// TODO(tasiaiso): split this function | ||||||
| 	let session = getCookies(request.headers).session; | 	let session = getCookies(request.headers).session; | ||||||
| 	if (request.uri == "/login") { | 	if (request.uri == '/login') { | ||||||
| 		let formData = form.decodeForm(request.query); | 		let formData = form.decodeForm(request.query); | ||||||
| 		if (query(request.headers)?.permissions?.authenticated) { | 		if (query(request.headers)?.permissions?.authenticated) { | ||||||
| 			if (formData.return) { | 			if (formData.return) { | ||||||
| 				response.writeHead(303, {"Location": formData.return}); | 				response.writeHead(303, {Location: formData.return}); | ||||||
| 			} else { | 			} else { | ||||||
| 				response.writeHead(303, {"Location": (request.client.tls ? 'https://' : 'http://') + request.headers.host + '/', "Content-Length": "0"}); | 				response.writeHead(303, { | ||||||
|  | 					Location: | ||||||
|  | 						(request.client.tls ? 'https://' : 'http://') + | ||||||
|  | 						request.headers.host + | ||||||
|  | 						'/', | ||||||
|  | 					'Content-Length': '0', | ||||||
|  | 				}); | ||||||
| 			} | 			} | ||||||
| 			response.end(); | 			response.end(); | ||||||
| 			return; | 			return; | ||||||
| @@ -204,22 +232,23 @@ function handler(request, response) { | |||||||
| 		let sessionIsNew = false; | 		let sessionIsNew = false; | ||||||
| 		let loginError; | 		let loginError; | ||||||
|  |  | ||||||
| 		if (request.method == "POST" || formData.submit) { | 		if (request.method == 'POST' || formData.submit) { | ||||||
| 			sessionIsNew = true; | 			sessionIsNew = true; | ||||||
| 			formData = form.decodeForm(utf8Decode(request.body), formData); | 			formData = form.decodeForm(utf8Decode(request.body), formData); | ||||||
| 			if (formData.submit == "Login") { | 			if (formData.submit == 'Login') { | ||||||
| 				let account = gDatabase.get("user:" + formData.name); | 				let account = gDatabase.get('user:' + formData.name); | ||||||
| 				account = account ? JSON.parse(account) : account; | 				account = account ? JSON.parse(account) : account; | ||||||
| 				if (formData.register == '1') { | 				if (formData.register == '1') { | ||||||
| 					if (!account && | 					if ( | ||||||
|  | 						!account && | ||||||
| 						isNameValid(formData.name) && | 						isNameValid(formData.name) && | ||||||
| 						formData.password == formData.confirm) { | 						formData.password == formData.confirm | ||||||
|  | 					) { | ||||||
| 						let users = new Set(); | 						let users = new Set(); | ||||||
| 						let users_original = gDatabase.get('users'); | 						let users_original = gDatabase.get('users'); | ||||||
| 						try { | 						try { | ||||||
| 							users = new Set(JSON.parse(users_original)); | 							users = new Set(JSON.parse(users_original)); | ||||||
| 						} catch { | 						} catch {} | ||||||
| 						} |  | ||||||
| 						if (!users.has(formData.name)) { | 						if (!users.has(formData.name)) { | ||||||
| 							users.add(formData.name); | 							users.add(formData.name); | ||||||
| 						} | 						} | ||||||
| @@ -237,10 +266,12 @@ function handler(request, response) { | |||||||
| 						loginError = 'Error registering account.'; | 						loginError = 'Error registering account.'; | ||||||
| 					} | 					} | ||||||
| 				} else if (formData.change == '1') { | 				} else if (formData.change == '1') { | ||||||
| 					if (account && | 					if ( | ||||||
|  | 						account && | ||||||
| 						isNameValid(formData.name) && | 						isNameValid(formData.name) && | ||||||
| 						formData.new_password == formData.confirm && | 						formData.new_password == formData.confirm && | ||||||
| 						verifyPassword(formData.password, account.password)) { | 						verifyPassword(formData.password, account.password) | ||||||
|  | 					) { | ||||||
| 						session = makeJwt({name: formData.name}); | 						session = makeJwt({name: formData.name}); | ||||||
| 						account = {password: hashPassword(formData.new_password)}; | 						account = {password: hashPassword(formData.new_password)}; | ||||||
| 						gDatabase.set('user:' + formData.name, JSON.stringify(account)); | 						gDatabase.set('user:' + formData.name, JSON.stringify(account)); | ||||||
| @@ -248,9 +279,11 @@ function handler(request, response) { | |||||||
| 						loginError = 'Error changing password.'; | 						loginError = 'Error changing password.'; | ||||||
| 					} | 					} | ||||||
| 				} else { | 				} else { | ||||||
| 					if (account && | 					if ( | ||||||
|  | 						account && | ||||||
| 						account.password && | 						account.password && | ||||||
| 						verifyPassword(formData.password, account.password)) { | 						verifyPassword(formData.password, account.password) | ||||||
|  | 					) { | ||||||
| 						session = makeJwt({name: formData.name}); | 						session = makeJwt({name: formData.name}); | ||||||
| 						if (noAdministrator()) { | 						if (noAdministrator()) { | ||||||
| 							makeAdministrator(formData.name); | 							makeAdministrator(formData.name); | ||||||
| @@ -268,32 +301,52 @@ function handler(request, response) { | |||||||
| 		let cookie = `session=${session}; path=/; Max-Age=${kRefreshInterval}; ${request.client.tls ? 'Secure; ' : ''}SameSite=Strict; HttpOnly`; | 		let cookie = `session=${session}; path=/; Max-Age=${kRefreshInterval}; ${request.client.tls ? 'Secure; ' : ''}SameSite=Strict; HttpOnly`; | ||||||
| 		let entry = readSession(session); | 		let entry = readSession(session); | ||||||
| 		if (entry && formData.return) { | 		if (entry && formData.return) { | ||||||
| 			response.writeHead(303, {"Location": formData.return, "Set-Cookie": cookie}); | 			response.writeHead(303, { | ||||||
|  | 				Location: formData.return, | ||||||
|  | 				'Set-Cookie': cookie, | ||||||
|  | 			}); | ||||||
| 			response.end(); | 			response.end(); | ||||||
| 		} else { | 		} else { | ||||||
| 			File.readFile("core/auth.html").then(function(data) { | 			File.readFile('core/auth.html') | ||||||
| 				let html = utf8Decode(data); | 				.then(function (data) { | ||||||
| 				let auth_data = { | 					let html = utf8Decode(data); | ||||||
| 					session_is_new: sessionIsNew, | 					let auth_data = { | ||||||
| 					name: entry?.name, | 						session_is_new: sessionIsNew, | ||||||
| 					error: loginError, | 						name: entry?.name, | ||||||
| 					code_of_conduct: core.globalSettings.code_of_conduct, | 						error: loginError, | ||||||
| 					have_administrator: !noAdministrator(), | 						code_of_conduct: core.globalSettings.code_of_conduct, | ||||||
| 				}; | 						have_administrator: !noAdministrator(), | ||||||
| 				html = utf8Encode(html.replace('$AUTH_DATA', JSON.stringify(auth_data))); | 					}; | ||||||
| 				response.writeHead(200, {"Content-Type": "text/html; charset=utf-8", "Set-Cookie": cookie, "Content-Length": html.length}); | 					html = utf8Encode( | ||||||
| 				response.end(html); | 						html.replace('$AUTH_DATA', JSON.stringify(auth_data)) | ||||||
| 			}).catch(function(error) { | 					); | ||||||
| 				response.writeHead(404, {"Content-Type": "text/plain; charset=utf-8", "Connection": "close"}); | 					response.writeHead(200, { | ||||||
| 				response.end("404 File not found"); | 						'Content-Type': 'text/html; charset=utf-8', | ||||||
| 			}); | 						'Set-Cookie': cookie, | ||||||
|  | 						'Content-Length': html.length, | ||||||
|  | 					}); | ||||||
|  | 					response.end(html); | ||||||
|  | 				}) | ||||||
|  | 				.catch(function (error) { | ||||||
|  | 					response.writeHead(404, { | ||||||
|  | 						'Content-Type': 'text/plain; charset=utf-8', | ||||||
|  | 						Connection: 'close', | ||||||
|  | 					}); | ||||||
|  | 					response.end('404 File not found'); | ||||||
|  | 				}); | ||||||
| 		} | 		} | ||||||
| 	} else if (request.uri == "/login/logout") { | 	} else if (request.uri == '/login/logout') { | ||||||
| 		response.writeHead(303, {"Set-Cookie": `session=; path=/; ${request.client.tls ? 'Secure; ' : ''}SameSite=Strict; expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly`, "Location": "/login" + (request.query ? "?" + request.query : "")}); | 		response.writeHead(303, { | ||||||
|  | 			'Set-Cookie': `session=; path=/; ${request.client.tls ? 'Secure; ' : ''}SameSite=Strict; expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly`, | ||||||
|  | 			Location: '/login' + (request.query ? '?' + request.query : ''), | ||||||
|  | 		}); | ||||||
| 		response.end(); | 		response.end(); | ||||||
| 	} else { | 	} else { | ||||||
| 		response.writeHead(200, {"Content-Type": "text/plain; charset=utf-8", "Connection": "close"}); | 		response.writeHead(200, { | ||||||
| 		response.end("Hello, " + request.client.peerName + "."); | 			'Content-Type': 'text/plain; charset=utf-8', | ||||||
|  | 			Connection: 'close', | ||||||
|  | 		}); | ||||||
|  | 		response.end('Hello, ' + request.client.peerName + '.'); | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -307,7 +360,7 @@ function getPermissions(session) { | |||||||
| 	let entry = readSession(session); | 	let entry = readSession(session); | ||||||
| 	if (entry) { | 	if (entry) { | ||||||
| 		permissions = getPermissionsForUser(entry.name); | 		permissions = getPermissionsForUser(entry.name); | ||||||
| 		permissions.authenticated = entry.name !== "guest"; | 		permissions.authenticated = entry.name !== 'guest'; | ||||||
| 	} | 	} | ||||||
| 	return permissions || {}; | 	return permissions || {}; | ||||||
| } | } | ||||||
| @@ -319,7 +372,11 @@ function getPermissions(session) { | |||||||
|  */ |  */ | ||||||
| function getPermissionsForUser(userName) { | function getPermissionsForUser(userName) { | ||||||
| 	let permissions = {}; | 	let permissions = {}; | ||||||
| 	if (core.globalSettings && core.globalSettings.permissions && core.globalSettings.permissions[userName]) { | 	if ( | ||||||
|  | 		core.globalSettings && | ||||||
|  | 		core.globalSettings.permissions && | ||||||
|  | 		core.globalSettings.permissions[userName] | ||||||
|  | 	) { | ||||||
| 		for (let i in core.globalSettings.permissions[userName]) { | 		for (let i in core.globalSettings.permissions[userName]) { | ||||||
| 			permissions[core.globalSettings.permissions[userName][i]] = true; | 			permissions[core.globalSettings.permissions[userName][i]] = true; | ||||||
| 		} | 		} | ||||||
| @@ -336,10 +393,12 @@ function query(headers) { | |||||||
| 	let session = getCookies(headers).session; | 	let session = getCookies(headers).session; | ||||||
| 	let entry; | 	let entry; | ||||||
| 	let autologin = tildefriends.args.autologin; | 	let autologin = tildefriends.args.autologin; | ||||||
| 	if (entry = autologin ? {name: autologin} : readSession(session)) { | 	if ((entry = autologin ? {name: autologin} : readSession(session))) { | ||||||
| 		return { | 		return { | ||||||
| 			session: entry, | 			session: entry, | ||||||
| 			permissions: autologin ? getPermissionsForUser(autologin) : getPermissions(session), | 			permissions: autologin | ||||||
|  | 				? getPermissionsForUser(autologin) | ||||||
|  | 				: getPermissions(session), | ||||||
| 		}; | 		}; | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										776
									
								
								core/client.js
									
									
									
									
									
								
							
							
						
						
									
										776
									
								
								core/client.js
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										908
									
								
								core/core.js
									
									
									
									
									
								
							
							
						
						
									
										908
									
								
								core/core.js
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										10
									
								
								core/form.js
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								core/form.js
									
									
									
									
									
								
							| @@ -4,12 +4,12 @@ | |||||||
|  * @returns |  * @returns | ||||||
|  */ |  */ | ||||||
| function decode(encoded) { | function decode(encoded) { | ||||||
| 	let result = ""; | 	let result = ''; | ||||||
| 	for (let i = 0; i < encoded.length; i++) { | 	for (let i = 0; i < encoded.length; i++) { | ||||||
| 		let c = encoded[i]; | 		let c = encoded[i]; | ||||||
| 		if (c == "+") { | 		if (c == '+') { | ||||||
| 			result += " "; | 			result += ' '; | ||||||
| 		} else if (c == "%") { | 		} else if (c == '%') { | ||||||
| 			result += String.fromCharCode(parseInt(encoded.slice(i + 1, i + 3), 16)); | 			result += String.fromCharCode(parseInt(encoded.slice(i + 1, i + 3), 16)); | ||||||
| 			i += 2; | 			i += 2; | ||||||
| 		} else { | 		} else { | ||||||
| @@ -41,4 +41,4 @@ function decodeForm(encoded, initial) { | |||||||
| 	return result; | 	return result; | ||||||
| } | } | ||||||
|  |  | ||||||
| export { decodeForm }; | export {decodeForm}; | ||||||
|   | |||||||
							
								
								
									
										101
									
								
								core/http.js
									
									
									
									
									
								
							
							
						
						
									
										101
									
								
								core/http.js
									
									
									
									
									
								
							| @@ -6,12 +6,12 @@ | |||||||
|  */ |  */ | ||||||
| function parseUrl(url) { | function parseUrl(url) { | ||||||
| 	// XXX: Hack. | 	// XXX: Hack. | ||||||
| 	let match = url.match(new RegExp("(\\w+)://([^/:]+)(?::(\\d+))?(.*)")); | 	let match = url.match(new RegExp('(\\w+)://([^/:]+)(?::(\\d+))?(.*)')); | ||||||
| 	return { | 	return { | ||||||
| 		protocol: match[1], | 		protocol: match[1], | ||||||
| 		host: match[2], | 		host: match[2], | ||||||
| 		path: match[4], | 		path: match[4], | ||||||
| 		port: match[3] ? parseInt(match[3]) : match[1] == "http" ? 80 : 443, | 		port: match[3] ? parseInt(match[3]) : match[1] == 'http' ? 80 : 443, | ||||||
| 	}; | 	}; | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -32,7 +32,7 @@ function parseResponse(data) { | |||||||
| 		} else if (!firstLine) { | 		} else if (!firstLine) { | ||||||
| 			firstLine = line; | 			firstLine = line; | ||||||
| 		} else { | 		} else { | ||||||
| 			let colon = line.indexOf(":"); | 			let colon = line.indexOf(':'); | ||||||
| 			headers[line.substring(colon)] = line.substring(colon + 1); | 			headers[line.substring(colon)] = line.substring(colon + 1); | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| @@ -48,57 +48,66 @@ function parseResponse(data) { | |||||||
|  */ |  */ | ||||||
| export function fetch(url, options, allowed_hosts) { | export function fetch(url, options, allowed_hosts) { | ||||||
| 	let parsed = parseUrl(url); | 	let parsed = parseUrl(url); | ||||||
| 	return new Promise(function(resolve, reject) { | 	return new Promise(function (resolve, reject) { | ||||||
| 		if ((allowed_hosts ?? []).indexOf(parsed.host) == -1) { | 		if ((allowed_hosts ?? []).indexOf(parsed.host) == -1) { | ||||||
| 			throw new Error(`fetch() request to host ${parsed.host} is not allowed.`); | 			throw new Error(`fetch() request to host ${parsed.host} is not allowed.`); | ||||||
| 		} | 		} | ||||||
| 		let socket = new Socket(); | 		let socket = new Socket(); | ||||||
| 		let buffer = new Uint8Array(0); | 		let buffer = new Uint8Array(0); | ||||||
|  |  | ||||||
| 		return socket.connect(parsed.host, parsed.port).then(function() { | 		return socket | ||||||
| 			socket.read(function(data) { | 			.connect(parsed.host, parsed.port) | ||||||
| 				if (data && data.length) { | 			.then(function () { | ||||||
| 					let newBuffer = new Uint8Array(buffer.length + data.length); | 				socket.read(function (data) { | ||||||
| 					newBuffer.set(buffer, 0); | 					if (data && data.length) { | ||||||
| 					newBuffer.set(data, buffer.length); | 						let newBuffer = new Uint8Array(buffer.length + data.length); | ||||||
| 					buffer = newBuffer; | 						newBuffer.set(buffer, 0); | ||||||
| 				} else { | 						newBuffer.set(data, buffer.length); | ||||||
| 					let result = parseHttpResponse(buffer); | 						buffer = newBuffer; | ||||||
| 					if (!result) { |  | ||||||
| 						reject(new Exception('Parse failed.')); |  | ||||||
| 					} |  | ||||||
| 					if (typeof result == 'number') { |  | ||||||
| 						if (result == -2) { |  | ||||||
| 							reject('Incomplete request.'); |  | ||||||
| 						} else { |  | ||||||
| 							reject('Bad request.'); |  | ||||||
| 						} |  | ||||||
| 					} else if (typeof result == 'object') { |  | ||||||
| 						resolve({ |  | ||||||
| 							body: buffer.slice(result.bytes_parsed), |  | ||||||
| 							status: result.status, |  | ||||||
| 							message: result.message, |  | ||||||
| 							headers: result.headers, |  | ||||||
| 						}); |  | ||||||
| 					} else { | 					} else { | ||||||
| 						reject(new Exception('Unexpected parse result.')); | 						let result = parseHttpResponse(buffer); | ||||||
|  | 						if (!result) { | ||||||
|  | 							reject(new Exception('Parse failed.')); | ||||||
|  | 						} | ||||||
|  | 						if (typeof result == 'number') { | ||||||
|  | 							if (result == -2) { | ||||||
|  | 								reject('Incomplete request.'); | ||||||
|  | 							} else { | ||||||
|  | 								reject('Bad request.'); | ||||||
|  | 							} | ||||||
|  | 						} else if (typeof result == 'object') { | ||||||
|  | 							resolve({ | ||||||
|  | 								body: buffer.slice(result.bytes_parsed), | ||||||
|  | 								status: result.status, | ||||||
|  | 								message: result.message, | ||||||
|  | 								headers: result.headers, | ||||||
|  | 							}); | ||||||
|  | 						} else { | ||||||
|  | 							reject(new Exception('Unexpected parse result.')); | ||||||
|  | 						} | ||||||
|  | 						resolve(parseResponse(utf8Decode(buffer))); | ||||||
| 					} | 					} | ||||||
| 					resolve(parseResponse(utf8Decode(buffer))); | 				}); | ||||||
| 				} |  | ||||||
| 			}); |  | ||||||
|  |  | ||||||
| 			if (parsed.port == 443) { | 				if (parsed.port == 443) { | ||||||
| 				return socket.startTls(); | 					return socket.startTls(); | ||||||
| 			} | 				} | ||||||
| 		}).then(function() { | 			}) | ||||||
| 			let body = typeof options?.body == 'string' ? utf8Encode(options.body) : (options.body || new Uint8Array(0)); | 			.then(function () { | ||||||
| 			let headers = utf8Encode(`${options?.method ?? 'GET'} ${parsed.path} HTTP/1.0\r\nHost: ${parsed.host}\r\nConnection: close\r\nContent-Length: ${body.length}\r\n\r\n`); | 				let body = | ||||||
| 			let fullRequest = new Uint8Array(headers.length + body.length); | 					typeof options?.body == 'string' | ||||||
| 			fullRequest.set(headers, 0); | 						? utf8Encode(options.body) | ||||||
| 			fullRequest.set(body, headers.length); | 						: options.body || new Uint8Array(0); | ||||||
| 			socket.write(fullRequest); | 				let headers = utf8Encode( | ||||||
| 		}).catch(function(error) { | 					`${options?.method ?? 'GET'} ${parsed.path} HTTP/1.0\r\nHost: ${parsed.host}\r\nConnection: close\r\nContent-Length: ${body.length}\r\n\r\n` | ||||||
| 			reject(error); | 				); | ||||||
| 		}); | 				let fullRequest = new Uint8Array(headers.length + body.length); | ||||||
|  | 				fullRequest.set(headers, 0); | ||||||
|  | 				fullRequest.set(body, headers.length); | ||||||
|  | 				socket.write(fullRequest); | ||||||
|  | 			}) | ||||||
|  | 			.catch(function (error) { | ||||||
|  | 				reject(error); | ||||||
|  | 			}); | ||||||
| 	}); | 	}); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -102,22 +102,54 @@ a:active { | |||||||
| } | } | ||||||
|  |  | ||||||
| /* Solarized Color Scheme Colors */ | /* Solarized Color Scheme Colors */ | ||||||
| .base03 { color: #002b36; } | .base03 { | ||||||
| .base02 { color: #073642; } | 	color: #002b36; | ||||||
| .base01 { color: #586e75; } | } | ||||||
| .base00 { color: #657b83; } | .base02 { | ||||||
| .base0 { color: #839496; } | 	color: #073642; | ||||||
| .base1 { color: #93a1a1; } | } | ||||||
| .base2 { color: #eee8d5; } | .base01 { | ||||||
| .base3 { color: #fdf6e3; } | 	color: #586e75; | ||||||
| .yellow { color: #b58900; } | } | ||||||
| .orange { color: #cb4b16; } | .base00 { | ||||||
| .red { color: #dc322f; } | 	color: #657b83; | ||||||
| .magenta { color: #d33682; } | } | ||||||
| .violet { color: #6c71c4; } | .base0 { | ||||||
| .blue { color: #268bd2; } | 	color: #839496; | ||||||
| .cyan { color: #2aa198; } | } | ||||||
| .green { color: #859900; } | .base1 { | ||||||
|  | 	color: #93a1a1; | ||||||
|  | } | ||||||
|  | .base2 { | ||||||
|  | 	color: #eee8d5; | ||||||
|  | } | ||||||
|  | .base3 { | ||||||
|  | 	color: #fdf6e3; | ||||||
|  | } | ||||||
|  | .yellow { | ||||||
|  | 	color: #b58900; | ||||||
|  | } | ||||||
|  | .orange { | ||||||
|  | 	color: #cb4b16; | ||||||
|  | } | ||||||
|  | .red { | ||||||
|  | 	color: #dc322f; | ||||||
|  | } | ||||||
|  | .magenta { | ||||||
|  | 	color: #d33682; | ||||||
|  | } | ||||||
|  | .violet { | ||||||
|  | 	color: #6c71c4; | ||||||
|  | } | ||||||
|  | .blue { | ||||||
|  | 	color: #268bd2; | ||||||
|  | } | ||||||
|  | .cyan { | ||||||
|  | 	color: #2aa198; | ||||||
|  | } | ||||||
|  | .green { | ||||||
|  | 	color: #859900; | ||||||
|  | } | ||||||
|  |  | ||||||
| .permissions { | .permissions { | ||||||
| 	position: absolute; | 	position: absolute; | ||||||
|   | |||||||
| @@ -8,7 +8,11 @@ let g_calls = {}; | |||||||
|  * @returns |  * @returns | ||||||
|  */ |  */ | ||||||
| function get_is_browser() { | function get_is_browser() { | ||||||
| 	try { return window !== undefined && console !== undefined; } catch { return false; } | 	try { | ||||||
|  | 		return window !== undefined && console !== undefined; | ||||||
|  | 	} catch { | ||||||
|  | 		return false; | ||||||
|  | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| if (k_is_browser) { | if (k_is_browser) { | ||||||
| @@ -23,21 +27,31 @@ if (k_is_browser) { | |||||||
|  * @returns |  * @returns | ||||||
|  */ |  */ | ||||||
| function make_rpc(target, prop, receiver) { | function make_rpc(target, prop, receiver) { | ||||||
| 	return function() { | 	return function () { | ||||||
| 		let id = g_next_id++; | 		let id = g_next_id++; | ||||||
| 		while (!id || g_calls[id] !== undefined) { | 		while (!id || g_calls[id] !== undefined) { | ||||||
| 			id = g_next_id++; | 			id = g_next_id++; | ||||||
| 		} | 		} | ||||||
| 		let promise = new Promise(function(resolve, reject) { | 		let promise = new Promise(function (resolve, reject) { | ||||||
| 			g_calls[id] = {resolve: resolve, reject: reject}; | 			g_calls[id] = {resolve: resolve, reject: reject}; | ||||||
| 		}); | 		}); | ||||||
| 		if (k_is_browser) { | 		if (k_is_browser) { | ||||||
| 			window.parent.postMessage({message: 'tfrpc', method: prop, params: [...arguments], id: id}, '*'); | 			window.parent.postMessage( | ||||||
|  | 				{message: 'tfrpc', method: prop, params: [...arguments], id: id}, | ||||||
|  | 				'*' | ||||||
|  | 			); | ||||||
| 			return promise; | 			return promise; | ||||||
| 		} else { | 		} else { | ||||||
| 			return app.postMessage({message: 'tfrpc', method: prop, params: [...arguments], id: id}).then(x => promise); | 			return app | ||||||
|  | 				.postMessage({ | ||||||
|  | 					message: 'tfrpc', | ||||||
|  | 					method: prop, | ||||||
|  | 					params: [...arguments], | ||||||
|  | 					id: id, | ||||||
|  | 				}) | ||||||
|  | 				.then((x) => promise); | ||||||
| 		} | 		} | ||||||
| 	} | 	}; | ||||||
| } | } | ||||||
|  |  | ||||||
| /** | /** | ||||||
| @@ -63,16 +77,22 @@ function call_rpc(message) { | |||||||
| 			let method = g_api[message.method]; | 			let method = g_api[message.method]; | ||||||
| 			if (method) { | 			if (method) { | ||||||
| 				try { | 				try { | ||||||
| 					Promise.resolve(method(...message.params)).then(function(result) { | 					Promise.resolve(method(...message.params)) | ||||||
| 						send({message: 'tfrpc', id: id, result: result}); | 						.then(function (result) { | ||||||
| 					}).catch(function(error) { | 							send({message: 'tfrpc', id: id, result: result}); | ||||||
| 						send({message: 'tfrpc', id: id, error: error}); | 						}) | ||||||
| 					}); | 						.catch(function (error) { | ||||||
|  | 							send({message: 'tfrpc', id: id, error: error}); | ||||||
|  | 						}); | ||||||
| 				} catch (error) { | 				} catch (error) { | ||||||
| 					send({message: 'tfrpc', id: id, error: error}); | 					send({message: 'tfrpc', id: id, error: error}); | ||||||
| 				} | 				} | ||||||
| 			} else { | 			} else { | ||||||
| 				send({message: 'tfrpc', id: id, error: `Method '${message.method}' not found.`}); | 				send({ | ||||||
|  | 					message: 'tfrpc', | ||||||
|  | 					id: id, | ||||||
|  | 					error: `Method '${message.method}' not found.`, | ||||||
|  | 				}); | ||||||
| 			} | 			} | ||||||
| 		} else if (message.error !== undefined) { | 		} else if (message.error !== undefined) { | ||||||
| 			if (g_calls[id]) { | 			if (g_calls[id]) { | ||||||
| @@ -93,11 +113,11 @@ function call_rpc(message) { | |||||||
| } | } | ||||||
|  |  | ||||||
| if (k_is_browser) { | if (k_is_browser) { | ||||||
| 	window.addEventListener('message', function(event) { | 	window.addEventListener('message', function (event) { | ||||||
| 		call_rpc(event.data); | 		call_rpc(event.data); | ||||||
| 	}); | 	}); | ||||||
| } else { | } else { | ||||||
| 	core.register('message', function(message) { | 	core.register('message', function (message) { | ||||||
| 		call_rpc(message?.message); | 		call_rpc(message?.message); | ||||||
| 	}); | 	}); | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										109
									
								
								docs/guide.md
									
									
									
									
									
								
							
							
						
						
									
										109
									
								
								docs/guide.md
									
									
									
									
									
								
							| @@ -5,7 +5,7 @@ Tilde Friends is a platform for making, running, and sharing web applications. | |||||||
| When you visit Tilde Friends in a web browser, you are presented with a | When you visit Tilde Friends in a web browser, you are presented with a | ||||||
| terminal interface, typically with a big text output box covering most of the | terminal interface, typically with a big text output box covering most of the | ||||||
| page and an input box at the bottom, into which text or commands can be | page and an input box at the bottom, into which text or commands can be | ||||||
| entered.  A script runs to produce text output and consume user input. | entered. A script runs to produce text output and consume user input. | ||||||
|  |  | ||||||
| The script is a Tilde Friends application, and it runs on the server, which | The script is a Tilde Friends application, and it runs on the server, which | ||||||
| means that unlike client-side JavaScript, it can have the ability to read and | means that unlike client-side JavaScript, it can have the ability to read and | ||||||
| @@ -21,7 +21,7 @@ and run. | |||||||
| # Architecture | # Architecture | ||||||
|  |  | ||||||
| Tilde Friends is a C++ application with a JavaScript runtime that provides | Tilde Friends is a C++ application with a JavaScript runtime that provides | ||||||
| restricted access to filesystem, network, and other system resources.  The core | restricted access to filesystem, network, and other system resources. The core | ||||||
| process runs a core set of scripts that implement a web server, typically | process runs a core set of scripts that implement a web server, typically | ||||||
| starting a new process for each visitor's session which runs scripts for the | starting a new process for each visitor's session which runs scripts for the | ||||||
| active application and stopping it when the visitor leaves. | active application and stopping it when the visitor leaves. | ||||||
| @@ -42,38 +42,38 @@ interact with the world. | |||||||
|  |  | ||||||
| There are several distinct classes of APIs. | There are several distinct classes of APIs. | ||||||
|  |  | ||||||
| First, there are low-level functions exposed from C++ to JavaScript.  Most of | First, there are low-level functions exposed from C++ to JavaScript. Most of | ||||||
| these are only available to the core process.  These typically only go through | these are only available to the core process. These typically only go through | ||||||
| a basic JavaScript to C++ transition and are relatively fast and immediate. | a basic JavaScript to C++ transition and are relatively fast and immediate. | ||||||
|  |  | ||||||
| 	// Displays some text to the server's console. |     // Displays some text to the server's console. | ||||||
| 	print("Hello, world!"); |     print("Hello, world!"); | ||||||
|  |  | ||||||
| There is a mechanism for communicating between processes.  Functions can be | There is a mechanism for communicating between processes. Functions can be | ||||||
| exported and called across process boundaries.  When this is done, any | exported and called across process boundaries. When this is done, any | ||||||
| arguments are serialized to a network protocol, deserialized by the other | arguments are serialized to a network protocol, deserialized by the other | ||||||
| process, the function called, and finally any return value is passed back in | process, the function called, and finally any return value is passed back in | ||||||
| the same way.  Any functions referenced by the arguments or return value are | the same way. Any functions referenced by the arguments or return value are | ||||||
| also exported and can be subsequently called across process boundaries. | also exported and can be subsequently called across process boundaries. | ||||||
| Functions called across process boundaries are always asynchronous, returning a | Functions called across process boundaries are always asynchronous, returning a | ||||||
| Promise.  Care must be taken for security reasons to not pass dangerous | Promise. Care must be taken for security reasons to not pass dangerous | ||||||
| functions ("deleteAllMydata()") to untrusted processes, and it is best for | functions ("deleteAllMydata()") to untrusted processes, and it is best for | ||||||
| performance reasons to minimize the data size transferred between processes. | performance reasons to minimize the data size transferred between processes. | ||||||
|  |  | ||||||
| 	// Send an "add" function to any other running processes.  When called, it |     // Send an "add" function to any other running processes.  When called, it | ||||||
| 	// will run in this process. |     // will run in this process. | ||||||
| 	core.broadcast({add: function(x, y) { return x + y; }}); |     core.broadcast({add: function(x, y) { return x + y; }}); | ||||||
|  |  | ||||||
| 	// Receive the above message and call the function. |     // Receive the above message and call the function. | ||||||
| 	core.register("onMessage", function(sender, message) { |     core.register("onMessage", function(sender, message) { | ||||||
| 		message.add(3, 4).then(x => terminal.print(x.toString())); |     	message.add(3, 4).then(x => terminal.print(x.toString())); | ||||||
| 	}); |     }); | ||||||
|  |  | ||||||
| Finally, there is a core web interface that runs on the client's browser that | Finally, there is a core web interface that runs on the client's browser that | ||||||
| extends access to a running Tilde Friends script. | extends access to a running Tilde Friends script. | ||||||
|  |  | ||||||
| 	// Displays a message in the client's browser. |     // Displays a message in the client's browser. | ||||||
| 	terminal.print("Hello, world!"); |     terminal.print("Hello, world!"); | ||||||
|  |  | ||||||
| ## API Documentation | ## API Documentation | ||||||
|  |  | ||||||
| @@ -89,80 +89,96 @@ Higher-level behaviors are often implemented within library-style apps | |||||||
| themselves and are beyond the scope of this document. | themselves and are beyond the scope of this document. | ||||||
|  |  | ||||||
| ### Terminal | ### Terminal | ||||||
| All interaction with a human user is through a terminal-like interface.  Though |  | ||||||
|  | All interaction with a human user is through a terminal-like interface. Though | ||||||
| it is somewhat limiting, it makes simple things easy, and it is possible to | it is somewhat limiting, it makes simple things easy, and it is possible to | ||||||
| construct complicated interfaces by creating and interacting with an iframe. | construct complicated interfaces by creating and interacting with an iframe. | ||||||
|  |  | ||||||
| #### terminal.print(arguments...) | #### terminal.print(arguments...) | ||||||
| Print to the terminal.  Arguments and lists are recursively expanded.  Numerous |  | ||||||
|  | Print to the terminal. Arguments and lists are recursively expanded. Numerous | ||||||
| special values are supported as implemented in client.cs. | special values are supported as implemented in client.cs. | ||||||
|  |  | ||||||
| 	// Create a link. |     // Create a link. | ||||||
| 	terminal.print({href: "http://www.tildefriends.net/", value: "Tilde Friends!"}); |     terminal.print({href: "http://www.tildefriends.net/", value: "Tilde Friends!"}); | ||||||
|  |  | ||||||
| 	// Create an iframe. |     // Create an iframe. | ||||||
| 	terminal.print({iframe: "<b>Hello, world!</b>", width: 640, height: 480}); |     terminal.print({iframe: "<b>Hello, world!</b>", width: 640, height: 480}); | ||||||
|  |  | ||||||
| 	// Use style. |     // Use style. | ||||||
| 	terminal.print({style: "color: #f00", value: "Hello, world!"}); |     terminal.print({style: "color: #f00", value: "Hello, world!"}); | ||||||
|  |  | ||||||
| 	// Create a link that when clicked will act as if the user typed a command. |     // Create a link that when clicked will act as if the user typed a command. | ||||||
| 	terminal.print({command: "exit", value: "Get out of here."}); |     terminal.print({command: "exit", value: "Get out of here."}); | ||||||
|  |  | ||||||
| #### terminal.clear() | #### terminal.clear() | ||||||
|  |  | ||||||
| Clears the terminal output. | Clears the terminal output. | ||||||
|  |  | ||||||
| #### terminal.readLine() | #### terminal.readLine() | ||||||
|  |  | ||||||
| Read a line of input from the user. | Read a line of input from the user. | ||||||
|  |  | ||||||
| #### terminal.setEcho(echo) | #### terminal.setEcho(echo) | ||||||
| Controls whether the terminal will automatically echo user input.  Defaults to true. |  | ||||||
|  | Controls whether the terminal will automatically echo user input. Defaults to true. | ||||||
|  |  | ||||||
| #### terminal.setPrompt(prompt) | #### terminal.setPrompt(prompt) | ||||||
| Sets the terminal prompt.  The default is ">". |  | ||||||
|  | Sets the terminal prompt. The default is ">". | ||||||
|  |  | ||||||
| #### terminal.setTitle(title) | #### terminal.setTitle(title) | ||||||
|  |  | ||||||
| Sets the browser window/tab title. | Sets the browser window/tab title. | ||||||
|  |  | ||||||
| #### terminal.split(terminalList) | #### terminal.split(terminalList) | ||||||
|  |  | ||||||
| Reconfigures the terminal layout, potentially into multiple split panes. | Reconfigures the terminal layout, potentially into multiple split panes. | ||||||
|  |  | ||||||
| 	terminal.split([ |     terminal.split([ | ||||||
| 		{ |     	{ | ||||||
| 			type: "horizontal", |     		type: "horizontal", | ||||||
| 			children: [ |     		children: [ | ||||||
| 				{name: "left", basis: "2in", grow: 0, shrink: 0}, |     			{name: "left", basis: "2in", grow: 0, shrink: 0}, | ||||||
| 				{name: "middle", grow: 1}, |     			{name: "middle", grow: 1}, | ||||||
| 				{name: "right", basis: "2in", grow: 0, shrink: 0}, |     			{name: "right", basis: "2in", grow: 0, shrink: 0}, | ||||||
| 			], |     		], | ||||||
| 		}, |     	}, | ||||||
| 	]); |     ]); | ||||||
|  |  | ||||||
| #### terminal.select(name) | #### terminal.select(name) | ||||||
|  |  | ||||||
| Directs subsequent output to the named terminal. | Directs subsequent output to the named terminal. | ||||||
|  |  | ||||||
| #### terminal.postMessageToIframe(iframeName, message) | #### terminal.postMessageToIframe(iframeName, message) | ||||||
|  |  | ||||||
| Sends a message to the iframe that was created with the given name, using the | Sends a message to the iframe that was created with the given name, using the | ||||||
| browser's window.postMessage. | browser's window.postMessage. | ||||||
|  |  | ||||||
| ### Database | ### Database | ||||||
| Tilde Friends uses lmdb as a basic key value store.  Keys and values are all |  | ||||||
| expected to be of type String.  Each application gets its own isolated | Tilde Friends uses lmdb as a basic key value store. Keys and values are all | ||||||
|  | expected to be of type String. Each application gets its own isolated | ||||||
| database. | database. | ||||||
|  |  | ||||||
| #### database.get(key) | #### database.get(key) | ||||||
|  |  | ||||||
| Retrieve the database value associated with the given key. | Retrieve the database value associated with the given key. | ||||||
|  |  | ||||||
| #### database.set(key, value) | #### database.set(key, value) | ||||||
|  |  | ||||||
| Sets the database value for the given key, overwriting any existing value. | Sets the database value for the given key, overwriting any existing value. | ||||||
|  |  | ||||||
| #### database.remove(key) | #### database.remove(key) | ||||||
|  |  | ||||||
| Remove the database entry for the given key. | Remove the database entry for the given key. | ||||||
|  |  | ||||||
| #### database.getAlll() | #### database.getAlll() | ||||||
|  |  | ||||||
| Retrieve a list of all key names. | Retrieve a list of all key names. | ||||||
|  |  | ||||||
| ### Network | ### Network | ||||||
|  |  | ||||||
| Network access is generally not extended to untrusted users. | Network access is generally not extended to untrusted users. | ||||||
|  |  | ||||||
| It is necessary to grant network permissions to an app owner through the | It is necessary to grant network permissions to an app owner through the | ||||||
| @@ -170,19 +186,24 @@ administration app. | |||||||
|  |  | ||||||
| Apps that require network access must declare it like this: | Apps that require network access must declare it like this: | ||||||
|  |  | ||||||
| 	//! { "permissions": ["network"] } |     //! { "permissions": ["network"] } | ||||||
|  |  | ||||||
| #### network.newConnection() | #### network.newConnection() | ||||||
|  |  | ||||||
| Creates a Connection object. | Creates a Connection object. | ||||||
|  |  | ||||||
| #### connection.connect(host, port) | #### connection.connect(host, port) | ||||||
|  |  | ||||||
| Opens a TCP connection to host:port. | Opens a TCP connection to host:port. | ||||||
|  |  | ||||||
| #### connection.read(readCallback) | #### connection.read(readCallback) | ||||||
|  |  | ||||||
| Begins reading and calls readCallback(data) for all data received. | Begins reading and calls readCallback(data) for all data received. | ||||||
|  |  | ||||||
| #### connection.write(data) | #### connection.write(data) | ||||||
|  |  | ||||||
| Writes data to the connection. | Writes data to the connection. | ||||||
|  |  | ||||||
| #### connection.close() | #### connection.close() | ||||||
|  |  | ||||||
| Closes the connection. | Closes the connection. | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user