forked from cory/tildefriends
		
	ssblit -> ssb. Let's finally get rid of the old thing.
git-svn-id: https://www.unprompted.com/svn/projects/tildefriends/trunk@4080 ed5197a5-7fde-0310-b194-c3ffbd925b24
This commit is contained in:
		
							
								
								
									
										97
									
								
								apps/cory/ssb/app.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								apps/cory/ssb/app.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,97 @@ | ||||
| import * as tfrpc from '/tfrpc.js'; | ||||
|  | ||||
| let g_database; | ||||
| let g_hash; | ||||
|  | ||||
| tfrpc.register(async function localStorageGet(key) { | ||||
| 	return app.localStorageGet(key); | ||||
| }); | ||||
| tfrpc.register(async function localStorageSet(key, value) { | ||||
| 	return app.localStorageSet(key, value); | ||||
| }); | ||||
| tfrpc.register(async function databaseGet(key) { | ||||
| 	return g_database ? g_database.get(key) : undefined; | ||||
| }); | ||||
| tfrpc.register(async function databaseSet(key, value) { | ||||
| 	return g_database ? g_database.set(key, value) : undefined; | ||||
| }); | ||||
| tfrpc.register(async function createIdentity() { | ||||
| 	return ssb.createIdentity(); | ||||
| }); | ||||
| tfrpc.register(async function getIdentities() { | ||||
| 	return ssb.getIdentities(); | ||||
| }); | ||||
| tfrpc.register(async function getAllIdentities() { | ||||
| 	return ssb.getAllIdentities(); | ||||
| }); | ||||
| tfrpc.register(async function getBroadcasts() { | ||||
| 	return ssb.getBroadcasts(); | ||||
| }); | ||||
| tfrpc.register(async function getConnections() { | ||||
| 	return ssb.connections(); | ||||
| }); | ||||
| tfrpc.register(async function connectionSendJson(id, message) { | ||||
| 	return ssb.connectionSendJson(id, message); | ||||
| }); | ||||
| tfrpc.register(async function createTunnel(portal, request_number, target) { | ||||
| 	let t = ssb.createTunnel(portal, request_number, target); | ||||
| 	return t; | ||||
| }); | ||||
| tfrpc.register(async function connect(token) { | ||||
| 	await ssb.connect(token); | ||||
| }); | ||||
| tfrpc.register(async function closeConnection(id) { | ||||
| 	await ssb.closeConnection(id); | ||||
| }); | ||||
| tfrpc.register(async function query(sql, args) { | ||||
| 	let result = []; | ||||
| 	await ssb.sqlStream(sql, args, function callback(row) { | ||||
| 		result.push(row); | ||||
| 	}); | ||||
| 	return result; | ||||
| }); | ||||
| tfrpc.register(async function appendMessage(id, message) { | ||||
| 	return ssb.appendMessageWithIdentity(id, message); | ||||
| }); | ||||
| core.register('message', async function message_handler(message) { | ||||
| 	if (message.event == 'hashChange') { | ||||
| 		g_hash = message.hash; | ||||
| 		await tfrpc.rpc.hashChanged(message.hash); | ||||
| 	} | ||||
| }); | ||||
| tfrpc.register(function getHash(id, message) { | ||||
| 	return g_hash; | ||||
| }); | ||||
| tfrpc.register(function setHash(hash) { | ||||
| 	return app.setHash(hash); | ||||
| }); | ||||
| ssb.addEventListener('message', async function(id) { | ||||
| 	await tfrpc.rpc.notifyNewMessage(id); | ||||
| }); | ||||
| tfrpc.register(async function store_blob(blob) { | ||||
| 	if (Array.isArray(blob)) { | ||||
| 		blob = Uint8Array.from(blob); | ||||
| 	} | ||||
| 	return await ssb.blobStore(blob); | ||||
| }); | ||||
| tfrpc.register(async function get_blob(id) { | ||||
| 	return utf8Decode(await ssb.blobGet(id)); | ||||
| }); | ||||
| tfrpc.register(function apps() { | ||||
| 	return core.apps(); | ||||
| }); | ||||
| ssb.addEventListener('broadcasts', async function() { | ||||
| 	await tfrpc.rpc.set('broadcasts', await ssb.getBroadcasts()); | ||||
| }); | ||||
|  | ||||
| core.register('onConnectionsChanged', async function() { | ||||
| 	await tfrpc.rpc.set('connections', await ssb.connections()); | ||||
| }); | ||||
|  | ||||
| async function main() { | ||||
| 	if (typeof(database) !== 'undefined') { | ||||
| 		g_database = await database('ssb'); | ||||
| 	} | ||||
| 	await app.setDocument(utf8Decode(await getFile('index.html'))); | ||||
| } | ||||
| main(); | ||||
							
								
								
									
										90
									
								
								apps/cory/ssb/commonmark-hashtag.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										90
									
								
								apps/cory/ssb/commonmark-hashtag.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,90 @@ | ||||
