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