Add issues app.
git-svn-id: https://www.unprompted.com/svn/projects/tildefriends/trunk@4435 ed5197a5-7fde-0310-b194-c3ffbd925b24
This commit is contained in:
		
							
								
								
									
										4
									
								
								apps/issues.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								apps/issues.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| { | ||||
|   "type": "tildefriends-app", | ||||
|   "emoji": "📦" | ||||
| } | ||||
							
								
								
									
										105
									
								
								apps/issues/app.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								apps/issues/app.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,105 @@ | ||||
| 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 getStoredConnections() { | ||||
| 	return ssb.storedConnections(); | ||||
| }); | ||||
| tfrpc.register(async function forgetStoredConnection(connection) { | ||||
| 	return ssb.forgetStoredConnection(connection); | ||||
| }); | ||||
| tfrpc.register(async function createTunnel(portal, target) { | ||||
| 	return ssb.createTunnel(portal, target); | ||||
| }); | ||||
| 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.sqlAsync(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(async function store_message(message) { | ||||
| 	return await ssb.storeMessage(message); | ||||
| }); | ||||
| tfrpc.register(function apps() { | ||||
| 	return core.apps(); | ||||
| }); | ||||
| tfrpc.register(async function try_decrypt(id, content) { | ||||
| 	return await ssb.privateMessageDecrypt(id, content); | ||||
| }); | ||||
| 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(); | ||||
							
								
								
									
										91
									
								
								apps/issues/commonmark-linkify.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								apps/issues/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/issues/commonmark.min.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								apps/issues/commonmark.min.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										14
									
								
								apps/issues/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								apps/issues/index.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| <!DOCTYPE html> | ||||
| <html style="color: #fff"> | ||||
| 	<head> | ||||
| 		<title>Tilde Friends</title> | ||||
| 		<base target="_top"> | ||||
| 	</head> | ||||
| 	<body> | ||||
| 		<tf-issues-app/> | ||||
| 		<script>window.litDisableBundleWarning = true;</script> | ||||
| 		<script src="commonmark.min.js"></script> | ||||
| 		<script src="commonmark-linkify.js" type="module"></script> | ||||
| 		<script src="script.js" type="module"></script> | ||||
| 	</body> | ||||
| </html> | ||||
							
								
								
									
										120
									
								
								apps/issues/lit-all.min.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								apps/issues/lit-all.min.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										1
									
								
								apps/issues/lit-all.min.js.map
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								apps/issues/lit-all.min.js.map
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										258
									
								
								apps/issues/script.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										258
									
								
								apps/issues/script.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,258 @@ | ||||
| import {LitElement, html, unsafeHTML} from './lit-all.min.js'; | ||||
| import * as tfrpc from '/static/tfrpc.js'; | ||||
| import * as tfutils from './tf-utils.js'; | ||||
|  | ||||
| const k_project = '%Hr+4xEVtjplidSKBlRWi4Aw/0Tfw7B+1OR9BzlDKmOI=.sha256'; | ||||
|  | ||||
| class TfIdPickerElement extends LitElement { | ||||
| 	static get properties() { | ||||
| 		return { | ||||
| 			ids: {type: Array}, | ||||
| 			selected: {type: String}, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	constructor() { | ||||
| 		super(); | ||||
| 		this.load(); | ||||
| 	} | ||||
|  | ||||
| 	async load() { | ||||
| 		this.selected = await tfrpc.rpc.localStorageGet('whoami'); | ||||
| 		this.ids = (await tfrpc.rpc.getIdentities()) || []; | ||||
| 	} | ||||
|  | ||||
| 	changed(event) { | ||||
| 		this.selected = event.srcElement.value; | ||||
| 		tfrpc.rpc.localStorageSet('whoami', this.selected); | ||||
| 	} | ||||
|  | ||||
| 	render() { | ||||
| 		if (this.ids) { | ||||
| 			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> | ||||
| 			`; | ||||
| 		} else { | ||||
| 			return html`<div>Loading...</div>`; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
| customElements.define('tf-id-picker', TfIdPickerElement); | ||||
|  | ||||
| class TfComposeElement extends LitElement { | ||||
| 	static get properties() { | ||||
| 		return { | ||||
| 			value: {type: String}, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	input() { | ||||
| 		let input = this.renderRoot.getElementById('input'); | ||||
| 		let preview = this.renderRoot.getElementById('preview'); | ||||
| 		if (input && preview) { | ||||
| 			preview.innerHTML = tfutils.markdown(input.value); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	submit() { | ||||
| 		this.dispatchEvent(new CustomEvent('tf-submit', { | ||||
| 			bubbles: true, | ||||
| 			composed: true, | ||||
| 			detail: { | ||||
| 				value: this.renderRoot.getElementById('input').value, | ||||
| 			}, | ||||
| 		})); | ||||
| 		this.renderRoot.getElementById('input').value = ''; | ||||
| 		this.input(); | ||||
| 	} | ||||
|  | ||||
| 	render() { | ||||
| 		return html` | ||||
| 			<div style="display: flex; flex-direction: row"> | ||||
| 				<textarea id="input" @input=${this.input} style="flex: 1 1">${this.value}</textarea> | ||||
| 				<div id="preview" style="flex: 1 1"></div> | ||||
| 			</div> | ||||
| 			<input type="submit" value="Submit" @click=${this.submit}></input> | ||||
| 		`; | ||||
| 	} | ||||
| } | ||||
| customElements.define('tf-compose', TfComposeElement); | ||||
|  | ||||
| class TfIssuesAppElement extends LitElement { | ||||
| 	static get properties() { | ||||
| 		return { | ||||
| 			issues: {type: Array}, | ||||
| 			selected: {type: Object}, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	constructor() { | ||||
| 		super(); | ||||
| 		this.issues = []; | ||||
| 		this.load(); | ||||
| 	} | ||||
|  | ||||
| 	async load() { | ||||
| 		let issues = {}; | ||||
| 		let messages = await tfrpc.rpc.query(` | ||||
| 			WITH issues AS (SELECT messages.* FROM messages_refs JOIN messages ON | ||||
| 				messages.id = messages_refs.message | ||||
| 				WHERE messages_refs.ref = ? AND json_extract(messages.content, '$.type') = 'issue'), | ||||
| 			edits AS (SELECT messages.* FROM issues JOIN messages_refs ON | ||||
| 				issues.id = messages_refs.ref JOIN messages ON | ||||
| 				messages.id = messages_refs.message | ||||
| 				WHERE json_extract(messages.content, '$.type') IN ('issue-edit', 'post')) | ||||
| 			SELECT * FROM issues | ||||
| 			UNION | ||||
| 			SELECT * FROM edits ORDER BY timestamp | ||||
| 		`, [k_project]); | ||||
| 		for (let message of messages) { | ||||
| 			let content = JSON.parse(message.content); | ||||
| 			switch (content.type) { | ||||
| 				case 'issue': | ||||
| 					issues[message.id] = { | ||||
| 						id: message.id, | ||||
| 						author: message.author, | ||||
| 						text: content.text, | ||||
| 						updates: [], | ||||
| 						created: message.timestamp, | ||||
| 						open: true, | ||||
| 					}; | ||||
| 					break; | ||||
| 				case 'issue-edit': | ||||
| 				case 'post': | ||||
| 					for (let issue of (content.issues || [])) { | ||||
| 						if (issues[issue.link]) { | ||||
| 							if (issue.open !== undefined) { | ||||
| 								issues[issue.link].open = issue.open; | ||||
| 								message.open = issue.open; | ||||
| 							} | ||||
| 							issues[issue.link].updates.push(message); | ||||
| 							issues[issue.link].updated = message.timestamp; | ||||
| 						} | ||||
| 					} | ||||
| 					break; | ||||
| 			} | ||||
| 		} | ||||
| 		this.issues = Object.values(issues).sort((x, y) => y.created - x.created); | ||||
| 		if (this.selected) { | ||||
| 			for (let issue of this.issues) { | ||||
| 				if (issue.id == this.selected.id) { | ||||
| 					this.selected = issue; | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	render_issue_table_row(issue) { | ||||
| 		return html` | ||||
| 			<tr> | ||||
| 				<td>${issue.open ? 'open' : 'closed'}</td> | ||||
| 				<td style="max-width: 8em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis">${issue.author}</td> | ||||
| 				<td style="max-width: 40em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; cursor: pointer" @click=${() => this.selected = issue}> | ||||
| 					${issue.text.split('\n')?.[0]} | ||||
| 				</td> | ||||
| 				<td>${new Date(issue.created).toLocaleDateString()}</td> | ||||
| 			</tr> | ||||
| 		`; | ||||
| 	} | ||||
|  | ||||
| 	render_update(update) { | ||||
| 		let content = JSON.parse(update.content); | ||||
| 		let message; | ||||
| 		if (content.text) { | ||||
| 			message = unsafeHTML(tfutils.markdown(content.text)); | ||||
| 		} | ||||
| 		return html` | ||||
| 			<div style="border-left: 2px solid #fff; padding-left: 8px"> | ||||
| 				<div>${new Date(update.timestamp).toLocaleString()}</div> | ||||
| 				<div>${update.author}</div> | ||||
| 				<div>${message}</div> | ||||
| 				<div>${update.open !== undefined ? (update.open ? 'issue opened' : 'issue closed') : undefined}</div> | ||||
| 			</div> | ||||
| 		`; | ||||
| 	} | ||||
|  | ||||
| 	async set_open(id, open) { | ||||
| 		if (confirm(`Are you sure you want to ${open ? 'open' : 'close'} this issue?`)) { | ||||
| 			let whoami = this.shadowRoot.getElementById('picker').selected; | ||||
| 			await tfrpc.rpc.appendMessage(whoami, { | ||||
| 				type: 'issue-edit', | ||||
| 				issues: [ | ||||
| 					{ | ||||
| 						link: id, | ||||
| 						open: open, | ||||
| 					}, | ||||
| 				], | ||||
| 			}); | ||||
| 			await this.load(); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	async create_issue(event) { | ||||
| 		let whoami = this.shadowRoot.getElementById('picker').selected; | ||||
| 		await tfrpc.rpc.appendMessage(whoami, { | ||||
| 			type: 'issue', | ||||
| 			project: k_project, | ||||
| 			text: event.detail.value, | ||||
| 		}); | ||||
| 		await this.load(); | ||||
| 	} | ||||
|  | ||||
| 	async reply_to_issue(event) { | ||||
| 		let whoami = this.shadowRoot.getElementById('picker').selected; | ||||
| 		await tfrpc.rpc.appendMessage(whoami, { | ||||
| 			type: 'post', | ||||
| 			text: event.detail.value, | ||||
| 			issues: [ | ||||
| 				{ | ||||
| 					link: this.selected.id, | ||||
| 				}, | ||||
| 			], | ||||
| 		}); | ||||
| 		await this.load(); | ||||
| 	} | ||||
|  | ||||
| 	render() { | ||||
| 		let header = html` | ||||
| 			<h1>Tilde Friends Issues</h1> | ||||
| 			<tf-id-picker id="picker"></tf-id-picker> | ||||
| 		`; | ||||
| 		if (this.selected) { | ||||
| 			return html` | ||||
| 				${header} | ||||
| 				<div> | ||||
| 					<input type="button" value="Back" @click=${() => this.selected = undefined}></input> | ||||
| 					${this.selected.open ? | ||||
| 						html`<input type="button" value="Close Issue" @click=${() => this.set_open(this.selected.id, false)}></input>` : | ||||
| 						html`<input type="button" value="Reopen Issue" @click=${() => this.set_open(this.selected.id, true)}></input>`} | ||||
| 				</div> | ||||
| 				<div>${new Date(this.selected.created).toLocaleString()}</div> | ||||
| 				<div>${this.selected.author}</div> | ||||
| 				<div>${this.selected.id}</div> | ||||
| 				<div>${unsafeHTML(tfutils.markdown(this.selected.text))}</div> | ||||
| 				${this.selected.updates.map(x => this.render_update(x))} | ||||
| 				<tf-compose @tf-submit=${this.reply_to_issue}></tf-compose> | ||||
| 			`; | ||||
| 		} else { | ||||
| 			return html` | ||||
| 				${header} | ||||
| 				<h2>New Issue</h2> | ||||
| 				<tf-compose @tf-submit=${this.create_issue}></tf-compose> | ||||
| 				<table> | ||||
| 					<tr> | ||||
| 						<th>Status</th> | ||||
| 						<th>Author</th> | ||||
| 						<th>Title</th> | ||||
| 						<th>Date</th> | ||||
| 					</tr> | ||||
| 					${this.issues.map(x => this.render_issue_table_row(x))} | ||||
| 				</table> | ||||
| 			`; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| customElements.define('tf-issues-app', TfIssuesAppElement); | ||||
							
								
								
									
										91
									
								
								apps/issues/tf-utils.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								apps/issues/tf-utils.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,91 @@ | ||||
| import * as linkify from './commonmark-linkify.js'; | ||||
|  | ||||
| function image(node, entering) { | ||||
| 	if (node.firstChild?.type === 'text' && | ||||
| 		node.firstChild.literal.startsWith('video:')) { | ||||
| 		if (entering) { | ||||
| 			this.lit('<video style="max-width: 100%; max-height: 480px" title="' + this.esc(node.firstChild?.literal) + '" controls>'); | ||||
| 			this.lit('<source src="' + this.esc(node.destination) + '"></source>'); | ||||
| 			this.disableTags += 1; | ||||
| 		} else { | ||||
| 			this.disableTags -= 1; | ||||
| 			this.lit('</video>'); | ||||
| 		} | ||||
| 	} else if (node.firstChild?.type === 'text' && | ||||
| 		node.firstChild.literal.startsWith('audio:')) { | ||||
| 		if (entering) { | ||||
| 			this.lit('<audio style="height: 32px; max-width: 100%" title="' + this.esc(node.firstChild?.literal) + '" controls>'); | ||||
| 			this.lit('<source src="' + this.esc(node.destination) + '"></source>'); | ||||
| 			this.disableTags += 1; | ||||
| 		} else { | ||||
| 			this.disableTags -= 1; | ||||
| 			this.lit('</audio>'); | ||||
| 		} | ||||
| 	} else { | ||||
| 		if (entering) { | ||||
| 			if (this.disableTags === 0) { | ||||
| 				this.lit('<div class="img_caption">' + this.esc(node.firstChild?.literal || node.destination) + '</div>'); | ||||
| 				if (this.options.safe && potentiallyUnsafe(node.destination)) { | ||||
| 					this.lit('<img src="" alt="'); | ||||
| 				} else { | ||||
| 					this.lit('<img src="' + this.esc(node.destination) + '" alt="'); | ||||
| 				} | ||||
| 			} | ||||
| 			this.disableTags += 1; | ||||
| 		} else { | ||||
| 			this.disableTags -= 1; | ||||
| 			if (this.disableTags === 0) { | ||||
| 				if (node.title) { | ||||
| 					this.lit('" title="' + this.esc(node.title)); | ||||
| 				} | ||||
| 				this.lit('" />'); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| export function markdown(md) { | ||||
| 	var reader = new commonmark.Parser({safe: true}); | ||||
| 	var writer = new commonmark.HtmlRenderer(); | ||||
| 	writer.image = image; | ||||
| 	var parsed = reader.parse(md || ''); | ||||
| 	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}`; | ||||
| } | ||||
		Reference in New Issue
	
	Block a user