| function textNode(text) { | ||||
|   const node = new commonmark.Node("text", undefined); | ||||
|   node.literal = text; | ||||
|   return node; | ||||
| } | ||||
|  | ||||
| function linkNode(text, link) { | ||||
|   const linkNode = new commonmark.Node("link", undefined); | ||||
|   linkNode.destination = `#q=${encodeURIComponent(link)}`; | ||||
|   linkNode.appendChild(textNode(text)); | ||||
|   return linkNode; | ||||
| } | ||||
|  | ||||
| function splitMatches(text, regexp) { | ||||
|   // Regexp must be sticky. | ||||
|   regexp = new RegExp(regexp, "gm"); | ||||
|  | ||||
|   let i = 0; | ||||
|   const result = []; | ||||
|  | ||||
|   let match = regexp.exec(text); | ||||
|   while (match) { | ||||
|     const matchText = match[0]; | ||||
|  | ||||
|     if (match.index > i) { | ||||
|       result.push([text.substring(i, match.index), false]); | ||||
|     } | ||||
|  | ||||
|     result.push([matchText, true]); | ||||
|     i = match.index + matchText.length; | ||||
|  | ||||
|     match = regexp.exec(text); | ||||
|   } | ||||
|  | ||||
|   if (i < text.length) { | ||||
|     result.push([text.substring(i, text.length), false]); | ||||
|   } | ||||
|  | ||||
|   return result; | ||||
| } | ||||
|  | ||||
| const regex = new RegExp("#[\\w-]+"); | ||||
|  | ||||
| function split(textNodes) { | ||||
|   const text = textNodes.map(n => n.literal).join(""); | ||||
|   const parts = splitMatches(text, regex); | ||||
|  | ||||
|   return parts.map(part => { | ||||
|     if (part[1]) { | ||||
|       return linkNode(part[0], part[0]); | ||||
|     } else { | ||||
|       return textNode(part[0]); | ||||
|     } | ||||
|   }); | ||||
| } | ||||
|  | ||||
| export function transform(parsed) { | ||||
|   const walker = parsed.walker(); | ||||
|   let event; | ||||
|  | ||||
|   let nodes = []; | ||||
|   while ((event = walker.next())) { | ||||
|     const node = event.node; | ||||
|     if (event.entering && node.type === "text") { | ||||
|       nodes.push(node); | ||||
|     } else { | ||||
|       if (nodes.length > 0) { | ||||
|         split(nodes) | ||||
|           .reverse() | ||||
|           .forEach(newNode => { | ||||
|             nodes[0].insertAfter(newNode); | ||||
|           }); | ||||
|  | ||||
|         nodes.forEach(n => n.unlink()); | ||||
|         nodes = []; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   if (nodes.length > 0) { | ||||
|     split(nodes) | ||||
|       .reverse() | ||||
|       .forEach(newNode => { | ||||
|         nodes[0].insertAfter(newNode); | ||||
|       }); | ||||
|     nodes.forEach(n => n.unlink()); | ||||
|   } | ||||
|  | ||||
|   return parsed; | ||||
| } | ||||
							
								
								
									
										91
									
								
								apps/cory/ssb/commonmark-linkify.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								apps/cory/ssb/commonmark-linkify.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,91 @@ | ||||
| function textNode(text) { | ||||
|   const node = new commonmark.Node("text", undefined); | ||||
|   node.literal = text; | ||||
|   return node; | ||||
| } | ||||
|  | ||||
| function linkNode(text, url) { | ||||
|   const urlNode = new commonmark.Node("link", undefined); | ||||
|   urlNode.destination = url; | ||||
|   urlNode.appendChild(textNode(text)); | ||||
|  | ||||
|   return urlNode; | ||||
| } | ||||
|  | ||||
| function splitMatches(text, regexp) { | ||||
|   // Regexp must be sticky. | ||||
|   regexp = new RegExp(regexp, "gm"); | ||||
|  | ||||
|   let i = 0; | ||||
|   const result = []; | ||||
|  | ||||
|   let match = regexp.exec(text); | ||||
|   while (match) { | ||||
|     const matchText = match[0]; | ||||
|  | ||||
|     if (match.index > i) { | ||||
|       result.push([text.substring(i, match.index), false]); | ||||
|     } | ||||
|  | ||||
|     result.push([matchText, true]); | ||||
|     i = match.index + matchText.length; | ||||
|  | ||||
|     match = regexp.exec(text); | ||||
|   } | ||||
|  | ||||
|   if (i < text.length) { | ||||
|     result.push([text.substring(i, text.length), false]); | ||||
|   } | ||||
|  | ||||
|   return result; | ||||
| } | ||||
|  | ||||
| const urlRegexp = new RegExp("https?://[^ ]+[^ .,]"); | ||||
|  | ||||
| function splitURLs(textNodes) { | ||||
|   const text = textNodes.map(n => n.literal).join(""); | ||||
|   const parts = splitMatches(text, urlRegexp); | ||||
|  | ||||
|   return parts.map(part => { | ||||
|     if (part[1]) { | ||||
|       return linkNode(part[0], part[0]); | ||||
|     } else { | ||||
|       return textNode(part[0]); | ||||
|     } | ||||
|   }); | ||||
| } | ||||
|  | ||||
| export function transform(parsed) { | ||||
|   const walker = parsed.walker(); | ||||
|   let event; | ||||
|  | ||||
|   let nodes = []; | ||||
|   while ((event = walker.next())) { | ||||
|     const node = event.node; | ||||
|     if (event.entering && node.type === "text") { | ||||
|       nodes.push(node); | ||||
|     } else { | ||||
|       if (nodes.length > 0) { | ||||
|         splitURLs(nodes) | ||||
|           .reverse() | ||||
|           .forEach(newNode => { | ||||
|             nodes[0].insertAfter(newNode); | ||||
|           }); | ||||
|  | ||||
|         nodes.forEach(n => n.unlink()); | ||||
|         nodes = []; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   if (nodes.length > 0) { | ||||
|     splitURLs(nodes) | ||||
|       .reverse() | ||||
|       .forEach(newNode => { | ||||
|         nodes[0].insertAfter(newNode); | ||||
|       }); | ||||
|     nodes.forEach(n => n.unlink()); | ||||
|   } | ||||
|  | ||||
|   return parsed; | ||||
| } | ||||
							
								
								
									
										1
									
								
								apps/cory/ssb/commonmark.min.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								apps/cory/ssb/commonmark.min.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										85
									
								
								apps/cory/ssb/emojis.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								apps/cory/ssb/emojis.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,85 @@ | ||||
| let g_emojis; | ||||
|  | ||||
| function get_emojis() { | ||||
| 	if (g_emojis) { | ||||
| 		return Promise.resolve(g_emojis); | ||||
| 	} | ||||
| 	return fetch('emojis.json').then(function(result) { | ||||
| 		g_emojis = result.json(); | ||||
| 		return g_emojis; | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| export function picker(callback, anchor) { | ||||
| 	get_emojis().then(function(json) { | ||||
| 		let existing = document.getElementById('emoji_picker'); | ||||
| 		if (existing) { | ||||
| 			existing.parentElement.removeChild(existing); | ||||
| 			return; | ||||
| 		} | ||||
| 		let div = document.createElement('div'); | ||||
| 		div.id = 'emoji_picker'; | ||||
| 		div.style.color = '#000'; | ||||
| 		div.style.background = '#fff'; | ||||
| 		div.style.border = '1px solid #000'; | ||||
| 		div.style.display = 'block'; | ||||
| 		div.style.position = 'absolute'; | ||||
| 		div.style.maxWidth = '16em'; | ||||
| 		div.style.maxHeight = '16em'; | ||||
| 		div.style.overflow = 'scroll'; | ||||
| 		div.style.fontWeight = 'bold'; | ||||
| 		let input = document.createElement('input'); | ||||
| 		input.type = 'text'; | ||||
| 		div.appendChild(input); | ||||
| 		let list = document.createElement('div'); | ||||
| 		div.appendChild(list); | ||||
| 		function refresh() { | ||||
| 			while (list.firstChild) { | ||||
| 				list.removeChild(list.firstChild); | ||||
| 			} | ||||
| 			let search = input.value; | ||||
| 			Object.entries(json).forEach(function(row) { | ||||
| 				let header = document.createElement('div'); | ||||
| 				header.appendChild(document.createTextNode(row[0])); | ||||
| 				list.appendChild(header); | ||||
| 				let any = false; | ||||
| 				for (let entry of row[1]) { | ||||
| 					if (search && | ||||
| 						search.length && | ||||
| 						entry.name.indexOf(search) == -1) { | ||||
| 						continue; | ||||
| 					} | ||||
| 					let emoji = document.createElement('span'); | ||||
| 					const k_size = '1.25em'; | ||||
| 					emoji.style.width = k_size; | ||||
| 					emoji.style.maxWidth = k_size; | ||||
| 					emoji.style.minWidth = k_size; | ||||
| 					emoji.style.height = k_size; | ||||
| 					emoji.style.maxHeight = k_size; | ||||
| 					emoji.style.minHeight = k_size; | ||||
| 					emoji.style.display = 'inline-block'; | ||||
| 					emoji.style.overflow = 'hidden'; | ||||
| 					emoji.style.cursor = 'pointer'; | ||||
| 					emoji.onclick = function() { | ||||
| 						callback(entry); | ||||
| 						div.parentElement.removeChild(div); | ||||
| 					} | ||||
| 					emoji.title = entry.name; | ||||
| 					emoji.appendChild(document.createTextNode(entry.emoji)); | ||||
| 					list.appendChild(emoji); | ||||
| 					any = true; | ||||
| 				} | ||||
| 				if (!any) { | ||||
| 					list.removeChild(header); | ||||
| 				} | ||||
| 			}); | ||||
| 		} | ||||
| 		refresh(); | ||||
| 		input.oninput = refresh; | ||||
| 		document.body.appendChild(div); | ||||
| 		div.style.position = 'fixed'; | ||||
| 		div.style.top = '50%'; | ||||
| 		div.style.left = '50%'; | ||||
| 		div.style.transform = 'translate(-50%, -50%)'; | ||||
| 	}); | ||||
| } | ||||
							
								
								
									
										15115
									
								
								apps/cory/ssb/emojis.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15115
									
								
								apps/cory/ssb/emojis.json
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										21
									
								
								apps/cory/ssb/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								apps/cory/ssb/index.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| <!DOCTYPE html> | ||||
| <html style="color: #fff"> | ||||
| 	<head> | ||||
| 		<title>Tilde Friends</title> | ||||
| 		<base target="_top"> | ||||
| 		<link rel="stylesheet" href="tribute.css" /> | ||||
| 		<style> | ||||
| 			.tribute-container { | ||||
| 				color: #000; | ||||
| 			} | ||||
| 		</style> | ||||
| 	</head> | ||||
| 	<body> | ||||
| 		<tf-app/> | ||||
| 		<script>window.litDisableBundleWarning = true;</script> | ||||
| 		<script src="commonmark.min.js"></script> | ||||
| 		<script src="commonmark-linkify.js" type="module"></script> | ||||
| 		<script src="commonmark-hashtag.js" type="module"></script> | ||||
| 		<script src="script.js" type="module"></script> | ||||
| 	</body> | ||||
| </html> | ||||
							
								
								
									
										132
									
								
								apps/cory/ssb/lit-all.min.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								apps/cory/ssb/lit-all.min.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										1
									
								
								apps/cory/ssb/lit-all.min.js.map
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								apps/cory/ssb/lit-all.min.js.map
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										13
									
								
								apps/cory/ssb/script.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								apps/cory/ssb/script.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| import {LitElement, html} from './lit-all.min.js'; | ||||
| import * as tfrpc from '/static/tfrpc.js'; | ||||
|  | ||||
| import * as tf_id_picker from './tf-id-picker.js'; | ||||
| import * as tf_app from './tf-app.js'; | ||||
| import * as tf_message from './tf-message.js'; | ||||
| import * as tf_user from './tf-user.js'; | ||||
| import * as tf_compose from './tf-compose.js'; | ||||
| import * as tf_news from './tf-news.js'; | ||||
| import * as tf_profile from './tf-profile.js'; | ||||
| import * as tf_tab_news from './tf-tab-news.js'; | ||||
| import * as tf_tab_search from './tf-tab-search.js'; | ||||
| import * as tf_tab_connections from './tf-tab-connections.js'; | ||||
							
								
								
									
										330
									
								
								apps/cory/ssb/tf-app.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										330
									
								
								apps/cory/ssb/tf-app.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,330 @@ | ||||
| import {LitElement, html, css, guard, until} from './lit-all.min.js'; | ||||
| import * as tfrpc from '/static/tfrpc.js'; | ||||
| import {styles} from './tf-styles.js'; | ||||
|  | ||||
| class TfElement extends LitElement { | ||||
| 	static get properties() { | ||||
| 		return { | ||||
| 			whoami: {type: String}, | ||||
| 			hash: {type: String}, | ||||
| 			unread: {type: Array}, | ||||
| 			tab: {type: String}, | ||||
| 			broadcasts: {type: Array}, | ||||
| 			connections: {type: Array}, | ||||
| 			loading: {type: Boolean}, | ||||
| 			loaded: {type: Boolean}, | ||||
| 			following: {type: Array}, | ||||
| 			users: {type: Object}, | ||||
| 			ids: {type: Array}, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	static styles = styles; | ||||
|  | ||||
| 	constructor() { | ||||
| 		super(); | ||||
| 		let self = this; | ||||
| 		this.hash = '#'; | ||||
| 		this.unread = []; | ||||
| 		this.tab = 'news'; | ||||
| 		this.broadcasts = []; | ||||
| 		this.connections = []; | ||||
| 		this.following = []; | ||||
| 		this.users = {}; | ||||
| 		this.loaded = false; | ||||
| 		tfrpc.rpc.getBroadcasts().then(b => { self.broadcasts = b || [] }); | ||||
| 		tfrpc.rpc.getConnections().then(c => { self.connections = c || [] }); | ||||
| 		tfrpc.rpc.getHash().then(hash => self.set_hash(hash)); | ||||
| 		tfrpc.register(function hashChanged(hash) { | ||||
| 			self.set_hash(hash); | ||||
| 		}); | ||||
| 		tfrpc.register(async function notifyNewMessage(id) { | ||||
| 			await self.fetch_new_message(id); | ||||
| 		}); | ||||
| 		tfrpc.register(function set(name, value) { | ||||
| 			if (name === 'broadcasts') { | ||||
| 				self.broadcasts = value; | ||||
| 			} else if (name === 'connections') { | ||||
| 				self.connections = value; | ||||
| 			} | ||||
| 		}); | ||||
| 		this.initial_load(); | ||||
| 	} | ||||
|  | ||||
| 	async initial_load() { | ||||
| 		let whoami = await tfrpc.rpc.localStorageGet('whoami'); | ||||
| 		let ids = (await tfrpc.rpc.getIdentities()) || []; | ||||
| 		this.whoami = whoami ?? (ids.length ? ids[0] : undefined); | ||||
| 		this.ids = ids; | ||||
| 	} | ||||
|  | ||||
| 	set_hash(hash) { | ||||
| 		this.hash = hash || '#'; | ||||
| 		if (this.hash.startsWith('#q=')) { | ||||
| 			this.tab = 'search'; | ||||
| 		} else if (this.hash === '#connections') { | ||||
| 			this.tab = 'connections'; | ||||
| 		} else { | ||||
| 			this.tab = 'news'; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async contacts_internal(id, last_row_id, following, max_row_id) { | ||||
| 		let result = Object.assign({}, following[id] || {}); | ||||
| 		result.following = result.following || {}; | ||||
| 		result.blocking = result.blocking || {}; | ||||
| 		let contacts = await tfrpc.rpc.query( | ||||
| 			` | ||||
| 				SELECT content FROM messages | ||||
| 				WHERE author = ? AND | ||||
| 				rowid > ? AND | ||||
| 				rowid <= ? AND | ||||
| 				json_extract(content, "$.type") = "contact" | ||||
| 				ORDER BY sequence | ||||
| 			`, | ||||
| 			[id, last_row_id, max_row_id]); | ||||
| 		for (let row of contacts) { | ||||
| 			let contact = JSON.parse(row.content); | ||||
| 			if (contact.following === true) { | ||||
| 				result.following[contact.contact] = true; | ||||
| 			} else if (contact.following === false) { | ||||
| 				delete result.following[contact.contact]; | ||||
| 			} else if (contact.blocking === true) { | ||||
| 				result.blocking[contact.contact] = true; | ||||
| 			} else if (contact.blocking === false) { | ||||
| 				delete result.blocking[contact.contact]; | ||||
| 			} | ||||
| 		} | ||||
| 		following[id] = result; | ||||
| 		return result; | ||||
| 	} | ||||
|  | ||||
| 	async contact(id, last_row_id, following, max_row_id) { | ||||
| 		return await this.contacts_internal(id, last_row_id, following, max_row_id); | ||||
| 	} | ||||
|  | ||||
| 	async following_deep_internal(ids, depth, blocking, last_row_id, following, max_row_id) { | ||||
| 		let contacts = await Promise.all([...new Set(ids)].map(x => this.contact(x, last_row_id, following, max_row_id))); | ||||
| 		let result = {}; | ||||
| 		for (let i = 0; i < ids.length; i++) { | ||||
| 			let id = ids[i]; | ||||
| 			let contact = contacts[i]; | ||||
| 			let found = Object.keys(contact.following).filter(y => !contact.blocking[y]); | ||||
| 			let deeper = depth > 1 ? await this.following_deep_internal(found, depth - 1, Object.assign({}, contact.blocking, blocking), last_row_id, following, max_row_id) : []; | ||||
| 			result[id] = [id, ...found, ...deeper]; | ||||
| 		} | ||||
| 		return [...new Set(Object.values(result).flat())]; | ||||
| 	} | ||||
|  | ||||
| 	async following_deep(ids, depth, blocking) { | ||||
| 		const k_cache_version = 5; | ||||
| 		let cache = await tfrpc.rpc.databaseGet('following'); | ||||
| 		cache = cache ? JSON.parse(cache) : {}; | ||||
| 		if (cache.version !== k_cache_version) { | ||||
| 			cache = { | ||||
| 				version: k_cache_version, | ||||
| 				following: {}, | ||||
| 				last_row_id: 0, | ||||
| 			}; | ||||
| 		} | ||||
| 		let max_row_id = (await tfrpc.rpc.query(` | ||||
| 			SELECT MAX(rowid) AS max_row_id FROM messages | ||||
| 		`, []))[0].max_row_id; | ||||
| 		let result = await this.following_deep_internal(ids, depth, blocking, cache.last_row_id, cache.following, max_row_id); | ||||
| 		cache.last_row_id = max_row_id; | ||||
| 		await tfrpc.rpc.databaseSet('following', JSON.stringify(cache)); | ||||
| 		return [result, cache.following]; | ||||
| 	} | ||||
|  | ||||
| 	async fetch_about(ids, users) { | ||||
| 		const k_cache_version = 1; | ||||
| 		let cache = await tfrpc.rpc.databaseGet('about'); | ||||
| 		cache = cache ? JSON.parse(cache) : {}; | ||||
| 		if (cache.version !== k_cache_version) { | ||||
| 			cache = { | ||||
| 				version: k_cache_version, | ||||
| 				about: {}, | ||||
| 				last_row_id: 0, | ||||
| 			}; | ||||
| 		} | ||||
| 		let max_row_id = (await tfrpc.rpc.query(` | ||||
| 			SELECT MAX(rowid) AS max_row_id FROM messages | ||||
| 		`, []))[0].max_row_id; | ||||
| 		for (let id of Object.keys(cache.about)) { | ||||
| 			if (ids.indexOf(id) == -1) { | ||||
| 				delete cache.about[id]; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		let abouts = await tfrpc.rpc.query( | ||||
| 			` | ||||
| 				SELECT | ||||
| 					messages.* | ||||
| 				FROM | ||||
| 					messages, | ||||
| 					json_each(?1) AS following | ||||
| 				WHERE | ||||
| 					messages.author = following.value AND | ||||
| 					messages.rowid > ?3 AND | ||||
| 					messages.rowid <= ?4 AND | ||||
| 					json_extract(messages.content, '$.type') = 'about' | ||||
| 				UNION | ||||
| 				SELECT | ||||
| 					messages.* | ||||
| 				FROM | ||||
| 					messages, | ||||
| 					json_each(?2) AS following | ||||
| 				WHERE | ||||
| 					messages.author = following.value AND | ||||
| 					messages.rowid <= ?4 AND | ||||
| 					json_extract(messages.content, '$.type') = 'about' | ||||
| 				ORDER BY messages.author, messages.sequence | ||||
| 			`, | ||||
| 			[ | ||||
| 				JSON.stringify(ids.filter(id => cache.about[id])), | ||||
| 				JSON.stringify(ids.filter(id => !cache.about[id])), | ||||
| 				cache.last_row_id, | ||||
| 				max_row_id, | ||||
| 			]); | ||||
| 		for (let about of abouts) { | ||||
| 			let content = JSON.parse(about.content); | ||||
| 			if (content.about === about.author) { | ||||
| 				delete content.type; | ||||
| 				delete content.about; | ||||
| 				cache.about[about.author] = Object.assign(cache.about[about.author] || {}, content); | ||||
| 			} | ||||
| 		} | ||||
| 		cache.last_row_id = max_row_id; | ||||
| 		await tfrpc.rpc.databaseSet('about', JSON.stringify(cache)); | ||||
| 		users = users || {}; | ||||
| 		for (let id of Object.keys(cache.about)) { | ||||
| 			users[id] = Object.assign(users[id] || {}, cache.about[id]); | ||||
| 		} | ||||
| 		return Object.assign({}, users); | ||||
| 	} | ||||
|  | ||||
| 	async fetch_new_message(id) { | ||||
| 		let messages = await tfrpc.rpc.query( | ||||
| 			` | ||||
| 				SELECT messages.* | ||||
| 				FROM messages | ||||
| 				JOIN json_each(?) AS following ON messages.author = following.value | ||||
| 				WHERE messages.id = ? | ||||
| 			`, | ||||
| 			[ | ||||
| 				JSON.stringify(this.following), | ||||
| 				id, | ||||
| 			]); | ||||
| 		if (messages && messages.length) { | ||||
| 			this.unread = [...this.unread, ...messages]; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async _handle_whoami_changed(event) { | ||||
| 		let old_id = this.whoami; | ||||
| 		let new_id = event.srcElement.selected; | ||||
| 		console.log('received', new_id); | ||||
| 		if (this.whoami !== new_id) { | ||||
| 			console.log(event); | ||||
| 			this.whoami = new_id; | ||||
| 			console.log(`whoami ${old_id} => ${new_id}`); | ||||
| 			await tfrpc.rpc.localStorageSet('whoami', new_id); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async create_identity() { | ||||
| 		if (confirm("Are you sure you want to create a new identity?")) { | ||||
| 			await tfrpc.rpc.createIdentity(); | ||||
| 			this.ids = (await tfrpc.rpc.getIdentities()) || []; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	render_id_picker() { | ||||
| 		return html` | ||||
| 			<tf-id-picker id="picker" selected=${this.whoami} .ids=${this.ids} @change=${this._handle_whoami_changed}></tf-id-picker> | ||||
| 			<button @click=${this.create_identity}>Create Identity</button> | ||||
| 		`; | ||||
| 	} | ||||
|  | ||||
| 	async load() { | ||||
| 		let whoami = this.whoami; | ||||
| 		let [following, users] = await this.following_deep([whoami], 2, {}); | ||||
| 		users = await this.fetch_about(following.sort(), users); | ||||
| 		this.following = following; | ||||
| 		this.users = users; | ||||
| 		console.log(`load finished ${whoami} => ${this.whoami}`); | ||||
| 		this.whoami = whoami; | ||||
| 		this.loaded = whoami; | ||||
| 	} | ||||
|  | ||||
| 	render_tab() { | ||||
| 		let following = this.following; | ||||
| 		let users = this.users; | ||||
| 		if (this.tab === 'news') { | ||||
| 			return html` | ||||
| 				<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') { | ||||
| 			return html` | ||||
| 				<tf-tab-connections .users=${this.users} .connections=${this.connections} .broadcasts=${this.broadcasts}></tf-tab-connections> | ||||
| 			`; | ||||
| 		} else if (this.tab === 'search') { | ||||
| 			return html` | ||||
| 				<tf-tab-search .following=${this.following} whoami=${this.whoami} .users=${this.users} query=${this.hash?.startsWith('#q=') ? decodeURIComponent(this.hash.substring(3)) : null}></tf-tab-search> | ||||
| 			`; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	add_fake_news() { | ||||
| 		this.unread = [{ | ||||
| 			author: this.whoami, | ||||
| 			placeholder: true, | ||||
| 			id: '%fake_id', | ||||
| 			text: 'text', | ||||
| 			content: 'hello', | ||||
| 		}, ...this.unread]; | ||||
| 	} | ||||
|  | ||||
| 	async set_tab(tab) { | ||||
| 		this.tab = tab; | ||||
| 		if (tab === 'news') { | ||||
| 			await tfrpc.rpc.setHash('#'); | ||||
| 		} else if (tab === 'connections') { | ||||
| 			await tfrpc.rpc.setHash('#connections'); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	render() { | ||||
| 		let self = this; | ||||
|  | ||||
| 		if (!this.loading && this.whoami && this.loaded !== this.whoami) { | ||||
| 			console.log(`starting loading ${this.whoami} ${this.loaded}`); | ||||
| 			this.loading = true; | ||||
| 			this.load().finally(function() { | ||||
| 				self.loading = false; | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		let tabs = html` | ||||
| 			<div> | ||||
| 				<input type="button" class="tab" value="News" ?disabled=${self.tab == 'news'} @click=${() => self.set_tab('news')}></input> | ||||
| 				<input type="button" class="tab" value="Connections" ?disabled=${self.tab == 'connections'} @click=${() => self.set_tab('connections')}></input> | ||||
| 				<input type="button" class="tab" value="Search" ?disabled=${self.tab == 'search'} @click=${() => self.set_tab('search')}></input> | ||||
| 			</div> | ||||
| 		`; | ||||
| 		let contents = | ||||
| 				!this.loaded ? | ||||
| 					this.loading ? | ||||
| 						html`<div>Loading...</div>` : | ||||
| 						html`<div>Select or create an identity.</div>` : | ||||
| 					this.render_tab(); | ||||
| 		return html` | ||||
| 			${this.render_id_picker()} | ||||
| 			${tabs} | ||||
| 			<!-- <input type="button" value="Fake News" @click=${this.add_fake_news}></input> --> | ||||
| 			${contents} | ||||
| 		`; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| customElements.define('tf-app', TfElement); | ||||
							
								
								
									
										265
									
								
								apps/cory/ssb/tf-compose.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										265
									
								
								apps/cory/ssb/tf-compose.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,265 @@ | ||||
| import {LitElement, html} from './lit-all.min.js'; | ||||
| import * as tfutils from './tf-utils.js'; | ||||
| import * as tfrpc from '/static/tfrpc.js'; | ||||
| import {styles} from './tf-styles.js'; | ||||
| import Tribute from './tribute.esm.js'; | ||||
|  | ||||
| class TfComposeElement extends LitElement { | ||||
| 	static get properties() { | ||||
| 		return { | ||||
| 			whoami: {type: String}, | ||||
| 			users: {type: Object}, | ||||
| 			root: {type: String}, | ||||
| 			branch: {type: String}, | ||||
| 			mentions: {type: Object}, | ||||
| 			apps: {type: Object}, | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	static styles = styles; | ||||
|  | ||||
| 	constructor() { | ||||
| 		super(); | ||||
| 		this.users = {}; | ||||
| 		this.root = undefined; | ||||
| 		this.branch = undefined; | ||||
| 		this.mentions = {}; | ||||
| 		this.apps = undefined; | ||||
| 	} | ||||
|  | ||||
| 	changed(event) { | ||||
| 		let edit = this.renderRoot.getElementById('edit'); | ||||
| 		let preview = this.renderRoot.getElementById('preview'); | ||||
| 		let text = edit.value; | ||||
|  | ||||
| 		/* Update mentions. */ | ||||
| 		for (let match of text.matchAll(/\[([^\[]+)]\(([@&%][^\)]+)/g)) { | ||||
| 			let name = match[1]; | ||||
| 			let link = match[2]; | ||||
| 			let balance = 0; | ||||
| 			let bracket_end = match.index + match[1].length + '[]'.length - 1; | ||||
| 			for (let i = bracket_end; i >= 0; i--) { | ||||
| 				if (text.charAt(i) == ']') { | ||||
| 					balance++; | ||||
| 				} else if (text.charAt(i) == '[') { | ||||
| 					balance--; | ||||
| 				} | ||||
| 				if (balance <= 0) { | ||||
| 					name = text.substring(i + 1, bracket_end); | ||||
| 					break; | ||||
| 				} | ||||
| 			} | ||||
| 			if (!this.mentions[link]) { | ||||
| 				this.mentions[link] = { | ||||
| 					link: link, | ||||
| 				} | ||||
| 			} | ||||
| 			this.mentions[link].name = name.startsWith('@') ? name.substring(1) : name; | ||||
| 			this.mentions = Object.assign({}, this.mentions); | ||||
| 		} | ||||
|  | ||||
| 		preview.innerHTML = tfutils.markdown(text); | ||||
| 	} | ||||
|  | ||||
| 	convert_to_webp(buffer, type) { | ||||
| 		return new Promise(function(resolve, reject) { | ||||
| 			let img = new Image(); | ||||
| 			img.onload = function() { | ||||
| 				let canvas = document.createElement('canvas'); | ||||
| 				let width_scale = Math.min(img.width, 1024) / img.width; | ||||
| 				let height_scale = Math.min(img.height, 1024) / img.height; | ||||
| 				let scale = Math.min(width_scale, height_scale); | ||||
| 				canvas.width = img.width * scale; | ||||
| 				canvas.height = img.height * scale; | ||||
| 				let context = canvas.getContext('2d'); | ||||
| 				context.drawImage(img, 0, 0, canvas.width, canvas.height); | ||||
| 				let data_url = canvas.toDataURL('image/webp'); | ||||
| 				let result = atob(data_url.split(',')[1]).split('').map(x => x.charCodeAt(0)); | ||||
| 				resolve(result); | ||||
| 			} | ||||
| 			img.onerror = function(event) { | ||||
| 				reject(new Error('Failed to load image.')); | ||||
| 			}; | ||||
| 			let raw = Array.from(new Uint8Array(buffer)).map(b => String.fromCharCode(b)).join(''); | ||||
| 			let original = `data:${type};base64,${btoa(raw)}`; | ||||
| 			img.src = original; | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	async add_file(file) { | ||||
| 		try { | ||||
| 			let self = this; | ||||
| 			let buffer = await file.arrayBuffer(); | ||||
| 			let type = file.type; | ||||
| 			if (type.startsWith('image/')) { | ||||
| 				buffer = await self.convert_to_webp(buffer, file.type); | ||||
| 				type = 'image/webp'; | ||||
| 			} else { | ||||
| 				buffer = Array.from(new Uint8Array(buffer)); | ||||
| 			} | ||||
| 			let id = await tfrpc.rpc.store_blob(buffer); | ||||
| 			let name = type.split('/')[0] + ':' + file.name; | ||||
| 			self.mentions[id] = { | ||||
| 				link: id, | ||||
| 				name: name, | ||||
| 				type: type, | ||||
| 				size: buffer.length ?? buffer.byteLength, | ||||
| 			}; | ||||
| 			self.mentions = Object.assign({}, self.mentions); | ||||
| 			let edit = self.renderRoot.getElementById('edit'); | ||||
| 			edit.value += `\n`; | ||||
| 			self.changed(); | ||||
| 		} catch(e) { | ||||
| 			alert(e?.message); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	paste(event) { | ||||
| 		let self = this; | ||||
| 		for (let item of event.clipboardData.items) { | ||||
| 			if (item.type?.startsWith('image/')) { | ||||
| 				let file = item.getAsFile(); | ||||
| 				if (!file) { | ||||
| 					continue; | ||||
| 				} | ||||
| 				self.add_file(file); | ||||
| 				break; | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	submit() { | ||||
| 		let self = this; | ||||
| 		let edit = this.renderRoot.getElementById('edit'); | ||||
| 		let message = { | ||||
| 			type: 'post', | ||||
| 			text: edit.value, | ||||
| 		}; | ||||
| 		if (this.root || this.branch) { | ||||
| 			message.root = this.root; | ||||
| 			message.branch = this.branch; | ||||
| 		} | ||||
| 		if (Object.values(this.mentions).length) { | ||||
| 			message.mentions = Object.values(this.mentions); | ||||
| 		} | ||||
| 		console.log('Would post:', message); | ||||
| 		tfrpc.rpc.appendMessage(this.whoami, message).then(function() { | ||||
| 			edit.value = ''; | ||||
| 			self.mentions = {}; | ||||
| 			self.changed(); | ||||
| 		}).catch(function(error) { | ||||
| 			alert(error.message); | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	discard() { | ||||
| 		let edit = this.renderRoot.getElementById('edit'); | ||||
| 		edit.value = ''; | ||||
| 		this.changed(); | ||||
| 		this.dispatchEvent(new CustomEvent('tf-discard')); | ||||
| 	} | ||||
|  | ||||
| 	attach() { | ||||
| 		let self = this; | ||||
| 		let edit = this.renderRoot.getElementById('edit'); | ||||
| 		let input = document.createElement('input'); | ||||
| 		input.type = 'file'; | ||||
| 		input.onchange = function(event) { | ||||
| 			let file = event.target.files[0]; | ||||
| 			self.add_file(file); | ||||
| 		}; | ||||
| 		input.click(); | ||||
| 	} | ||||
|  | ||||
| 	firstUpdated() { | ||||
| 		let tribute = new Tribute({ | ||||
| 			values: Object.entries(this.users).map(x => ({key: x[1].name, value: x[0]})), | ||||
| 			selectTemplate: function(item) { | ||||
| 				return `[@${item.original.key}](${item.original.value})`; | ||||
| 			}, | ||||
| 		}); | ||||
| 		tribute.attach(this.renderRoot.getElementById('edit')); | ||||
| 	} | ||||
|  | ||||
| 	remove_mention(id) { | ||||
| 		delete this.mentions[id]; | ||||
| 		this.mentions = Object.assign({}, this.mentions); | ||||
| 	} | ||||
|  | ||||
| 	render_mention(mention) { | ||||
| 		let self = this; | ||||
| 		return html` | ||||
| 			<div> | ||||
| 				<pre style="white-space: pre-wrap">${JSON.stringify(mention, null, 2)}</pre> | ||||
| 				<input type="button" value="x" @click=${() => self.remove_mention(mention.link)}></input> | ||||
| 			</div>`; | ||||
| 	} | ||||
|  | ||||
| 	render_attach_app() { | ||||
| 		let self = this; | ||||
|  | ||||
| 		async function attach_selected_app() { | ||||
| 			let name = self.renderRoot.getElementById('select').value; | ||||
| 			let id = self.apps[name]; | ||||
| 			let mentions = {}; | ||||
| 			mentions[id] = { | ||||
| 				name: name, | ||||
| 				link: id, | ||||
| 				type: 'application/tildefriends', | ||||
| 			}; | ||||
| 			if (name && id) { | ||||
| 				let app = JSON.parse(await tfrpc.rpc.get_blob(id)); | ||||
| 				for (let entry of Object.entries(app.files)) { | ||||
| 					mentions[entry[1]] = { | ||||
| 						name: entry[0], | ||||
| 						link: entry[1], | ||||
| 					}; | ||||
| 				} | ||||
| 			} | ||||
| 			this.mentions = Object.assign(this.mentions || {}, mentions); | ||||
| 			this.apps = null; | ||||
| 		} | ||||
|  | ||||
| 		if (this.apps) { | ||||
| 			return html` | ||||
| 				<div> | ||||
| 					<select id="select"> | ||||
| 						${Object.keys(self.apps).map(app => html`<option value=${app}>${app}</option>`)} | ||||
| 					</select> | ||||
| 					<input type="button" value="Attach" @click=${attach_selected_app}></input> | ||||
| 					<input type="button" value="Cancel" @click=${() => this.apps = null}></input> | ||||
| 				</div> | ||||
| 				`; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	render_attach_app_button() { | ||||
| 		async function attach_app() { | ||||
| 			this.apps = await tfrpc.rpc.apps(); | ||||
| 		} | ||||
| 		if (!this.apps) { | ||||
| 			return html`<input type="button" value="Attach App" @click=${attach_app}></input>` | ||||
| 		} else { | ||||
| 			return html`<input type="button" value="Discard App" @click=${() => this.apps = null}></input>` | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	render() { | ||||
| 		let self = this; | ||||
| 		let result = html` | ||||
| 			<div style="display: flex; flex-direction: row; width: 100%"> | ||||
| 				<textarea id="edit" @input=${this.changed} @paste=${this.paste} style="flex: 1 0 50%"></textarea> | ||||
| 				<div id="preview" style="flex: 1 0 50%"></div> | ||||
| 			</div> | ||||
| 			${Object.values(this.mentions).map(x => self.render_mention(x))} | ||||
| 			${this.render_attach_app()} | ||||
| 			<input type="button" value="Submit" @click=${this.submit}></input> | ||||
| 			<input type="button" value="Attach" @click=${this.attach}></input> | ||||
| 			${this.render_attach_app_button()} | ||||
| 			<input type="button" value="Discard" @click=${this.discard}></input> | ||||
| 		`; | ||||
| 		return result; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| customElements.define('tf-compose', TfComposeElement); | ||||
							
								
								
									
										57
									
								
								apps/cory/ssb/tf-connections.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								apps/cory/ssb/tf-connections.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| import {LitElement, html} from './lit-all.min.js'; | ||||
| import * as tfrpc from '/static/tfrpc.js'; | ||||
|  | ||||
| class TfConnectionsElement extends LitElement { | ||||
| 	static get properties() { | ||||
| 		return { | ||||
| 			broadcasts: {type: Array}, | ||||
| 			identities: {type: Array}, | ||||
| 			connections: {type: Array}, | ||||
| 			users: {type: Object}, | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	constructor() { | ||||
| 		super(); | ||||
| 		let self = this; | ||||
| 		this.broadcasts = []; | ||||
| 		this.identities = []; | ||||
| 		this.connections = []; | ||||
| 		this.users = {}; | ||||
| 		tfrpc.rpc.getAllIdentities().then(function(identities) { | ||||
| 			self.identities = identities || []; | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	_emit_change() { | ||||
| 		let changed_event = new Event('change', { | ||||
| 			srcElement: this, | ||||
| 		}); | ||||
| 		this.dispatchEvent(changed_event); | ||||
| 	} | ||||
|  | ||||
| 	changed(event) { | ||||
| 		this.selected = event.srcElement.value; | ||||
| 		tfrpc.rpc.localStorageSet('whoami', this.selected); | ||||
| 		this._emit_change(); | ||||
| 	} | ||||
|  | ||||
| 	render() { | ||||
| 		return html` | ||||
| 			<h2>Broadcasts</h2> | ||||
| 			<ul> | ||||
| 				${this.broadcasts.map(x => html`<li><tf-user id=${x.pubkey} .users=${this.users}></tf-user></li>`)} | ||||
| 			</ul> | ||||
| 			<h2>Connections</h2> | ||||
| 			<ul> | ||||
| 				${this.connections.map(x => html`<li><tf-user id=${x} .users=${this.users}></tf-user></li>`)} | ||||
| 			</ul> | ||||
| 			<h2>Local Accounts</h2> | ||||
| 			<ul> | ||||
| 				${this.identities.map(x => html`<li><tf-user id=${x} .users=${this.users}></tf-user></li>`)} | ||||
| 			</ul> | ||||
| 		`; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| customElements.define('tf-connections', TfConnectionsElement); | ||||
							
								
								
									
										37
									
								
								apps/cory/ssb/tf-id-picker.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								apps/cory/ssb/tf-id-picker.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| import {LitElement, html} from './lit-all.min.js'; | ||||
| import * as tfrpc from '/static/tfrpc.js'; | ||||
|  | ||||
| /* | ||||
| ** Provide a list of IDs, and this lets the user pick one. | ||||
| */ | ||||
| class TfIdentityPickerElement extends LitElement { | ||||
| 	static get properties() { | ||||
| 		return { | ||||
| 			ids: {type: Array}, | ||||
| 			selected: {type: String}, | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	constructor() { | ||||
| 		super(); | ||||
| 		let self = this; | ||||
| 		this.ids = []; | ||||
| 	} | ||||
|  | ||||
| 	changed(event) { | ||||
| 		this.selected = event.srcElement.value; | ||||
| 		this.dispatchEvent(new Event('change', { | ||||
| 			srcElement: this, | ||||
| 		})); | ||||
| 	} | ||||
|  | ||||
| 	render() { | ||||
| 		return html` | ||||
| 			<select @change=${this.changed} style="max-width: 100%"> | ||||
| 				${(this.ids ?? []).map(id => html`<option ?selected=${id == this.selected} value=${id}>${id}</option>`)} | ||||
| 			</select> | ||||
| 		`; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| customElements.define('tf-id-picker', TfIdentityPickerElement); | ||||
							
								
								
									
										399
									
								
								apps/cory/ssb/tf-message.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										399
									
								
								apps/cory/ssb/tf-message.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,399 @@ | ||||
| import {LitElement, html, unsafeHTML} from './lit-all.min.js'; | ||||
| import * as tfrpc from '/static/tfrpc.js'; | ||||
| import * as tfutils from './tf-utils.js'; | ||||
| import * as emojis from './emojis.js'; | ||||
| import {styles} from './tf-styles.js'; | ||||
|  | ||||
| class TfMessageElement extends LitElement { | ||||
| 	static get properties() { | ||||
| 		return { | ||||
| 			whoami: {type: String}, | ||||
| 			message: {type: Object}, | ||||
| 			users: {type: Object}, | ||||
| 			reply: {type: Boolean}, | ||||
| 			raw: {type: Boolean}, | ||||
| 			collapsed: {type: Boolean}, | ||||
| 			content_warning_expanded: {type: Boolean}, | ||||
| 			blog_data: {type: String}, | ||||
| 			blog_expanded: {type: Boolean}, | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	static styles = styles; | ||||
|  | ||||
| 	constructor() { | ||||
| 		super(); | ||||
| 		let self = this; | ||||
| 		this.whoami = null; | ||||
| 		this.message = {}; | ||||
| 		this.users = {}; | ||||
| 		this.reply = false; | ||||
| 		this.raw = false; | ||||
| 		this.collapsed = false; | ||||
| 	} | ||||
|  | ||||
| 	show_reply() { | ||||
| 		this.reply = true; | ||||
| 	} | ||||
|  | ||||
| 	render_votes() { | ||||
| 		function normalize_expression(expression) { | ||||
| 			if (expression === 'Like' || !expression) { | ||||
| 				return '👍'; | ||||
| 			} else if (expression === 'Unlike') { | ||||
| 				return '👎'; | ||||
| 			} else if (expression === 'heart') { | ||||
| 				return '❤️'; | ||||
| 			} else { | ||||
| 				return expression; | ||||
| 			} | ||||
| 		} | ||||
| 		return html`<div>${(this.message.votes || []).map( | ||||
| 			vote => html` | ||||
| 				<span title="${this.users[vote.author]?.name ?? vote.author} ${new Date(vote.timestamp)}"> | ||||
| 					${normalize_expression(vote.content.vote.expression)} | ||||
| 				</span> | ||||
| 			`)}</div>`; | ||||
| 	} | ||||
|  | ||||
| 	render_raw() { | ||||
| 		let raw = { | ||||
| 			id: this.message?.id, | ||||
| 			previous: this.message?.previous, | ||||
| 			author: this.message?.author, | ||||
| 			sequence: this.message?.sequence, | ||||
| 			timestamp: this.message?.timestamp, | ||||
| 			hash: this.message?.hash, | ||||
| 			content: this.message?.content, | ||||
| 			signature: this.message?.signature, | ||||
| 		} | ||||
| 		return html`<div style="white-space: pre-wrap">${JSON.stringify(raw, null, 2)}</div>` | ||||
| 	} | ||||
|  | ||||
| 	vote(emoji) { | ||||
| 		let reaction = emoji.emoji; | ||||
| 		let message = this.message.id; | ||||
| 		if (confirm('Are you sure you want to react with ' + reaction + ' to ' + message + '?')) { | ||||
| 			tfrpc.rpc.appendMessage( | ||||
| 				this.whoami, | ||||
| 				{ | ||||
| 					type: 'vote', | ||||
| 					vote: { | ||||
| 						link: message, | ||||
| 						value: 1, | ||||
| 						expression: reaction, | ||||
| 					}, | ||||
| 				}).catch(function(error) { | ||||
| 					alert(error?.message); | ||||
| 				}); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	react(event) { | ||||
| 		emojis.picker(x => this.vote(x)); | ||||
| 	} | ||||
|  | ||||
| 	show_image(link) { | ||||
| 		let div = document.createElement('div'); | ||||
| 		div.style.left = 0; | ||||
| 		div.style.top = 0; | ||||
| 		div.style.width = '100%'; | ||||
| 		div.style.height = '100%'; | ||||
| 		div.style.position = 'fixed'; | ||||
| 		div.style.background = '#000'; | ||||
| 		div.style.zIndex = 100; | ||||
| 		div.style.display = 'grid'; | ||||
| 		let img = document.createElement('img'); | ||||
| 		img.src = link; | ||||
| 		img.style.maxWidth = '100%'; | ||||
| 		img.style.maxHeight = '100%'; | ||||
| 		img.style.display = 'block'; | ||||
| 		img.style.margin = 'auto'; | ||||
| 		img.style.objectFit = 'contain'; | ||||
| 		img.style.width = '100%'; | ||||
| 		div.appendChild(img); | ||||
| 		function image_close(event) { | ||||
| 			document.body.removeChild(div); | ||||
| 			window.removeEventListener('keydown', image_close); | ||||
| 		} | ||||
| 		div.onclick = image_close; | ||||
| 		window.addEventListener('keydown', image_close); | ||||
| 		document.body.appendChild(div); | ||||
| 	} | ||||
|  | ||||
| 	body_click(event) { | ||||
| 		if (event.srcElement.tagName == 'IMG') { | ||||
| 			this.show_image(event.srcElement.src); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	render_mention(mention) { | ||||
| 		if (!mention?.link || typeof(mention.link) != 'string') { | ||||
| 			return html` <pre>${JSON.stringify(mention)}</pre>`; | ||||
| 		} else if (mention?.link?.startsWith('&') && | ||||
| 			mention?.type?.startsWith('image/')) { | ||||
| 			return html` | ||||
| 				<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('&') && | ||||
| 			mention.name?.startsWith('audio:')) { | ||||
| 			return html` | ||||
| 				<audio controls style="height: 32px"> | ||||
| 					<source src=${'/' + mention.link + '/view'}></source> | ||||
| 				</audio> | ||||
| 			`; | ||||
| 		} else if (mention.link?.startsWith('&') && | ||||
| 			mention.name?.startsWith('video:')) { | ||||
| 			return html` | ||||
| 				<video controls style="max-height: 240px"> | ||||
| 					<source src=${'/' + mention.link + '/view'}></source> | ||||
| 				</video> | ||||
| 			`; | ||||
| 		} else if (mention.link?.startsWith('&') && | ||||
| 			mention?.type === 'application/tildefriends') { | ||||
| 			return html` <a href=${`/${mention.link}/`}>😎 ${mention.name}</a>`; | ||||
| 		} else if (mention.link?.startsWith('%') || mention.link?.startsWith('@')) { | ||||
| 			return html` <a href=${'#' + encodeURIComponent(mention.link)}>${mention.name}</a>`; | ||||
| 		} else if (mention.link?.startsWith('#')) { | ||||
| 			return html` <a href=${'#q=' + encodeURIComponent(mention.link)}>${mention.link}</a>`; | ||||
| 		} else if (Object.keys(mention).length == 2 && mention.link && mention.name) { | ||||
| 			return html` <a href=${`/${mention.link}/view`}>${mention.name}</a>`; | ||||
| 		} else { | ||||
| 			return html` <pre style="white-space: pre-wrap">${JSON.stringify(mention, null, 2)}</pre>`; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	render_mentions() { | ||||
| 		let mentions = this.message?.content?.mentions || []; | ||||
| 		mentions = mentions.filter(x => | ||||
| 			x.name?.startsWith('audio:') || | ||||
| 			x.name?.startsWith('video:') || | ||||
| 			this.message?.content?.text?.indexOf(x.link) === -1); | ||||
| 		if (mentions.length) { | ||||
| 			let self = this; | ||||
| 			return html` | ||||
| 				<fieldset style="background-color: rgba(0, 0, 0, 0.1); padding: 0.5em; border: 1px solid black"> | ||||
| 					<legend>Mentions</legend> | ||||
| 					${mentions.map(x => self.render_mention(x))} | ||||
| 				</fieldset> | ||||
| 			`; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	render_children() { | ||||
| 		let self = this; | ||||
| 		if (this.collapsed && this.message.child_messages?.length) { | ||||
| 			return html`<input type="button" value=${this.message.child_messages?.length + ' More'} @click=${() => self.collapsed = false}></input>`; | ||||
| 		} else { | ||||
| 			return html`<input type="button" value="Collapse" @click=${() => self.collapsed = true}></input>${(this.message.child_messages || []).map(x => html`<tf-message .message=${x} whoami=${this.whoami} .users=${this.users}></tf-message>`)}`; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	render() { | ||||
| 		let content = this.message?.content; | ||||
| 		let self = this; | ||||
| 		let raw_button = this.raw ? | ||||
| 				html`<input type="button" value="Message" @click=${() => self.raw = false}></input>` : | ||||
| 				html`<input type="button" value="Raw" @click=${() => self.raw = true}></input>`; | ||||
| 		function small_frame(inner) { | ||||
| 			return html` | ||||
| 				<div style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px; display: inline-block"> | ||||
| 					<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> | ||||
| 					${raw_button} | ||||
| 					${self.raw ? self.render_raw() : inner} | ||||
| 					${self.render_votes()} | ||||
| 				</div> | ||||
| 			` | ||||
| 		} | ||||
| 		if (this.message.placeholder) { | ||||
| 			return html` | ||||
| 				<div style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px; overflow-wrap: anywhere"> | ||||
| 					<a target="_top" href=${'#' + this.message.id}>${this.message.id}</a> (placeholder) | ||||
| 					<div>${this.render_votes()}</div> | ||||
| 					${(this.message.child_messages || []).map(x => html` | ||||
| 						<tf-message .message=${x} whoami=${this.whoami} .users=${this.users} collapsed=true></tf-message> | ||||
| 					`)} | ||||
| 				</div>`; | ||||
| 		} else if (typeof(content?.type === 'string')) { | ||||
| 			if (content.type == 'about') { | ||||
| 				let name; | ||||
| 				let image; | ||||
| 				let description; | ||||
| 				if (content.name !== undefined) { | ||||
| 					name = html`<div><b>Name:</b> ${content.name}</div>`; | ||||
| 				} | ||||
| 				if (content.image !== undefined) { | ||||
| 					image = html` | ||||
| 						<div><img src=${'/' + content.image + '/view'} style="width: 256px; height: auto"></img></div> | ||||
| 					`; | ||||
| 				} | ||||
| 				if (content.description !== undefined) { | ||||
| 					description = html` | ||||
| 						<div style="flex: 1 0 50%"> | ||||
| 							<div>${unsafeHTML(tfutils.markdown(content.description))}</div> | ||||
| 						</div> | ||||
| 					` | ||||
| 				} | ||||
| 				return small_frame(html` | ||||
| 					<div style="font-weight: bold">Updated profile.</div> | ||||
| 					${name} | ||||
| 					${image} | ||||
| 					${description} | ||||
| 				`); | ||||
| 			} else if (content.type == 'contact') { | ||||
| 				return small_frame(html` | ||||
| 					<div> | ||||
| 						is | ||||
| 						${ | ||||
| 							content.blocking === true ? 'blocking' : | ||||
| 							content.blocking === false ? 'no longer blocking' : | ||||
| 							content.following === true ? 'following' : | ||||
| 							content.following === false ? 'no longer following' : | ||||
| 							'?' | ||||
| 						} | ||||
| 						<tf-user id=${this.message.content.contact} .users=${this.users}></tf-user> | ||||
| 					</div> | ||||
| 				`); | ||||
| 			} else if (content.type == 'post') { | ||||
| 				let reply = this.reply ? html` | ||||
| 					<tf-compose | ||||
| 						?enabled=${this.reply} | ||||
| 						whoami=${this.whoami} | ||||
| 						.users=${this.users} | ||||
| 						root=${this.message.content.root || this.message.id} | ||||
| 						branch=${this.message.id} | ||||
| 						@tf-discard=${() => this.reply = false}></tf-compose> | ||||
| 				` : html` | ||||
| 					<input type="button" value="Reply" @click=${this.show_reply}></input> | ||||
| 				`; | ||||
| 				let self = this; | ||||
| 				let body = this.raw ? | ||||
| 					this.render_raw() : | ||||
| 					unsafeHTML(tfutils.markdown(content.text)); | ||||
| 				let content_warning = html` | ||||
| 					<div style="border: 1px solid #fff; border-radius: 1em; padding: 8px; margin: 4px" @click=${x => self.content_warning_expanded = !self.content_warning_expanded}>${content.contentWarning}</div> | ||||
| 					`; | ||||
| 				let content_html = | ||||
| 					html` | ||||
| 						<div @click=${this.body_click}>${body}</div> | ||||
| 						${this.render_mentions()} | ||||
| 					`; | ||||
| 				let payload = | ||||
| 					content.contentWarning ? | ||||
| 						self.content_warning_expanded ? | ||||
| 							html` | ||||
| 								${content_warning} | ||||
| 								${content_html} | ||||
| 							` : | ||||
| 							content_warning : | ||||
| 						content_html; | ||||
| 				return html` | ||||
| 					<style> | ||||
| 						code { | ||||
| 							white-space: pre-wrap; | ||||
| 							overflow-wrap: break-word; | ||||
| 						} | ||||
| 						div { | ||||
| 							overflow-wrap: anywhere; | ||||
| 						} | ||||
| 						img { | ||||
| 							max-width: 100%; | ||||
| 							height: auto; | ||||
| 							display: block; | ||||
| 						} | ||||
| 					</style> | ||||
| 					<div 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"> | ||||
| 							<tf-user id=${this.message.author} .users=${this.users}></tf-user> | ||||
| 							<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>${raw_button}</span> | ||||
| 						</div> | ||||
| 						${payload} | ||||
| 						${this.render_votes()} | ||||
| 						<div> | ||||
| 							${reply} | ||||
| 							<input type="button" value="React" @click=${this.react}></input> | ||||
| 						</div> | ||||
| 						${this.render_children()} | ||||
| 					</div> | ||||
| 				`; | ||||
| 			} else if (content.type === 'blog') { | ||||
| 				let self = this; | ||||
| 				console.log('requesting data'); | ||||
| 				tfrpc.rpc.get_blob(content.blog).then(function(data) { | ||||
| 					self.blog_data = data; | ||||
| 				}); | ||||
| 				let payload = | ||||
| 						this.blog_expanded ? | ||||
| 						html`<div>${this.blog_data ? unsafeHTML(tfutils.markdown(this.blog_data)) : 'Loading...'}</div>` : | ||||
| 						undefined; | ||||
| 				let body = this.raw ? | ||||
| 						this.render_raw() : | ||||
| 						html` | ||||
| 							<div | ||||
| 								style="border: 1px solid #fff; border-radius: 1em; padding: 8px; margin: 4px; cursor: pointer" | ||||
| 								@click=${x => self.blog_expanded = !self.blog_expanded}> | ||||
| 								<h2>${content.title}</h2> | ||||
| 								<div style="display: flex; flex-direction: row"> | ||||
| 									<img src=/${content.thumbnail}/view></img> | ||||
| 									<span>${content.summary}</span> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 							${payload} | ||||
| 						`; | ||||
| 				return html` | ||||
| 					<style> | ||||
| 						code { | ||||
| 							white-space: pre-wrap; | ||||
| 							overflow-wrap: break-word; | ||||
| 						} | ||||
| 						div { | ||||
| 							overflow-wrap: anywhere; | ||||
| 						} | ||||
| 						img { | ||||
| 							max-width: 100%; | ||||
| 							height: auto; | ||||
| 							display: block; | ||||
| 						} | ||||
| 					</style> | ||||
| 					<div 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"> | ||||
| 							<tf-user id=${this.message.author} .users=${this.users}></tf-user> | ||||
| 							<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>${raw_button}</span> | ||||
| 						</div> | ||||
|  | ||||
| 						<div>${body}</div> | ||||
| 						${this.render_mentions()} | ||||
| 						${this.render_votes()} | ||||
| 					</div> | ||||
| 				`; | ||||
| 			} else if (content.type === 'pub') { | ||||
| 				return small_frame(html` | ||||
| 				<span> | ||||
| 					<div> | ||||
| 						🍻 <tf-user .users=${this.users} id=${content.address.key}></tf-user> | ||||
| 					</div> | ||||
| 					<pre>${content.address.host}:${content.address.port}</pre> | ||||
| 				</span>`); | ||||
| 			} else if (content.type === 'channel') { | ||||
| 				return small_frame(html` | ||||
| 					<div> | ||||
| 						${content.subscribed ? 'subscribed to' : 'unsubscribed from'} <a href=${'#q=' + encodeURIComponent('#' + content.channel)}>#${content.channel}</a> | ||||
| 					</div> | ||||
| 				`); | ||||
| 			} else if (typeof(this.message.content) == 'string') { | ||||
| 				return small_frame(html`<span>🔒</span>`); | ||||
| 			} else { | ||||
| 				return small_frame(html`<div><b>type</b>: ${content.type}</div>`); | ||||
| 			} | ||||
| 		} else { | ||||
| 			return small_frame(this.render_raw()); | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| customElements.define('tf-message', TfMessageElement); | ||||
							
								
								
									
										159
									
								
								apps/cory/ssb/tf-news.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										159
									
								
								apps/cory/ssb/tf-news.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,159 @@ | ||||
| import {LitElement, html, unsafeHTML, until} from './lit-all.min.js'; | ||||
| import * as tfrpc from '/static/tfrpc.js'; | ||||
| import {styles} from './tf-styles.js'; | ||||
|  | ||||
| class TfNewsElement extends LitElement { | ||||
| 	static get properties() { | ||||
| 		return { | ||||
| 			whoami: {type: String}, | ||||
| 			users: {type: Object}, | ||||
| 			messages: {type: Array}, | ||||
| 			following: {type: Array}, | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	static styles = styles; | ||||
|  | ||||
| 	constructor() { | ||||
| 		super(); | ||||
| 		let self = this; | ||||
| 		this.whoami = null; | ||||
| 		this.users = {}; | ||||
| 		this.messages = []; | ||||
| 		this.following = []; | ||||
| 	} | ||||
|  | ||||
| 	process_messages(messages) { | ||||
| 		let self = this; | ||||
| 		let messages_by_id = {}; | ||||
|  | ||||
| 		console.log('processing', messages.length, 'messages'); | ||||
|  | ||||
| 		function ensure_message(id) { | ||||
| 			let found = messages_by_id[id]; | ||||
| 			if (found) { | ||||
| 				return found; | ||||
| 			} else { | ||||
| 				let added = { | ||||
| 					id: id, | ||||
| 					placeholder: true, | ||||
| 					content: '"placeholder"', | ||||
| 					parent_message: undefined, | ||||
| 					child_messages: [], | ||||
| 					votes: [], | ||||
| 				}; | ||||
| 				messages_by_id[id] = added; | ||||
| 				return added; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		function link_message(message) { | ||||
| 			if (message.content.type === 'vote') { | ||||
| 				let parent = ensure_message(message.content.vote.link); | ||||
| 				if (!parent.votes) { | ||||
| 					parent.votes = []; | ||||
| 				} | ||||
| 				parent.votes.push(message); | ||||
| 				message.parent_message = message.content.vote.link; | ||||
| 			} else if (message.content.type == 'post') { | ||||
| 				if (message.content.root) { | ||||
| 					if (typeof(message.content.root) === 'string') { | ||||
| 						let m = ensure_message(message.content.root); | ||||
| 						if (!m.child_messages) { | ||||
| 							m.child_messages = []; | ||||
| 						} | ||||
| 						m.child_messages.push(message); | ||||
| 						message.parent_message = message.content.root; | ||||
| 					} else { | ||||
| 						let m = ensure_message(message.content.root[0]); | ||||
| 						if (!m.child_messages) { | ||||
| 							m.child_messages = []; | ||||
| 						} | ||||
| 						m.child_messages.push(message); | ||||
| 						message.parent_message = message.content.root[0]; | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		for (let message of messages) { | ||||
| 			message.parent_message = undefined; | ||||
| 			message.child_messages = undefined; | ||||
| 		} | ||||
|  | ||||
| 		for (let message of messages) { | ||||
| 			try { | ||||
| 				message.content = JSON.parse(message.content); | ||||
| 			} catch { | ||||
| 			} | ||||
| 			if (!messages_by_id[message.id]) { | ||||
| 				messages_by_id[message.id] = message; | ||||
| 				link_message(message); | ||||
| 			} else if (messages_by_id[message.id].placeholder) { | ||||
| 				let placeholder = messages_by_id[message.id]; | ||||
| 				messages_by_id[message.id] = message; | ||||
| 				message.parent_message = placeholder.parent_message; | ||||
| 				message.child_messages = placeholder.child_messages; | ||||
| 				message.votes = placeholder.votes; | ||||
| 				if (placeholder.parent_message && messages_by_id[placeholder.parent_message]) { | ||||
| 					let children = messages_by_id[placeholder.parent_message].child_messages; | ||||
| 					children.splice(children.indexOf(placeholder), 1); | ||||
| 					children.push(message); | ||||
| 				} | ||||
| 				link_message(message); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		return messages_by_id; | ||||
| 	} | ||||
|  | ||||
| 	update_latest_subtree_timestamp(messages) { | ||||
| 		let latest = 0; | ||||
| 		for (let message of messages || []) { | ||||
| 			if (message.latest_subtree_timestamp === undefined) { | ||||
| 				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); | ||||
| 		} | ||||
| 		return latest; | ||||
| 	} | ||||
|  | ||||
| 	finalize_messages(messages_by_id) { | ||||
| 		function recursive_sort(messages, top) { | ||||
| 			if (messages) { | ||||
| 				if (top) { | ||||
| 					messages.sort((a, b) => b.latest_subtree_timestamp - a.latest_subtree_timestamp); | ||||
| 				} else { | ||||
| 					messages.sort((a, b) => a.timestamp - b.timestamp); | ||||
| 				} | ||||
| 				for (let message of messages) { | ||||
| 					recursive_sort(message.child_messages, false); | ||||
| 				} | ||||
| 				return messages.map(x => Object.assign({}, x)); | ||||
| 			} else { | ||||
| 				return {}; | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		let roots = Object.values(messages_by_id).filter(x => !x.parent_message); | ||||
| 		this.update_latest_subtree_timestamp(roots); | ||||
| 		return recursive_sort(roots, true); | ||||
| 	} | ||||
|  | ||||
| 	async load_and_render(messages) { | ||||
| 		let messages_by_id = this.process_messages(messages); | ||||
| 		let final_messages = this.finalize_messages(messages_by_id); | ||||
| 		return html` | ||||
| 			<div style="display: flex; flex-direction: column"> | ||||
| 				${final_messages.map(x => html`<tf-message .message=${x} whoami=${this.whoami} .users=${this.users} collapsed=true></tf-message>`)} | ||||
| 			</div> | ||||
| 		`; | ||||
| 	} | ||||
|  | ||||
| 	render() { | ||||
| 		let messages = this.load_and_render(this.messages || []); | ||||
| 		return html`${until(messages, html`<div>Loading placeholders...</div>`)}`; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| customElements.define('tf-news', TfNewsElement); | ||||
							
								
								
									
										180
									
								
								apps/cory/ssb/tf-profile.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										180
									
								
								apps/cory/ssb/tf-profile.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,180 @@ | ||||
| import {LitElement, html, unsafeHTML} from './lit-all.min.js'; | ||||
| import * as tfrpc from '/static/tfrpc.js'; | ||||
| import * as tfutils from './tf-utils.js'; | ||||
| import {styles} from './tf-styles.js'; | ||||
|  | ||||
| class TfProfileElement extends LitElement { | ||||
| 	static get properties() { | ||||
| 		return { | ||||
| 			editing: {type: Object}, | ||||
| 			whoami: {type: String}, | ||||
| 			id: {type: String}, | ||||
| 			users: {type: Object}, | ||||
| 			size: {type: Number}, | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	static styles = styles; | ||||
|  | ||||
| 	constructor() { | ||||
| 		super(); | ||||
| 		let self = this; | ||||
| 		this.editing = null; | ||||
| 		this.whoami = null; | ||||
| 		this.id = null; | ||||
| 		this.users = {}; | ||||
| 		this.size = 0; | ||||
| 	} | ||||
|  | ||||
| 	modify(change) { | ||||
| 		tfrpc.rpc.appendMessage(this.whoami, | ||||
| 			Object.assign({ | ||||
| 				type: 'contact', | ||||
| 				contact: this.id, | ||||
| 			}, change)).catch(function(error) { | ||||
| 				alert(error?.message); | ||||
| 			}) | ||||
| 	} | ||||
|  | ||||
| 	follow() { | ||||
| 		this.modify({following: true}); | ||||
| 	} | ||||
|  | ||||
| 	unfollow() { | ||||
| 		this.modify({following: false}); | ||||
| 	} | ||||
|  | ||||
| 	block() { | ||||
| 		this.modify({blocking: true}); | ||||
| 	} | ||||
|  | ||||
| 	unblock() { | ||||
| 		this.modify({blocking: false}); | ||||
| 	} | ||||
|  | ||||
| 	edit() { | ||||
| 		let original = this.users[this.id]; | ||||
| 		this.editing = { | ||||
| 			name: original.name, | ||||
| 			description: original.description, | ||||
| 			image: original.image, | ||||
| 		}; | ||||
| 		console.log(this.editing); | ||||
| 	} | ||||
|  | ||||
| 	save_edits() { | ||||
| 		let self = this; | ||||
| 		let message = { | ||||
| 			type: 'about', | ||||
| 			about: this.whoami, | ||||
| 		}; | ||||
| 		for (let key of Object.keys(this.editing)) { | ||||
| 			if (this.editing[key] !== this.users[this.id][key]) { | ||||
| 				message[key] = this.editing[key]; | ||||
| 			} | ||||
| 		} | ||||
| 		tfrpc.rpc.appendMessage(this.whoami, message).then(function() { | ||||
| 			self.editing = null; | ||||
| 		}).catch(function(error) { | ||||
| 			alert(error?.message); | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	discard_edits() { | ||||
| 		this.editing = null; | ||||
| 	} | ||||
|  | ||||
| 	attach_image() { | ||||
| 		let self = this; | ||||
| 		let input = document.createElement('input'); | ||||
| 		input.type = 'file'; | ||||
| 		input.onchange = function(event) { | ||||
| 			let file = event.target.files[0]; | ||||
| 			file.arrayBuffer().then(function(buffer) { | ||||
| 				let bin = Array.from(new Uint8Array(buffer)); | ||||
| 				return tfrpc.rpc.store_blob(bin); | ||||
| 			}).then(function(id) { | ||||
| 				self.editing = Object.assign({}, self.editing, {image: id}); | ||||
| 				console.log(self.editing); | ||||
| 			}).catch(function(e) { | ||||
| 				alert(e.message); | ||||
| 			}); | ||||
| 		}; | ||||
| 		input.click(); | ||||
| 	} | ||||
|  | ||||
| 	render() { | ||||
| 		let self = this; | ||||
| 		let profile = this.users[this.id] || {}; | ||||
| 		tfrpc.rpc.query( | ||||
| 			`SELECT SUM(LENGTH(content)) AS size FROM messages WHERE author = ?`, | ||||
| 			[this.id]).then(function(result) { | ||||
| 				self.size = result[0].size; | ||||
| 			}); | ||||
| 		let edit; | ||||
| 		let follow; | ||||
| 		let block; | ||||
| 		if (this.id === this.whoami) { | ||||
| 			if (this.editing) { | ||||
| 				edit = html` | ||||
| 					<input type="button" value="Save Profile" @click=${this.save_edits}></input> | ||||
| 					<input type="button" value="Discard" @click=${this.discard_edits}></input> | ||||
| 				`; | ||||
| 			} else { | ||||
| 				edit = html`<input type="button" value="Edit Profile" @click=${this.edit}></input>`; | ||||
| 			} | ||||
| 		} | ||||
| 		if (this.id !== this.whoami && | ||||
| 			this.users[this.whoami]?.following) { | ||||
| 			follow = | ||||
| 				this.users[this.whoami].following[this.id] ? | ||||
| 				html`<input type="button" value="Unfollow" @click=${this.unfollow}></input>` : | ||||
| 				html`<input type="button" value="Follow" @click=${this.follow}></input>`; | ||||
| 		} | ||||
| 		if (this.id !== this.whoami && | ||||
| 			this.users[this.whoami]?.blocking) { | ||||
| 			block = | ||||
| 				this.users[this.whoami].blocking[this.id] ? | ||||
| 				html`<input type="button" value="Unblock" @click=${this.unblock}></input>` : | ||||
| 				html`<input type="button" value="Block" @click=${this.block}></input>`; | ||||
| 		} | ||||
| 		let edit_profile = this.editing ? html` | ||||
| 			<div style="flex: 1 0 50%"> | ||||
| 				<div> | ||||
| 					<label for="name">Name:</label> | ||||
| 					<input 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> | ||||
| 					<textarea id="description" @input=${event => this.editing = Object.assign({}, this.editing, {description: event.srcElement.value})}>${this.editing.description}</textarea> | ||||
| 				</div> | ||||
| 				<input type="button" value="Attach Image" @click=${this.attach_image}></input> | ||||
| 			</div>` : null; | ||||
| 		let image = typeof(profile.image) == 'string' ? profile.image : profile.image?.link; | ||||
| 		image = this.editing?.image ?? image; | ||||
| 		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"> | ||||
| 			<tf-user id=${this.id} .users=${this.users}></tf-user> (${tfutils.human_readable_size(this.size)}) | ||||
| 			<div style="display: flex; flex-direction: row"> | ||||
| 				${edit_profile} | ||||
| 				<div style="flex: 1 0 50%"> | ||||
| 					<div><img src=${'/' + image + '/view'} style="width: 256px; height: auto"></img></div> | ||||
| 					<div>${unsafeHTML(tfutils.markdown(description))}</div> | ||||
| 				</div> | ||||
| 			</div> | ||||
| 			<div> | ||||
| 				Following ${Object.keys(profile.following || {}).length} identities. | ||||
| 				Followed by ${Object.values(self.users).filter(x => (x.following || {})[self.id]).length} identities. | ||||
| 				Blocking ${Object.keys(profile.blocking || {}).length} identities. | ||||
| 				Blocked by ${Object.values(self.users).filter(x => (x.blocking || {})[self.id]).length} identities. | ||||
| 			</div> | ||||
| 			<div> | ||||
| 				${edit} | ||||
| 				${follow} | ||||
| 				${block} | ||||
| 			</div> | ||||
| 		</div>`; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| customElements.define('tf-profile', TfProfileElement); | ||||
							
								
								
									
										32
									
								
								apps/cory/ssb/tf-styles.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								apps/cory/ssb/tf-styles.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| import {css} from './lit-all.min.js'; | ||||
|  | ||||
| export let styles = css` | ||||
| a:link { | ||||
| 	color: #bbf; | ||||
| } | ||||
|  | ||||
| a:visited { | ||||
| 	color: #ddd; | ||||
| } | ||||
|  | ||||
| a:hover { | ||||
| 	color: #ddf; | ||||
| } | ||||
|  | ||||
| img { | ||||
| 	max-width: min(640px, 100%); | ||||
| 	max-height: min(480px, auto); | ||||
| } | ||||
|  | ||||
| .tab { | ||||
| 	border: 0; | ||||
| 	padding: 8px; | ||||
| 	margin: 0px; | ||||
| 	cursor: pointer; | ||||
| } | ||||
|  | ||||
| .tab:disabled { | ||||
| 	color: #088; | ||||
| 	background-color: #fff; | ||||
| } | ||||
| `; | ||||
							
								
								
									
										103
									
								
								apps/cory/ssb/tf-tab-connections.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								apps/cory/ssb/tf-tab-connections.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,103 @@ | ||||
| import {LitElement, html} from './lit-all.min.js'; | ||||
| import * as tfrpc from '/static/tfrpc.js'; | ||||
|  | ||||
| class TfTabConnectionsElement extends LitElement { | ||||
| 	static get properties() { | ||||
| 		return { | ||||
| 			broadcasts: {type: Array}, | ||||
| 			identities: {type: Array}, | ||||
| 			connections: {type: Array}, | ||||
| 			users: {type: Object}, | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	constructor() { | ||||
| 		super(); | ||||
| 		let self = this; | ||||
| 		this.broadcasts = []; | ||||
| 		this.identities = []; | ||||
| 		this.connections = []; | ||||
| 		this.users = {}; | ||||
| 		tfrpc.rpc.getAllIdentities().then(function(identities) { | ||||
| 			self.identities = identities || []; | ||||
| 		}); | ||||
| 	} | ||||
|  | ||||
| 	render_connection_summary(connection) { | ||||
| 		if (connection.address && connection.port) { | ||||
| 			return html`(<small>${connection.address}:${connection.port}</small>)`; | ||||
| 		} else if (connection.tunnel) { | ||||
| 			return html`(room peer)`; | ||||
| 		} else { | ||||
| 			return JSON.stringify(connection); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	render_room_peers(connection) { | ||||
| 		let self = this; | ||||
| 		let peers = this.broadcasts.filter(x => x.tunnel?.id == connection); | ||||
| 		if (peers.length) { | ||||
| 			return html` | ||||
| 				<ul> | ||||
| 					${peers.map(x => html`${self.render_room_peer(x)}`)} | ||||
| 				</ul> | ||||
| 			`; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async _tunnel(portal, target) { | ||||
| 		let request_number = await tfrpc.rpc.connectionSendJson(portal, {name: ['tunnel', 'connect'], args: [{portal: portal, target: target}], type: 'duplex'}); | ||||
| 		return tfrpc.rpc.createTunnel(portal, request_number, target); | ||||
| 	} | ||||
|  | ||||
| 	render_room_peer(connection) { | ||||
| 		let self = this; | ||||
| 		return html` | ||||
| 			<li> | ||||
| 				<input type="button" @click=${() => self._tunnel(connection.tunnel.id, connection.pubkey)} value="Connect"></input> | ||||
| 				<tf-user id=${connection.pubkey} .users=${this.users}></tf-user> | ||||
| 			</li> | ||||
| 		`; | ||||
| 	} | ||||
|  | ||||
| 	render_broadcast(connection) { | ||||
| 		return html` | ||||
| 			<li> | ||||
| 				<input type="button" @click=${() => tfrpc.rpc.connect(connection)} value="Connect"></input> | ||||
| 				<tf-user id=${connection.pubkey} .users=${this.users}></tf-user> | ||||
| 				${this.render_connection_summary(connection)} | ||||
| 			</li> | ||||
| 		` | ||||
| 	} | ||||
|  | ||||
| 	render() { | ||||
| 		let self = this; | ||||
| 		return html` | ||||
| 			<h2>New Connection</h2> | ||||
| 			<div style="display: flex; flex-direction: column"> | ||||
| 				<textarea id="code"></textarea> | ||||
| 			</div> | ||||
| 			<input type="button" @click=${() => tfrpc.rpc.connect(self.renderRoot.getElementById('code').value)} value="Connect"></input> | ||||
| 			<h2>Broadcasts</h2> | ||||
| 			<ul> | ||||
| 				${this.broadcasts.filter(x => x.address).map(x => self.render_broadcast(x))} | ||||
| 			</ul> | ||||
| 			<h2>Connections</h2> | ||||
| 			<ul> | ||||
| 				${this.connections.map(x => html` | ||||
| 					<li> | ||||
| 						<input type="button" @click=${() => tfrpc.rpc.closeConnection(x)} value="Close"></input> | ||||
| 						<tf-user id=${x} .users=${this.users}></tf-user> | ||||
| 						${self.render_room_peers(x)} | ||||
| 					</li> | ||||
| 				`)} | ||||
| 			</ul> | ||||
| 			<h2>Local Accounts</h2> | ||||
| 			<ul> | ||||
| 				${this.identities.map(x => html`<li><tf-user id=${x} .users=${this.users}></tf-user></li>`)} | ||||
| 			</ul> | ||||
| 		`; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| customElements.define('tf-tab-connections', TfTabConnectionsElement); | ||||
							
								
								
									
										174
									
								
								apps/cory/ssb/tf-tab-news.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										174
									
								
								apps/cory/ssb/tf-tab-news.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,174 @@ | ||||
| import {LitElement, html, unsafeHTML, until} from './lit-all.min.js'; | ||||
| import * as tfrpc from '/static/tfrpc.js'; | ||||
| import {styles} from './tf-styles.js'; | ||||
|  | ||||
| class TfTabNewsFeedElement extends LitElement { | ||||
| 	static get properties() { | ||||
| 		return { | ||||
| 			whoami: {type: String}, | ||||
| 			users: {type: Object}, | ||||
| 			hash: {type: String}, | ||||
| 			following: {type: Array}, | ||||
| 			messages: {type: Array}, | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	static styles = styles; | ||||
|  | ||||
| 	constructor() { | ||||
| 		super(); | ||||
| 		let self = this; | ||||
| 		this.whoami = null; | ||||
| 		this.users = {}; | ||||
| 		this.hash = '#'; | ||||
| 		this.following = []; | ||||
| 	} | ||||
|  | ||||
| 	async fetch_messages() { | ||||
| 		if (this.hash.startsWith('#@')) { | ||||
| 			let r = await tfrpc.rpc.query( | ||||
| 				` | ||||
| 					WITH mine AS (SELECT messages.* | ||||
| 						FROM messages | ||||
| 						WHERE messages.author = ? | ||||
| 						ORDER BY sequence DESC | ||||
| 						LIMIT 20) | ||||
| 					SELECT messages.* | ||||
| 						FROM mine | ||||
| 						JOIN messages_refs ON mine.id = messages_refs.ref | ||||
| 						JOIN messages ON messages_refs.message = messages.id | ||||
| 					UNION | ||||
| 					SELECT * FROM mine | ||||
| 				`, | ||||
| 				[ | ||||
| 					this.hash.substring(1), | ||||
| 				]); | ||||
| 			return r; | ||||
| 		} else if (this.hash.startsWith('#%')) { | ||||
| 			return await tfrpc.rpc.query( | ||||
| 				` | ||||
| 					SELECT messages.* | ||||
| 					FROM messages | ||||
| 					WHERE id = ?1 | ||||
| 					UNION | ||||
| 					SELECT messages.* | ||||
| 					FROM messages JOIN messages_refs | ||||
| 					ON messages.id = messages_refs.message | ||||
| 					WHERE messages_refs.ref = ?1 | ||||
| 				`, | ||||
| 				[ | ||||
| 					this.hash.substring(1), | ||||
| 				]); | ||||
| 		} else { | ||||
| 			return await tfrpc.rpc.query( | ||||
| 				` | ||||
| 					WITH news AS (SELECT messages.* | ||||
| 					FROM messages | ||||
| 					JOIN json_each(?) AS following ON messages.author = following.value | ||||
| 					WHERE messages.timestamp > ? | ||||
| 					ORDER BY messages.timestamp DESC) | ||||
| 					SELECT messages.* | ||||
| 						FROM news | ||||
| 						JOIN messages_refs ON news.id = messages_refs.ref | ||||
| 						JOIN messages ON messages_refs.message = messages.id | ||||
| 					UNION | ||||
| 					SELECT messages.* | ||||
| 						FROM news | ||||
| 						JOIN messages_refs ON news.id = messages_refs.message | ||||
| 						JOIN messages ON messages_refs.ref = messages.id | ||||
| 					UNION | ||||
| 					SELECT news.* FROM news | ||||
| 				`, | ||||
| 				[ | ||||
| 					JSON.stringify(this.following), | ||||
| 					new Date().valueOf() - 24 * 60 * 60 * 1000, | ||||
| 				]); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	render() { | ||||
| 		if (!this.messages || | ||||
| 			this._messages_hash !== this.hash || | ||||
| 			this._messages_following !== this.following) { | ||||
| 			console.log(`loading messages for ${this.whoami}`); | ||||
| 			let self = this; | ||||
| 			this.messages = []; | ||||
| 			this._messages_hash = this.hash; | ||||
| 			this._messages_following = this.following; | ||||
| 			this.fetch_messages().then(function(messages) { | ||||
| 				self.messages = messages; | ||||
| 				console.log(`loading mesages done for ${self.whoami}`); | ||||
| 			}).catch(function(error) { | ||||
| 				alert(JSON.stringify(error, null, 2)); | ||||
| 			}); | ||||
| 		} | ||||
| 		return html`<tf-news id="news" whoami=${this.whoami} .users=${this.users} .messages=${this.messages} .following=${this.following}></tf-news>`; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| class TfTabNewsElement extends LitElement { | ||||
| 	static get properties() { | ||||
| 		return { | ||||
| 			whoami: {type: String}, | ||||
| 			users: {type: Object}, | ||||
| 			hash: {type: String}, | ||||
| 			unread: {type: Array}, | ||||
| 			following: {type: Array}, | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	static styles = styles; | ||||
|  | ||||
| 	constructor() { | ||||
| 		super(); | ||||
| 		let self = this; | ||||
| 		this.whoami = null; | ||||
| 		this.users = {}; | ||||
| 		this.hash = '#'; | ||||
| 		this.unread = []; | ||||
| 		this.following = []; | ||||
| 		this.cache = {}; | ||||
| 	} | ||||
|  | ||||
| 	show_more() { | ||||
| 		let unread = this.unread; | ||||
| 		let news = this.renderRoot?.getElementById('news'); | ||||
| 		if (news) { | ||||
| 			console.log('injecting messages', news.messages); | ||||
| 			news.messages = Object.values(Object.fromEntries([...this.unread, ...news.messages].map(x => [x.id, x]))); | ||||
| 			this.dispatchEvent(new CustomEvent('refresh')); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	new_messages_text() { | ||||
| 		if (!this.unread?.length) { | ||||
| 			return 'No new messages.'; | ||||
| 		} | ||||
| 		let counts = {}; | ||||
| 		for (let message of this.unread) { | ||||
| 			let type = 'private'; | ||||
| 			try { | ||||
| 				type = JSON.parse(message.content).type || type; | ||||
| 			} catch { | ||||
| 			} | ||||
| 			counts[type] = (counts[type] || 0) + 1; | ||||
| 		} | ||||
| 		return 'Show New: ' + Object.keys(counts).sort().map(x => (counts[x].toString() + ' ' + x + 's')).join(', '); | ||||
| 	} | ||||
|  | ||||
| 	render() { | ||||
| 		let profile = this.hash.startsWith('#@') ? | ||||
| 			html`<tf-profile id=${this.hash.substring(1)} whoami=${this.whoami} .users=${this.users}></tf-profile>` : undefined; | ||||
| 		return html` | ||||
| 			<div><input type="button" value=${this.new_messages_text()} @click=${this.show_more}></input></div> | ||||
| 			<a target="_top" href="#" ?hidden=${this.hash.length <= 1}>🏠Home</a> | ||||
| 			<div>Welcome, <tf-user id=${this.whoami} .users=${this.users}></tf-user>!</div> | ||||
| 			<div><tf-compose whoami=${this.whoami} .users=${this.users}></tf-compose></div> | ||||
| 			${profile} | ||||
| 			<tf-tab-news-feed id="news" whoami=${this.whoami} .users=${this.users} .following=${this.following} hash=${this.hash}></tf-tab-news-feed> | ||||
| 		`; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| customElements.define('tf-tab-news-feed', TfTabNewsFeedElement); | ||||
| customElements.define('tf-tab-news', TfTabNewsElement); | ||||
							
								
								
									
										73
									
								
								apps/cory/ssb/tf-tab-search.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								apps/cory/ssb/tf-tab-search.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | ||||
| import {LitElement, html, unsafeHTML} from './lit-all.min.js'; | ||||
| import * as tfrpc from '/static/tfrpc.js'; | ||||
| import {styles} from './tf-styles.js'; | ||||
|  | ||||
| class TfTabSearchElement extends LitElement { | ||||
| 	static get properties() { | ||||
| 		return { | ||||
| 			whoami: {type: String}, | ||||
| 			users: {type: Object}, | ||||
| 			following: {type: Array}, | ||||
| 			query: {type: String}, | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	static styles = styles; | ||||
|  | ||||
| 	constructor() { | ||||
| 		super(); | ||||
| 		let self = this; | ||||
| 		this.whoami = null; | ||||
| 		this.users = {}; | ||||
| 		this.following = []; | ||||
| 	} | ||||
|  | ||||
| 	async search(query) { | ||||
| 		console.log('Searching...', this.whoami, query); | ||||
| 		let search = this.renderRoot.getElementById('search'); | ||||
| 		if (search ) { | ||||
| 			search.value = query; | ||||
| 			search.focus(); | ||||
| 			search.select(); | ||||
| 		} | ||||
| 		await tfrpc.rpc.setHash('#q=' + encodeURIComponent(query)); | ||||
| 		let results = await tfrpc.rpc.query(` | ||||
| 				SELECT messages.* | ||||
| 				FROM messages_fts(?) | ||||
| 				JOIN messages ON messages.rowid = messages_fts.rowid | ||||
| 				JOIN json_each(?) AS following ON messages.author = following.value | ||||
| 				ORDER BY timestamp DESC limit 100 | ||||
| 			`, | ||||
| 			['"' + query.replace('"', '""') + '"', JSON.stringify(this.following)]); | ||||
| 		console.log('Done.'); | ||||
| 		search = this.renderRoot.getElementById('search'); | ||||
| 		if (search ) { | ||||
| 			search.value = query; | ||||
| 			search.focus(); | ||||
| 			search.select(); | ||||
| 		} | ||||
| 		this.renderRoot.getElementById('news').messages = results; | ||||
| 	} | ||||
|  | ||||
| 	search_keydown(event) { | ||||
| 		if (event.keyCode == 13) { | ||||
| 			this.query = this.renderRoot.getElementById('search').value; | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	render() { | ||||
| 		if (this.query !== this.last_query) { | ||||
| 			this.search(this.query); | ||||
| 		} | ||||
| 		let self = this; | ||||
| 		return html` | ||||
| 			<div style="display: flex; flex-direction: row"> | ||||
| 				<input type="text" id="search" value=${this.query} style="flex: 1" @keydown=${this.search_keydown}></input> | ||||
| 				<input type="button" value="Search" @click=${(event) => self.search(self.renderRoot.getElementById('search').value)}></input> | ||||
| 			</div> | ||||
| 			<tf-news id="news" whoami=${this.whoami} .messages=${this.messages} .users=${this.users}></tf-news> | ||||
| 		`; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| customElements.define('tf-tab-search', TfTabSearchElement); | ||||
							
								
								
									
										39
									
								
								apps/cory/ssb/tf-user.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								apps/cory/ssb/tf-user.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| import {LitElement, html} from './lit-all.min.js'; | ||||
| import * as tfrpc from '/static/tfrpc.js'; | ||||
| import {styles} from './tf-styles.js'; | ||||
|  | ||||
| class TfUserElement extends LitElement { | ||||
| 	static get properties() { | ||||
| 		return { | ||||
| 			id: {type: String}, | ||||
| 			users: {type: Object}, | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	static styles = styles; | ||||
|  | ||||
| 	constructor() { | ||||
| 		super(); | ||||
| 		this.id = null; | ||||
| 		this.users = {}; | ||||
| 	} | ||||
|  | ||||
| 	render() { | ||||
| 		if (this.users[this.id]) { | ||||
| 			let image = this.users[this.id].image; | ||||
| 			image = typeof(image) == 'string' ? image : image?.link; | ||||
| 			return html` | ||||
| 				<div style="display: inline-block; font-weight: bold"> | ||||
| 					<img style="width: 2em; height: 2em; vertical-align: middle; border-radius: 50%" ?hidden=${image === undefined} src="${image ? '/' + image + '/view' : undefined}"> | ||||
| 					<a target="_top" href=${'#' + this.id}>${this.users[this.id].name ?? this.id}</a> | ||||
| 				</div>`; | ||||
| 		} else { | ||||
| 			return html` | ||||
| 				<div style="display: inline-block; font-weight: bold; word-wrap: anywhere"> | ||||
| 					<a target="_top" href=${'#' + this.id}>${this.id}</a> | ||||
| 				</div>`; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| customElements.define('tf-user', TfUserElement); | ||||
							
								
								
									
										48
									
								
								apps/cory/ssb/tf-utils.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								apps/cory/ssb/tf-utils.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| import * as linkify from './commonmark-linkify.js'; | ||||
| import * as hashtagify from './commonmark-hashtag.js'; | ||||
|  | ||||
| export function markdown(md) { | ||||
| 	var reader = new commonmark.Parser({safe: true}); | ||||
| 	var writer = new commonmark.HtmlRenderer(); | ||||
| 	var parsed = reader.parse(md || ''); | ||||
| 	parsed = hashtagify.transform(parsed); | ||||
| 	parsed = linkify.transform(parsed); | ||||
| 	var walker = parsed.walker(); | ||||
| 	var event, node; | ||||
| 	while ((event = walker.next())) { | ||||
| 		node = event.node; | ||||
| 		if (event.entering) { | ||||
| 			if (node.type == 'link') { | ||||
| 				if (node.destination.startsWith('@') && | ||||
| 					node.destination.endsWith('.ed25519')) { | ||||
| 					node.destination = '#' + node.destination; | ||||
| 				} else if (node.destination.startsWith('%') && | ||||
| 					node.destination.endsWith('.sha256')) { | ||||
| 					node.destination = '#' + node.destination; | ||||
| 				} else if (node.destination.startsWith('&') && | ||||
| 					node.destination.endsWith('.sha256')) { | ||||
| 					node.destination = '/' + node.destination + '/view'; | ||||
| 				} | ||||
| 			} else if (node.type == 'image') { | ||||
| 				if (node.destination.startsWith('&')) { | ||||
| 					node.destination = '/' + node.destination + '/view'; | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	return writer.render(parsed); | ||||
| } | ||||
|  | ||||
| export function human_readable_size(bytes) { | ||||
| 	let v = bytes; | ||||
| 	let u = 'B'; | ||||
| 	for (let unit of ['kB', 'MB', 'GB']) { | ||||
| 		if (v > 1024) { | ||||
| 			v /= 1024; | ||||
| 			u = unit; | ||||
| 		} else { | ||||
| 			break; | ||||
| 		} | ||||
| 	} | ||||
| 	return `${Math.round(v * 10) / 10} ${u}`; | ||||
| } | ||||
							
								
								
									
										32
									
								
								apps/cory/ssb/tribute.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								apps/cory/ssb/tribute.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| .tribute-container { | ||||
|   position: absolute; | ||||
|   top: 0; | ||||
|   left: 0; | ||||
|   height: auto; | ||||
|   overflow: auto; | ||||
|   display: block; | ||||
|   z-index: 999999; | ||||
| } | ||||
| .tribute-container ul { | ||||
|   margin: 0; | ||||
|   margin-top: 2px; | ||||
|   padding: 0; | ||||
|   list-style: none; | ||||
|   background: #efefef; | ||||
| } | ||||
| .tribute-container li { | ||||
|   padding: 5px 5px; | ||||
|   cursor: pointer; | ||||
| } | ||||
| .tribute-container li.highlight { | ||||
|   background: #ddd; | ||||
| } | ||||
| .tribute-container li span { | ||||
|   font-weight: bold; | ||||
| } | ||||
| .tribute-container li.no-match { | ||||
|   cursor: default; | ||||
| } | ||||
| .tribute-container .menu-highlighted { | ||||
|   font-weight: bold; | ||||
| } | ||||
							
								
								
									
										1797
									
								
								apps/cory/ssb/tribute.esm.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1797
									
								
								apps/cory/ssb/tribute.esm.js
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
		Reference in New Issue
	
	Block a user