├── client ├── import-env.js ├── intents │ ├── native-intent.css │ ├── native-intent.js │ └── create-feed.html ├── fonts.css ├── envoyager.css ├── el │ └── envoyager │ │ ├── main.js │ │ ├── 404.js │ │ ├── reload.js │ │ ├── box.js │ │ ├── card.js │ │ ├── root-route.js │ │ ├── create-feed.js │ │ ├── loading.js │ │ ├── intent-action.js │ │ ├── show-identity.js │ │ ├── feed-list.js │ │ ├── header.js │ │ ├── image-drop.js │ │ ├── create-identity.js │ │ ├── intent-list-modal.js │ │ └── item-card.js ├── db │ ├── router.js │ ├── identities.js │ ├── navigation.js │ └── model.js ├── form-styles.js ├── envoyager.js └── button-styles.js ├── README.md ├── fonts └── Lexend-VariableFont_wght.ttf ├── src ├── save-json.js ├── rel.js ├── profile-data.js ├── load-json.js ├── preload-webview.js ├── preload.js ├── index-wrapper.cjs ├── key-management.js ├── ipfs-handler.js ├── index.js ├── ipfs-node.js ├── data-source.js └── intents.js ├── lib ├── rel.mjs └── envoy-feed.mjs ├── img ├── feed-icon.svg ├── install-intent-icon.svg ├── maxi-icon.svg └── save-icon.svg ├── index.html ├── LICENSE ├── package.json ├── TODO.md ├── .gitignore ├── INTENTS.md └── deps └── lit.js /client/import-env.js: -------------------------------------------------------------------------------- 1 | 2 | window.litDisableBundleWarning = true; 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Envoyage 3 | 4 | Experimenting with user agency. 5 | -------------------------------------------------------------------------------- /client/intents/native-intent.css: -------------------------------------------------------------------------------- 1 | 2 | @import url(../envoyager.css); 3 | -------------------------------------------------------------------------------- /fonts/Lexend-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/darobin/envoyager/main/fonts/Lexend-VariableFont_wght.ttf -------------------------------------------------------------------------------- /client/fonts.css: -------------------------------------------------------------------------------- 1 | 2 | @font-face { 3 | font-family: "Lexend"; 4 | src: url("../fonts/Lexend-VariableFont_wght.ttf"); 5 | font-style: normal; 6 | } 7 | -------------------------------------------------------------------------------- /src/save-json.js: -------------------------------------------------------------------------------- 1 | 2 | import { writeFile } from 'fs/promises'; 3 | 4 | export default async function saveJSON (url, obj) { 5 | return writeFile(url, JSON.stringify(obj, null, 2)); 6 | } 7 | -------------------------------------------------------------------------------- /client/intents/native-intent.js: -------------------------------------------------------------------------------- 1 | 2 | // import that just runs whatever must be set before the other imports load 3 | import '../import-env.js' 4 | 5 | import '../el/envoyager/create-feed.js'; 6 | import '../el/envoyager/reload.js'; 7 | -------------------------------------------------------------------------------- /lib/rel.mjs: -------------------------------------------------------------------------------- 1 | 2 | // call with makeRel(import.meta.url), returns a function that resolves relative paths 3 | export default function makeRel (importURL) { 4 | return (pth) => new URL(pth, importURL).toString().replace(/^file:\/\//, ''); 5 | } 6 | -------------------------------------------------------------------------------- /src/rel.js: -------------------------------------------------------------------------------- 1 | 2 | // call with makeRel(import.meta.url), returns a function that resolves relative paths 3 | export default function makeRel (importURL) { 4 | return (pth) => new URL(pth, importURL).toString().replace(/^file:\/\//, ''); 5 | } 6 | -------------------------------------------------------------------------------- /src/profile-data.js: -------------------------------------------------------------------------------- 1 | 2 | import { homedir } from 'os'; 3 | import { join } from 'path'; 4 | 5 | export const dataDir = join(homedir(), '.envoyager'); 6 | export const identitiesDir = join(dataDir, 'identities'); 7 | 8 | export function did2keyDir (did) { 9 | return join(identitiesDir, encodeURIComponent(did), 'keys'); 10 | } 11 | -------------------------------------------------------------------------------- /src/load-json.js: -------------------------------------------------------------------------------- 1 | 2 | import { readFile } from 'fs/promises'; 3 | 4 | export default async function loadJSON (url) { 5 | const data = await readFile(url); 6 | return new Promise((resolve, reject) => { 7 | try { 8 | resolve(JSON.parse(data)); 9 | } 10 | catch (err) { 11 | reject(err); 12 | } 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /client/envoyager.css: -------------------------------------------------------------------------------- 1 | 2 | @import url(./fonts.css); 3 | 4 | :root { 5 | --heading-font: Lexend; 6 | --highlight: lightgreen; 7 | --error: #ff4500; 8 | --lightest: rgb(239, 243, 244); 9 | --lightest-bg: rgb(0, 0, 0, 0.03); 10 | } 11 | 12 | html, body { 13 | margin: 0; 14 | padding: 0; 15 | } 16 | 17 | * { 18 | box-sizing: border-box; 19 | } 20 | -------------------------------------------------------------------------------- /img/feed-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /client/el/envoyager/main.js: -------------------------------------------------------------------------------- 1 | 2 | import { LitElement, css, html } from '../../../deps/lit.js'; 3 | 4 | class EnvoyagerMain extends LitElement { 5 | static styles = css` 6 | :host { 7 | display: block; 8 | } 9 | `; 10 | 11 | constructor () { 12 | super(); 13 | } 14 | 15 | render () { 16 | return html`
17 | Drop Envoy here. 18 | 19 |
`; 20 | } 21 | } 22 | customElements.define('nv-main', EnvoyagerMain); 23 | -------------------------------------------------------------------------------- /img/install-intent-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /client/el/envoyager/404.js: -------------------------------------------------------------------------------- 1 | 2 | import { LitElement, css, html } from '../../../deps/lit.js'; 3 | 4 | class Envoyager404 extends LitElement { 5 | static styles = css` 6 | :host { 7 | display: block; 8 | color: #f00; 9 | text-align: center; 10 | padding: 1.5rem 1rem 1rem 1rem; 11 | } 12 | `; 13 | 14 | constructor () { 15 | super(); 16 | } 17 | 18 | render () { 19 | return html`

Not all those who wander are lost, but it looks like you are.

`; 20 | } 21 | } 22 | customElements.define('nv-404', Envoyager404); 23 | -------------------------------------------------------------------------------- /client/db/router.js: -------------------------------------------------------------------------------- 1 | 2 | import { registerStore, getStore, derived } from './model.js'; 3 | 4 | const defaultValue = { screen: undefined }; 5 | const store = derived( 6 | [ 7 | getStore('identities'), 8 | getStore('navigation'), 9 | ], 10 | updateRoute, 11 | defaultValue 12 | ) 13 | ; 14 | 15 | registerStore('router', store); 16 | 17 | function updateRoute ([identities, navigation]) { 18 | if (identities.state === 'loading') return { screen: 'app-loading' }; 19 | if (!identities.people.length) return { screen: 'create-identity' }; 20 | return navigation; 21 | } 22 | -------------------------------------------------------------------------------- /client/intents/create-feed.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Create Feed 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Envoyager 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /img/maxi-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /img/save-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /client/el/envoyager/reload.js: -------------------------------------------------------------------------------- 1 | 2 | import { LitElement, css, html } from '../../../deps/lit.js'; 3 | 4 | class EnvoyagerReload extends LitElement { 5 | static styles = css` 6 | :host { 7 | display: block; 8 | position: fixed; 9 | bottom: 0; 10 | right: 0; 11 | opacity: 0.5; 12 | } 13 | button { 14 | border: none; 15 | font-size: 0.8rem; 16 | } 17 | `; 18 | 19 | constructor () { 20 | super(); 21 | } 22 | 23 | reload () { 24 | document.location.reload(); 25 | } 26 | 27 | render () { 28 | return html``; 29 | } 30 | } 31 | customElements.define('nv-reload', EnvoyagerReload); 32 | -------------------------------------------------------------------------------- /src/preload-webview.js: -------------------------------------------------------------------------------- 1 | 2 | const { contextBridge, ipcRenderer } = require('electron'); 3 | const { invoke, sendToHost } = ipcRenderer; 4 | 5 | // XXX note that this will cause some weird issues, we're not set up to manage this well 6 | // from inside items yet 7 | let intentID = 1; 8 | 9 | contextBridge.exposeInMainWorld('envoyager',{ 10 | // 🚨🚨🚨 SHARED WITH PRELOADS 🚨🚨🚨 11 | // always copy changes here over there 12 | intent: (action, type, data) => { 13 | const id = 'x' + intentID++; 14 | invoke('intent:show-matching-intents', action, type, data, id); 15 | return id; 16 | }, 17 | signalIntentCancelled: () => { 18 | sendToHost('intent-cancelled'); 19 | }, 20 | signalCreateFeed: (data) => { 21 | sendToHost('create-feed', data); 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /client/db/identities.js: -------------------------------------------------------------------------------- 1 | 2 | import { registerStore, writable } from './model.js'; 3 | 4 | const defaultValue = { state: 'initial', people: [], err: null }; 5 | const store = writable(defaultValue); 6 | 7 | registerStore('identities', store); 8 | 9 | export async function initIdentities () { 10 | store.set({ state: 'loading', people: [] }); 11 | try { 12 | const ipnsList = await window.envoyager.loadIdentities(); 13 | const resList = await Promise.all(ipnsList.map(({ ipns }) => fetch(`ipns://${ipns}`, { headers: { Accept: 'application/json' }}))); 14 | const people = (await Promise.all(resList.map(r => r.json()))).map((p, idx) => ({...p, url: `ipns://${ipnsList[idx].ipns}`})); 15 | store.set({ state: 'loaded', people }); 16 | } 17 | catch (err) { 18 | store.set({ state: 'error', people: [], err }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/preload.js: -------------------------------------------------------------------------------- 1 | 2 | const { contextBridge, ipcRenderer } = require('electron'); 3 | const { invoke } = ipcRenderer; 4 | let intentID = 1; 5 | 6 | contextBridge.exposeInMainWorld('envoyager',{ 7 | // identities 8 | loadIdentities: () => invoke('identities:load'), 9 | createIdentity: (data) => invoke('identities:create', data), 10 | // saveIdentity: (person) => invoke('identities:save', person), 11 | // deleteIdentity: (did) => invoke('identities:delete', did), 12 | // intents 13 | onIntentList: (cb) => ipcRenderer.on('intent-list', cb), 14 | // 🚨🚨🚨 SHARED WITH PRELOADS 🚨🚨🚨 15 | // always copy changes here over there 16 | intent: (action, type, data) => { 17 | const id = intentID++; 18 | invoke('intent:show-matching-intents', action, type, data, id); 19 | return id; 20 | }, 21 | createFeed: (data) => invoke('intent:create-feed', data), 22 | }); 23 | -------------------------------------------------------------------------------- /client/form-styles.js: -------------------------------------------------------------------------------- 1 | 2 | import { css } from '../deps/lit.js'; 3 | 4 | export const formStyles = css` 5 | .form-line { 6 | display: flex; 7 | flex-wrap: wrap; 8 | justify-content: space-between; 9 | margin-top: 1rem; 10 | } 11 | .form-action { 12 | text-align: right; 13 | padding: 1px; 14 | margin-top: 2rem; 15 | } 16 | label { 17 | font-family: var(--heading-font); 18 | font-variation-settings: "wght" 400; 19 | } 20 | input { 21 | border: none; 22 | border-bottom: 1px solid #ccc; 23 | outline: none; 24 | transition: all 0.5s; 25 | } 26 | input:focus { 27 | border-color: var(--highlight); 28 | } 29 | input:not(:blank):invalid { 30 | border-color: var(--error); 31 | } 32 | .form-line > label { 33 | flex-basis: 150px; 34 | } 35 | .form-line > input { 36 | flex-grow: 1; 37 | } 38 | .error-message { 39 | color: var(--error); 40 | margin-top: 1rem; 41 | margin-left: var(--left-pad); 42 | } 43 | `; 44 | -------------------------------------------------------------------------------- /client/envoyager.js: -------------------------------------------------------------------------------- 1 | 2 | // import that just runs whatever must be set before the other imports load 3 | import './import-env.js' 4 | 5 | // state 6 | import './db/model.js'; 7 | import { initIdentities } from './db/identities.js'; 8 | import './db/navigation.js'; 9 | import './db/router.js'; 10 | 11 | // elements 12 | import './el/envoyager/404.js'; 13 | import './el/envoyager/header.js'; 14 | import './el/envoyager/card.js'; 15 | import './el/envoyager/main.js'; 16 | import './el/envoyager/root-route.js'; 17 | import './el/envoyager/create-identity.js'; 18 | import './el/envoyager/show-identity.js'; 19 | import './el/envoyager/box.js'; 20 | import './el/envoyager/image-drop.js'; 21 | import './el/envoyager/loading.js'; 22 | import './el/envoyager/feed-list.js'; 23 | import './el/envoyager/intent-list-modal.js'; 24 | import './el/envoyager/intent-action.js'; 25 | import './el/envoyager/item-card.js'; 26 | 27 | await initIdentities(); 28 | -------------------------------------------------------------------------------- /client/el/envoyager/box.js: -------------------------------------------------------------------------------- 1 | 2 | import { LitElement, css, html } from '../../../deps/lit.js'; 3 | 4 | class EnvoyagerBox extends LitElement { 5 | static properties = { 6 | title: String, 7 | }; 8 | 9 | static styles = css` 10 | :host { 11 | display: block; 12 | max-width: 100%; 13 | } 14 | header { 15 | color: #fff; 16 | border: 3px double #000; 17 | } 18 | h2 { 19 | background: #000; 20 | font-family: var(--heading-font); 21 | font-variation-settings: "wght" 150; 22 | letter-spacing: 2px; 23 | margin: 0; 24 | padding: 0.2rem 0.4rem; 25 | } 26 | `; 27 | 28 | constructor () { 29 | super(); 30 | this.title = 'Untitled Box' 31 | } 32 | 33 | render () { 34 | return html`
35 |

${this.title}

36 | 37 |
`; 38 | } 39 | } 40 | customElements.define('nv-box', EnvoyagerBox); 41 | -------------------------------------------------------------------------------- /src/index-wrapper.cjs: -------------------------------------------------------------------------------- 1 | 2 | const { protocol, app } = require('electron'); 3 | 4 | // I don't fully understand why but the esm load wrapper, together with the delayed import (which we can't await) 5 | // mean that whatever needs to be called before app init has to be called here. 6 | 7 | // I am not clear at all as to what the privileges mean. They are listed at 8 | // https://www.electronjs.org/docs/latest/api/structures/custom-scheme but that is harldy 9 | // informative. https://www.electronjs.org/docs/latest/api/protocol#protocolregisterschemesasprivilegedcustomschemes 10 | // is pretty clear that the behaviour we want requires at least `standard`. 11 | const privileges = { 12 | standard: true, 13 | secure: false, 14 | bypassCSP: false, 15 | allowServiceWorkers: false, 16 | supportFetchAPI: true, 17 | corsEnabled: false, 18 | stream: true, 19 | }; 20 | protocol.registerSchemesAsPrivileged([ 21 | { scheme: 'ipfs', privileges }, 22 | { scheme: 'ipns', privileges }, 23 | ]); 24 | app.enableSandbox(); 25 | 26 | 27 | require = require('esm')(module); 28 | module.exports = import("./index.js"); 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Robin Berjon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/db/navigation.js: -------------------------------------------------------------------------------- 1 | 2 | import { registerStore, writable } from './model.js'; 3 | 4 | const defaultValue = { screen: 'main', params: {} }; 5 | const store = writable(defaultValue); 6 | 7 | store.go = (screen = 'main', params = {}) => { 8 | let hash = screen; 9 | if (Object.keys(params).length) { 10 | hash += '|' + Object.keys(params).sort().map(k => `${k}=${encodeURIComponent(params[k])}`).join('&'); 11 | } 12 | if (window.location.hash === `#${hash}`) return; 13 | window.location.hash = `#${hash}`; 14 | store.set({ screen, params }); 15 | }; 16 | window.addEventListener('load', () => { 17 | if (window.location.hash) { 18 | const hash = window.location.hash.replace('#', ''); 19 | const [screen, rest] = hash.split('|'); 20 | const params = {}; 21 | if (rest) { 22 | rest.split('&').forEach(part => { 23 | const [k, v] = part.split('=', 2); 24 | params[k] = decodeURIComponent(v); 25 | }); 26 | } 27 | store.set({ screen, params }); 28 | } 29 | }) 30 | 31 | registerStore('navigation', store); 32 | // XXX on set this should change the hash, and on load it should restore from the hash 33 | -------------------------------------------------------------------------------- /client/el/envoyager/card.js: -------------------------------------------------------------------------------- 1 | 2 | import { LitElement, css, html } from '../../../deps/lit.js'; 3 | 4 | 5 | // webviewTag boolean (optional) - Whether to enable the tag. Defaults to false. Note: The preload script 6 | // configured for the will have node integration enabled when it is executed so you should ensure 7 | // remote/untrusted content is not able to create a tag with a possibly malicious preload script. You can 8 | // use the will-attach-webview event on webContents to strip away the preload script and to validate or alter the 9 | // 's initial settings. 10 | class EnvoyagerCard extends LitElement { 11 | static styles = css` 12 | :host { 13 | display: block; 14 | width: 100%; 15 | height: 100%; 16 | } 17 | div, webview { 18 | display: flex; 19 | width: 100%; 20 | height: 100%; 21 | } 22 | `; 23 | 24 | static properties = { 25 | src: { type: String }, 26 | }; 27 | 28 | constructor () { 29 | super(); 30 | } 31 | 32 | render () { 33 | return html`
`; 34 | } 35 | } 36 | customElements.define('nv-card', EnvoyagerCard); 37 | -------------------------------------------------------------------------------- /src/key-management.js: -------------------------------------------------------------------------------- 1 | 2 | import { subtle } from 'crypto'; 3 | import { join } from 'path'; 4 | import { readFile, writeFile } from "fs/promises"; 5 | 6 | const keyParams = { name: 'ECDA', namedCurve: 'P-521' }; 7 | const keyExtractable = true; 8 | const keyUsages = ['encrypt', 'decrypt', 'deriveKey', 'sign', 'verify']; 9 | 10 | // this might be useful but doesn't allow derivation as initially planned, using the built-in instead 11 | export async function dirCryptoKeyPair (personDir) { 12 | const privKeyFile = join(personDir, 'private.key'); 13 | const pubKeyFile = join(personDir, 'public.key'); 14 | try { 15 | const privateKey = await subtle.importKey('jwk', await readFile(privKeyFile), keyParams, keyExtractable, keyUsages); 16 | const publicKey = await subtle.importKey('jwk', await readFile(pubKeyFile), keyParams, keyExtractable, keyUsages); 17 | return { privateKey, publicKey }; 18 | } 19 | catch (err) { 20 | const keyPair = await subtle.generateKey(keyParams, keyExtractable, keyUsages); 21 | await writeFile(pubKeyFile, await subtle.exportKey('jwk', keyPair.publicKey)); 22 | await writeFile(privKeyFile, await subtle.exportKey('jwk', keyPair.privateKey)); 23 | return keyPair; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "envoyager", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "description": "Experimenting with user agency", 6 | "author": "Robin Berjon ", 7 | "license": "MIT", 8 | "scripts": { 9 | "start": "electron --trace-warnings .", 10 | "build": "npm exec electron-builder --mac" 11 | }, 12 | "bin": {}, 13 | "main": "src/index-wrapper.cjs", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/darobin/envoyage.git" 17 | }, 18 | "esm": { 19 | "await": true 20 | }, 21 | "eslintConfig": { 22 | "env": { 23 | "browser": true, 24 | "mocha": true, 25 | "es2021": true 26 | }, 27 | "extends": "eslint:recommended", 28 | "overrides": [], 29 | "parserOptions": { 30 | "ecmaVersion": "latest", 31 | "sourceType": "module" 32 | }, 33 | "rules": {} 34 | }, 35 | "devDependencies": { 36 | "eslint": "^8.26.0" 37 | }, 38 | "dependencies": { 39 | "bufferutil": "^4.0.7", 40 | "electron": "^21.2.3", 41 | "esm": "^3.2.25", 42 | "ipfs-core": "^0.17.0", 43 | "mime-types": "^2.1.35", 44 | "nanoid": "^4.0.0", 45 | "sanitize-filename": "^1.6.3", 46 | "utf-8-validate": "^5.0.10", 47 | "wasmagic": "^0.0.23" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /client/el/envoyager/root-route.js: -------------------------------------------------------------------------------- 1 | 2 | import { LitElement, html, css } from '../../../deps/lit.js'; 3 | import { getStore } from '../../db/model.js'; 4 | 5 | class EnvoyagerRootRoute extends LitElement { 6 | static properties = { 7 | screen: { attribute: false }, 8 | }; 9 | static styles = css` 10 | :host { 11 | display: block; 12 | } 13 | nv-loading { 14 | position: absolute; 15 | top: 0; 16 | } 17 | `; 18 | 19 | constructor () { 20 | super(); 21 | getStore('router').subscribe(({ screen = 'main', params = {} } = {}) => { 22 | this.screen = screen; 23 | this.params = params; 24 | }); 25 | } 26 | 27 | render () { 28 | console.warn(`rendering ${this.screen}(${JSON.stringify(this.params)})`); 29 | switch (this.screen) { 30 | case 'app-loading': 31 | return html``; 32 | case 'main': 33 | return html``; 34 | case 'create-identity': 35 | return html``; 36 | case 'show-identity': 37 | return html``; 38 | default: 39 | return html``; 40 | } 41 | } 42 | } 43 | customElements.define('nv-root-route', EnvoyagerRootRoute); 44 | -------------------------------------------------------------------------------- /client/button-styles.js: -------------------------------------------------------------------------------- 1 | 2 | import { css } from '../deps/lit.js'; 3 | 4 | export const buttonStyles = css` 5 | button { 6 | position: relative; 7 | margin: 0; 8 | padding: 0; 9 | min-width: 150px; 10 | height: 2.5em; 11 | line-height: 2.375em; 12 | font-family: var(--heading-font); 13 | font-size: 1em; 14 | font-weight: 200; 15 | font-variation-settings: "wght" 200; 16 | letter-spacing: 1px; 17 | cursor: pointer; 18 | border: none; 19 | border-radius: 0; 20 | user-select: none; 21 | background-image: none; 22 | transition: all 0.15s ease-in 0s; 23 | background: #000; 24 | color: #fff; 25 | } 26 | button[type="reset"] { 27 | border: 1px solid #000; 28 | background: #fff; 29 | color: #000; 30 | } 31 | button:hover, button:focus { 32 | color: var(--highlight); 33 | } 34 | button:active { 35 | background: var(--highlight); 36 | color: #000; 37 | } 38 | button span.icon { 39 | /* border-right: 1px solid #fff; */ 40 | display: inline-block; 41 | padding-right: 0.5rem; 42 | font-weight: 400; 43 | font-variation-settings: "wght" 400; 44 | color: var(--highlight); 45 | } 46 | button.small { 47 | min-width: 0; 48 | height: auto; 49 | line-height: initial; 50 | margin-left: 0.5rem; 51 | } 52 | button.discreet { 53 | background: transparent; 54 | opacity: 0.5; 55 | } 56 | button.discreet:hover { 57 | opacity: 1; 58 | } 59 | `; 60 | -------------------------------------------------------------------------------- /client/el/envoyager/create-feed.js: -------------------------------------------------------------------------------- 1 | 2 | import { LitElement, css, html, nothing } from '../../../deps/lit.js'; 3 | import { buttonStyles } from '../../button-styles.js'; 4 | import { formStyles } from '../../form-styles.js'; 5 | 6 | class EnvoyagerCreateFeed extends LitElement { 7 | static styles = [css` 8 | :host { 9 | display: block; 10 | } 11 | .form-line { 12 | margin-left: 1rem; 13 | margin-right: 1rem; 14 | } 15 | `, formStyles, buttonStyles]; 16 | 17 | constructor () { 18 | super(); 19 | } 20 | 21 | async formHandler (ev) { 22 | ev.preventDefault(); 23 | const fd = new FormData(ev.target); 24 | const data = {}; 25 | for (let [key, value] of fd.entries()) { 26 | data[key] = value; 27 | } 28 | console.warn(data); 29 | this.errMsg = data.name ? null : 'Name is required.'; 30 | 31 | if (this.errMsg) this.requestUpdate(); 32 | else { 33 | window.envoyager.signalCreateFeed(data); 34 | } 35 | } 36 | 37 | cancel () { 38 | window.envoyager.signalIntentCancelled(); 39 | } 40 | 41 | render () { 42 | const err = this.errMsg ? html`
${this.errMsg}
` : nothing; 43 | return html`
44 |
45 | 46 | 47 |
48 | ${err} 49 |
50 | 51 | 52 |
53 |
`; 54 | } 55 | } 56 | customElements.define('nv-create-feed', EnvoyagerCreateFeed); 57 | -------------------------------------------------------------------------------- /lib/envoy-feed.mjs: -------------------------------------------------------------------------------- 1 | 2 | import { create as createNode } from 'ipfs-core'; 3 | import { CID } from 'multiformats'; 4 | 5 | export default class EnvoyFeed { 6 | constructor (feed) { 7 | console.warn(`CTOR(${feed})[${typeof feed}]`); 8 | this.init = false; 9 | this.feed = feed; // the CID or IPNS 10 | this.feedData = { 11 | $type: 'feed', 12 | nextPage: null, // we'll add pagination at some point 13 | items: [], 14 | }; 15 | } 16 | async ensureInit () { 17 | if (this.init) return; 18 | this.node = await createNode(); 19 | this.init = true; 20 | } 21 | async loadFeed () { 22 | await this.ensureInit(); 23 | if (!this.feed) throw new Error(`No feed configured.`); 24 | const data = await this.node.dag.get(cnv(this.feed)); 25 | this.feedData = data.value; 26 | if (!this.feedData.items) this.feedData.items = []; 27 | return this.feedData; 28 | } 29 | async loadItem (cid) { 30 | await this.ensureInit(); 31 | const data = await this.node.dag.get(cnv(cid)); 32 | return data.value; 33 | } 34 | async publishFeed () { 35 | await this.ensureInit(); 36 | const cid = await this.node.dag.put(this.feedData); 37 | this.feed = cid.toString(); 38 | return this.feed; 39 | } 40 | async createMicroBlog (mb) { 41 | await this.ensureInit(); 42 | if (typeof mb === 'string') mb = { text: mb }; 43 | if (!mb.date) mb.date = new Date().toISOString(); 44 | mb.$type = 'text'; 45 | const cid = await this.node.dag.put(mb); 46 | return cid.toString(); 47 | } 48 | async publishMicroBlogToFeed (mb) { 49 | await this.ensureInit(); 50 | const cid = await this.createMicroBlog(mb); 51 | this.feedData.items.unshift(cid); 52 | return await this.publishFeed(); 53 | } 54 | } 55 | 56 | function cnv (cid) { 57 | if (typeof cid === 'string') return CID.parse(cid); 58 | return cid; 59 | } 60 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | 2 | # Things to do 3 | 4 | - [x] demonstrate loading from a fake protocol into an `iframe` 5 | - [x] create a `BrowserView` that's attached to an element that can load from `ipfs` 6 | - [ ] set up js-ipfs and integrate it such that it works 7 | - [ ] outside of the app (in scratch code) 8 | - [ ] publish a few small entries as their own blocks (CAR?) 9 | - [ ] publish a list of them as IPLD lists of links 10 | - [ ] resolve an IPNS to that list of links (and make it easy to update) 11 | - [ ] in the app 12 | - [ ] store some IPNS to pull from 13 | - [ ] render feed entries 14 | - [ ] pure text entry (or just MD?) 15 | - [ ] HTML+files entry, including metadata extraction to show in small and the full thing 16 | - [ ] make it easy to post new entries 17 | - [ ] pure text 18 | - [ ] a simple HTML+file variant 19 | - [ ] update IPLD list, including pagination 20 | - [ ] IPNS updating 21 | 22 | ## `BrowserView` woes 23 | 24 | Using `webview` isn't great but attaching `BrowserView` to an element is extremely painful at best, 25 | if it can even be made to work reliably without a big pile of hacks. 26 | 27 | Instead, we could have a dual mode: 28 | * `entry-card` when in embedded mode renders a summary of sorts 29 | * and when in full mode it runs as the full thing 30 | 31 | The downside is that this doesn't really give us composability. 32 | 33 | 34 | ## Later 35 | 36 | // - [ ] the IPNS needs to be made available for copying from the UI 37 | // - [ ] when we create an IPNS for feeds, we can also make a QR code for them, to be easily followed! 38 | 39 | 40 | - [ ] Compare with IPP and Dave's thing 41 | 42 | - [ ] resolve an IPID DID to the IPNS-resolved feed 43 | - [ ] self-modifying entries? 44 | - [ ] installable entries 45 | - [ ] intents? 46 | - [ ] would it make sense to make intents controlled via UCANs? Different sources could have different 47 | wiring. 48 | -------------------------------------------------------------------------------- /client/el/envoyager/loading.js: -------------------------------------------------------------------------------- 1 | 2 | import { LitElement, css, html } from '../../../deps/lit.js'; 3 | 4 | class EnvoyagerLoading extends LitElement { 5 | static styles = css` 6 | :host { 7 | display: block; 8 | width: 100%; 9 | height: 100%; 10 | --pulse-fill: var(--highlight); 11 | } 12 | div { 13 | display: flex; 14 | align-items: center; 15 | justify-content: center; 16 | width: 100%; 17 | height: 100%; 18 | } 19 | span { 20 | position: relative; 21 | display: flex; 22 | align-items: center; 23 | justify-content: center; 24 | width: 100%; 25 | max-width: 6rem; 26 | margin-top: 3rem; 27 | margin-bottom: 3rem; 28 | } 29 | span::before, span::after { 30 | content: ""; 31 | position: absolute; 32 | border-radius: 50%; 33 | animation: pulsOut 1.8s ease-in-out infinite; 34 | filter: drop-shadow(0 0 1rem rgba(255, 255, 255, 0.75)); 35 | } 36 | span::before { 37 | width: 100%; 38 | padding-bottom: 100%; 39 | box-shadow: inset 0 0 0 1rem var(--pulse-fill); 40 | animation-name: pulsIn; 41 | } 42 | span::after { 43 | width: calc(100% - 2rem); 44 | padding-bottom: calc(100% - 2rem); 45 | box-shadow: 0 0 0 0 var(--pulse-fill); 46 | } 47 | @keyframes pulsIn { 48 | 0% { 49 | box-shadow: inset 0 0 0 1rem var(--pulse-fill); 50 | opacity: 1; 51 | } 52 | 50%, 100% { 53 | box-shadow: inset 0 0 0 0 var(--pulse-fill); 54 | opacity: 0; 55 | } 56 | } 57 | @keyframes pulsOut { 58 | 0%, 50% { 59 | box-shadow: 0 0 0 0 var(--pulse-fill); 60 | opacity: 0; 61 | } 62 | 100% { 63 | box-shadow: 0 0 0 1rem var(--pulse-fill); 64 | opacity: 1; 65 | } 66 | } 67 | `; 68 | 69 | constructor () { 70 | super(); 71 | } 72 | 73 | render () { 74 | return html`
`; 75 | } 76 | } 77 | customElements.define('nv-loading', EnvoyagerLoading); 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | scratch/ 106 | -------------------------------------------------------------------------------- /client/el/envoyager/intent-action.js: -------------------------------------------------------------------------------- 1 | 2 | import { LitElement, html, css } from '../../../deps/lit.js'; 3 | 4 | class EnvoyagerIntentAction extends LitElement { 5 | static properties = { 6 | src: {}, 7 | action: {}, 8 | type: {}, 9 | data: { attribute: false }, 10 | onComplete: { attribute: false }, 11 | onCancel: { attribute: false }, 12 | }; 13 | 14 | static styles = css` 15 | :host { 16 | /* display: block; */ 17 | display: flex; 18 | align-items: stretch; 19 | } 20 | div { 21 | width: 100%; 22 | } 23 | `; 24 | 25 | constructor () { 26 | super(); 27 | this.src = null; 28 | this.action = null; 29 | this.type = null; 30 | this.data = {}; 31 | this.onComplete = ()=>{}; 32 | this.onCancel = ()=>{}; 33 | } 34 | 35 | debug (ev) { 36 | ev.target.previousElementSibling.openDevTools(); 37 | } 38 | 39 | async dispatchIPC ({ channel, args }) { 40 | console.warn(`dispatching`, channel); 41 | if (channel === 'intent-cancelled') return this.onCancel(); 42 | // this does not belong here, but due to unpleasant architecture in coordinating webviews, all intent work is here. 43 | if (channel === 'create-feed') { 44 | const [data] = args; 45 | await window.envoyager.createFeed({ 46 | name: data.name, 47 | creator: this.data.creator, 48 | parent: this.data.parent, 49 | position: this.data.position, 50 | }); 51 | this.onComplete(); 52 | console.warn(`Done creating feed, send onComplete.`); 53 | } 54 | 55 | } 56 | 57 | // webview attributes 58 | // src (use loadSrc) 59 | // preload - need one for intents injection ./src/preload-webview.js 60 | // partition - set it to src 61 | // executeJavaScript() to inject special nv-* elements that are available there 62 | render () { 63 | if (!this.src) return html``; 64 | let src = this.src; 65 | if (/^native:/.test(src)) src = src.replace(/^native:/, './client/intents/') + '.html'; 66 | return html`
67 | 68 | 69 |
`; 70 | } 71 | } 72 | customElements.define('nv-intent-action', EnvoyagerIntentAction); 73 | -------------------------------------------------------------------------------- /client/el/envoyager/show-identity.js: -------------------------------------------------------------------------------- 1 | 2 | import { LitElement, css, html } from '../../../deps/lit.js'; 3 | import { getStore } from '../../db/model.js'; 4 | 5 | class EnvoyagerShowIdentity extends LitElement { 6 | static properties = { 7 | person: { attribute: false }, 8 | people: { attribute: false }, 9 | identity: {}, 10 | }; 11 | static styles = [css` 12 | :host { 13 | display: block; 14 | max-width: 50rem; 15 | margin: 2rem auto; 16 | --left-pad: calc(2rem + 142px); 17 | } 18 | #banner { 19 | height: 200px; 20 | background-color: var(--highlight); 21 | background-size: cover; 22 | background-position: center; 23 | } 24 | #avatar { 25 | height: 142px; 26 | width: 142px; 27 | margin: -42px auto auto 1rem; 28 | border-radius: 50%; 29 | background-color: #000; 30 | background-size: cover; 31 | background-position: center; 32 | border: 3px solid #fff; 33 | } 34 | pre { 35 | margin-top: 0; 36 | margin-left: var(--left-pad); 37 | } 38 | #name { 39 | display: block; 40 | margin: -100px 0 auto var(--left-pad); 41 | width: calc(100% - var(--left-pad)); 42 | font-size: 2rem; 43 | font-family: var(--heading-font); 44 | font-weight: 200; 45 | font-variation-settings: "wght" 200; 46 | letter-spacing: 1px; 47 | } 48 | nv-feed-list { 49 | margin-left: var(--left-pad); 50 | margin-top: 2rem; 51 | } 52 | `]; 53 | 54 | constructor () { 55 | super(); 56 | this.identity = null; 57 | getStore('identities').subscribe(({ people = [] } = {}) => { 58 | this.people = people; 59 | }); 60 | } 61 | 62 | willUpdate (props) { 63 | if (props.has('identity') || props.has('people')) { 64 | this.person = this.people.find(p => p.$id === this.identity) || null; 65 | } 66 | } 67 | 68 | render () { 69 | console.warn(`person`, this.person); 70 | const url = this.person?.url || ''; 71 | return html`
72 | 73 |
74 |

${this.person?.name || 'Nameless Internet Entity'}

75 |
${this.person?.$id}
76 | 77 |
`; 78 | } 79 | } 80 | customElements.define('nv-show-identity', EnvoyagerShowIdentity); 81 | -------------------------------------------------------------------------------- /INTENTS.md: -------------------------------------------------------------------------------- 1 | 2 | # Envoyager Intents 3 | 4 | Envoyager is an experiment in designing the Composable Web. Content addressable components can be 5 | rendered together, but they also need a way to communicate with one another in predictable, 6 | declarative ways that enable composed actions and RPC that maintains the guarantees that loading 7 | a single component provides. 8 | 9 | Intents are meant to support that. They are inspired by previous work in 10 | [Web Intents](https://www.w3.org/TR/web-intents/), which itself learnt from Android Intents. 11 | 12 | ## Invoking Intents 13 | 14 | Intents are invoked with `const intent = envoyager.intent(action, type, data)`. The `action` is a verb, like 15 | `edit`, `pick`, or `create`. The type is a form of media type, though not bound to IANA media 16 | types but rather winnowing down what the action applies to, eg. `create envoyager/feed`. The 17 | data is any supplemental data that can usefully be provided to the intent handler. 18 | 19 | When an intent is invoked, the user is presented with a modal to pick which intent they wish to 20 | use. (We can refine that later, eg. with a `` 87 | : nothing 88 | } 89 | `; 90 | } 91 | } 92 | customElements.define('nv-feed-list', EnvoyagerFeedList); 93 | -------------------------------------------------------------------------------- /client/el/envoyager/header.js: -------------------------------------------------------------------------------- 1 | 2 | import { LitElement, css, html, nothing } from '../../../deps/lit.js'; 3 | import { getStore } from '../../db/model.js'; 4 | 5 | class EnvoyagerHeader extends LitElement { 6 | static properties = { 7 | person: { attribute: false }, 8 | }; 9 | static styles = css` 10 | :host { 11 | display: block; 12 | background: #000; 13 | color: #fff; 14 | text-align: center; 15 | padding: 1.5rem 1rem 1rem 1rem; 16 | } 17 | text { 18 | font-family: Lexend, monospace; 19 | font-size: 40px; 20 | font-variation-settings: "wght" 100; 21 | letter-spacing: 5px; 22 | fill: #fff; 23 | } 24 | tspan { 25 | opacity: 0.0; 26 | } 27 | path { 28 | stroke-width: 1px; 29 | stroke: #fff; 30 | fill: none; 31 | } 32 | path + path { 33 | stroke-width: 1.5px; 34 | stroke: var(--highlight); 35 | } 36 | header { 37 | position: relative; 38 | } 39 | #person { 40 | position: absolute; 41 | right: 0; 42 | top: 0; 43 | } 44 | #person button { 45 | margin: 0; 46 | padding: 0; 47 | background: transparent; 48 | border: 2px solid #fff; 49 | width: 54px; 50 | height: 54px; 51 | border-radius: 50%; 52 | cursor: pointer; 53 | user-select: none; 54 | transition: all 0.15s ease-in 0s; 55 | } 56 | #person button:hover { 57 | border-color: var(--highlight); 58 | } 59 | #person img { 60 | border-radius: 50%; 61 | } 62 | `; 63 | 64 | constructor () { 65 | super(); 66 | getStore('identities').subscribe(({ people = [] } = {}) => { 67 | this.person = people[0] || null; 68 | }); 69 | this.nav = getStore('navigation'); 70 | } 71 | 72 | goToPerson () { 73 | this.nav.go('show-identity', { id: this.person.$id }); 74 | } 75 | 76 | render () { 77 | return html`
78 | 89 | ${ 90 | this.person 91 | ? html`
92 | 93 |
` 94 | : nothing 95 | } 96 |
`; 97 | } 98 | } 99 | customElements.define('nv-header', EnvoyagerHeader); 100 | -------------------------------------------------------------------------------- /client/el/envoyager/image-drop.js: -------------------------------------------------------------------------------- 1 | 2 | import { LitElement, css, html } from '../../../deps/lit.js'; 3 | 4 | // XXX 5 | // - add support for clicking to trigger picker instead by triggering an intent 6 | // and having one of the intent handlers be native 7 | 8 | // some credit due to https://www.smashingmagazine.com/2018/01/drag-drop-file-uploader-vanilla-js/ 9 | class EnvoyagerImageDrop extends LitElement { 10 | // the idea is that the context can override this 11 | static styles = css` 12 | :host { 13 | display: block; 14 | background: #fff; 15 | border: 3px dashed rgba(0, 0, 0, 0.1); 16 | } 17 | #drop { 18 | width: 100%; 19 | height: 100%; 20 | border-radius: inherit; 21 | background-size: cover; 22 | display: flex; 23 | align-items: center; 24 | justify-content: center; 25 | } 26 | svg { 27 | height: 50%; 28 | max-height: 142px; 29 | min-height: 18px; 30 | opacity: 0.2; 31 | stroke: #000; 32 | } 33 | .highlight svg { 34 | opacity: 1; 35 | } 36 | `; 37 | 38 | constructor () { 39 | super(); 40 | } 41 | 42 | firstUpdated () { 43 | const dropArea = this.renderRoot.getElementById('drop'); 44 | ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(evtn => { 45 | dropArea.addEventListener( 46 | evtn, 47 | (e) => { 48 | e.preventDefault(); 49 | e.stopPropagation(); 50 | }, 51 | false 52 | ); 53 | }); 54 | ['dragenter', 'dragover'].forEach(evtn => { 55 | dropArea.addEventListener(evtn, () => dropArea.classList.add('highlight'), false) 56 | }); 57 | ['dragleave', 'drop'].forEach(evtn => { 58 | dropArea.addEventListener(evtn, () => dropArea.classList.remove('highlight'), false) 59 | }); 60 | dropArea.addEventListener( 61 | 'drop', 62 | (ev) => { 63 | // here we could add support for multiple 64 | const files = ev.dataTransfer.files; 65 | dropArea.style.backgroundImage = `url(${URL.createObjectURL(files[0])})`; 66 | const cev = new CustomEvent('image-dropped', { 67 | bubbles: true, 68 | composed: true, 69 | detail: { imageFile: files[0] }, 70 | }); 71 | this.dispatchEvent(cev); 72 | }, 73 | false 74 | ); 75 | } 76 | 77 | render () { 78 | return html`
79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 |
`; 87 | } 88 | } 89 | customElements.define('nv-image-drop', EnvoyagerImageDrop); 90 | -------------------------------------------------------------------------------- /client/el/envoyager/create-identity.js: -------------------------------------------------------------------------------- 1 | 2 | import { LitElement, css, html, nothing } from '../../../deps/lit.js'; 3 | import { buttonStyles } from '../../button-styles.js'; 4 | import { formStyles } from '../../form-styles.js'; 5 | import { getStore } from '../../db/model.js'; 6 | import { initIdentities } from '../../db/identities.js'; 7 | 8 | class EnvoyagerCreateIdentity extends LitElement { 9 | static styles = [css` 10 | :host { 11 | display: block; 12 | max-width: 50rem; 13 | margin: 2rem auto; 14 | --left-pad: calc(2rem + 142px); 15 | } 16 | #banner { 17 | height: 200px; 18 | } 19 | #avatar { 20 | height: 142px; 21 | width: 142px; 22 | margin: -42px auto auto 1rem; 23 | border-radius: 50%; 24 | } 25 | .form-line { 26 | margin-left: var(--left-pad); 27 | } 28 | #name { 29 | display: block; 30 | margin: -100px 0 auto var(--left-pad); 31 | width: calc(100% - var(--left-pad)); 32 | font-size: 2rem; 33 | font-family: var(--heading-font); 34 | font-weight: 200; 35 | font-variation-settings: "wght" 200; 36 | letter-spacing: 1px; 37 | } 38 | #did { 39 | font-family: monospace; 40 | } 41 | .error-message { 42 | margin-left: var(--left-pad); 43 | } 44 | `, formStyles, buttonStyles]; 45 | 46 | constructor () { 47 | super(); 48 | this.banner = null; 49 | this.avatar = null; 50 | this.errMsg = null; 51 | } 52 | 53 | async formHandler (ev) { 54 | ev.preventDefault(); 55 | const fd = new FormData(ev.target); 56 | const data = {}; 57 | for (let [key, value] of fd.entries()) { 58 | data[key] = value; 59 | } 60 | for (const k of ['avatar', 'banner']) { 61 | if (!this[k]) continue; 62 | data[k] = { 63 | mediaType: this[k].type, 64 | buffer: await this[k].arrayBuffer(), 65 | }; 66 | } 67 | console.warn(data); 68 | this.errMsg = await window.envoyager.createIdentity(data); 69 | const nav = getStore('navigation'); 70 | if (this.errMsg) this.requestUpdate(); 71 | else { 72 | await initIdentities(); 73 | nav.go('show-identity', { id: data.did }); 74 | } 75 | } 76 | 77 | render () { 78 | const err = this.errMsg ? html`
${this.errMsg}
` : nothing; 79 | return html` 80 |
81 | 82 | this.avatar = e.detail.imageFile}> 83 | 84 |
85 | 86 | 87 |
88 | ${err} 89 |
90 | 91 |
92 |
93 |
`; 94 | } 95 | } 96 | customElements.define('nv-create-identity', EnvoyagerCreateIdentity); 97 | -------------------------------------------------------------------------------- /src/ipfs-handler.js: -------------------------------------------------------------------------------- 1 | 2 | import { PassThrough } from 'stream'; 3 | import { Buffer } from 'buffer'; 4 | import { WASMagic } from 'wasmagic'; 5 | import { CID } from 'multiformats'; 6 | import { base32 } from "multiformats/bases/base32"; 7 | import { resolveIPNS, getDag } from './ipfs-node.js'; 8 | 9 | 10 | // this is so that we can send strings as streams 11 | function createStream (text) { 12 | const rv = new PassThrough(); 13 | rv.push(text); 14 | rv.push(null); 15 | return rv; 16 | } 17 | 18 | export async function ipfsProtocolHandler (req, cb) { 19 | const url = new URL(req.url); 20 | let cid; 21 | if (url.protocol === 'ipns:') { 22 | cid = await resolveIPNS(url.hostname); 23 | } 24 | else if (url.protocol === 'ipfs:') { 25 | cid = url.hostname; 26 | } 27 | else { 28 | return cb({ 29 | statusCode: 421, // Misdirected Request 30 | mimeType: 'application/json', 31 | data: createStream(JSON.stringify({ 32 | err: true, 33 | msg: `Backend does not support requests for scheme "${url.scheme}".`, 34 | }, null, 2)), 35 | }); 36 | } 37 | console.warn(`url to cid`, req.url, cid); 38 | if (req.method !== 'GET') return cb({ 39 | statusCode: 405, // Method Not Allowed 40 | mimeType: 'application/json', 41 | data: createStream(JSON.stringify({ 42 | err: true, 43 | msg: `Request method "${req.method}" is not supported.`, 44 | }, null, 2)), 45 | }); 46 | // Because we understand the data model used in Envoyager, we should use that when possible to obtain the correct media 47 | // type as specified at creation. However, for temporary expediency we use wasmagic detection. 48 | const value = await getDag(cid, url.pathname); 49 | if (value instanceof Uint8Array && value.constructor.name === 'Uint8Array') { 50 | let mimeType; 51 | // our expectation is that raw will generally be wrapped in IPLD, but that will not be true over UnixFS for instance 52 | // we poke for mediaType next to what we assume is src in the current path (we could restrict to that) 53 | if (url.pathname && url.pathname.length > 1) { 54 | try { 55 | const mtURL = new URL('mediaType', url.href); 56 | mimeType = await getDag(cid, mtURL.pathname); 57 | } 58 | catch (err) {/**/} 59 | } 60 | if (!mimeType) { 61 | const magic = await WASMagic.create(); 62 | mimeType = magic.getMime(Buffer.from(value)); 63 | } 64 | console.warn(`Value is binary, with type ${mimeType}`); 65 | cb({ 66 | statusCode: 200, 67 | mimeType, 68 | data: createStream(value), 69 | }); 70 | } 71 | else { 72 | console.warn(`Value is`, value); 73 | cb({ 74 | statusCode: 200, 75 | mimeType: 'application/json', 76 | data: createStream(JSON.stringify(value, ipld2json, 2)), 77 | }); 78 | } 79 | } 80 | 81 | export function ipld2json (k, v) { 82 | // in order to intercept the toJSON on CID, we find CID objects from their parent 83 | if (typeof v === 'object' && Object.values(v).find(o => o instanceof CID)) { 84 | const ret = {}; 85 | Object.keys(v).forEach(key => { 86 | ret[key] = (v[key] instanceof CID) ? `ipfs://${v[key].toString(base32)}/` : v[key]; 87 | }); 88 | return ret; 89 | } 90 | return v; 91 | } 92 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 2 | import { app, protocol, BrowserWindow, screen } from 'electron'; 3 | import { ipfsProtocolHandler } from './ipfs-handler.js'; 4 | import { initIPNSCache, shutdown } from './ipfs-node.js'; 5 | import { initDataSource } from './data-source.js'; 6 | import { initIntents } from './intents.js'; 7 | import makeRel from './rel.js'; 8 | 9 | let mainWindow; 10 | const rel = makeRel(import.meta.url); 11 | 12 | // there can be only one 13 | const singleInstanceLock = app.requestSingleInstanceLock(); 14 | if (!singleInstanceLock) { 15 | app.quit(); 16 | } 17 | else { 18 | app.on('second-instance', () => { 19 | if (mainWindow) { 20 | if (mainWindow.isMinimized()) mainWindow.restore(); 21 | mainWindow.focus(); 22 | } 23 | }); 24 | } 25 | 26 | app.whenReady().then(async () => { 27 | protocol.registerStreamProtocol('ipfs', ipfsProtocolHandler); 28 | protocol.registerStreamProtocol('ipns', ipfsProtocolHandler); 29 | await initIPNSCache(); 30 | await initDataSource(); 31 | await initIntents(); 32 | const { width, height } = screen.getPrimaryDisplay().workAreaSize; 33 | mainWindow = new BrowserWindow({ 34 | width, 35 | height, 36 | show: false, 37 | backgroundColor: '#fff', 38 | title: 'Nytive', 39 | titleBarStyle: 'hidden', 40 | icon: './img/icon.png', 41 | webPreferences: { 42 | webviewTag: true, // I know that this isn't great, but the alternatives aren't there yet 43 | preload: rel('./preload.js'), 44 | }, 45 | }); 46 | mainWindow.loadFile('index.html'); 47 | mainWindow.once('ready-to-show', () => { 48 | mainWindow.show(); 49 | }); 50 | const { webContents } = mainWindow; 51 | // reloading 52 | webContents.on('before-input-event', makeKeyDownMatcher('cmd+R', reload)); 53 | webContents.on('before-input-event', makeKeyDownMatcher('ctrl+R', reload)); 54 | webContents.on('before-input-event', makeKeyDownMatcher('cmd+alt+I', openDevTools)); 55 | webContents.on('before-input-event', makeKeyDownMatcher('ctrl+alt+I', openDevTools)); 56 | }); 57 | 58 | app.on('will-quit', shutdown); 59 | 60 | function reload () { 61 | console.log('RELOAD'); 62 | mainWindow.reload(); 63 | } 64 | 65 | function openDevTools () { 66 | mainWindow.webContents.openDevTools(); 67 | } 68 | 69 | // function makeKeyUpMatcher (sc, cb) { 70 | // return makeKeyMatcher('keyUp', sc, cb); 71 | // } 72 | 73 | function makeKeyDownMatcher (sc, cb) { 74 | return makeKeyMatcher('keyDown', sc, cb); 75 | } 76 | 77 | function makeKeyMatcher (type, sc, cb) { 78 | let parts = sc.split(/[+-]/) 79 | , key = parts.pop().toLowerCase() 80 | , modifiers = { 81 | shift: false, 82 | control: false, 83 | meta: false, 84 | alt: false, 85 | } 86 | ; 87 | parts.forEach(p => { 88 | p = p.toLowerCase(); 89 | if (p === 'ctrl') p = 'control'; 90 | if (p === 'cmd') p = 'meta'; 91 | if (typeof modifiers[p] !== 'boolean') console.warn(`Unknown command modifier ${p}.`); 92 | modifiers[p] = true; 93 | }); 94 | return (evt, input) => { 95 | if (type !== input.type) return; 96 | if (key !== input.key) return; 97 | let badMod = false; 98 | Object.keys(modifiers).forEach(mod => { 99 | if (input[mod] !== modifiers[mod]) badMod = true; 100 | }); 101 | if (badMod) return; 102 | cb(); 103 | }; 104 | } 105 | -------------------------------------------------------------------------------- /src/ipfs-node.js: -------------------------------------------------------------------------------- 1 | 2 | import { join } from 'path'; 3 | import { readFile, writeFile } from "fs/promises"; 4 | import process from 'process'; 5 | import { create as createNode } from 'ipfs-core'; 6 | import sanitize from 'sanitize-filename'; 7 | import { base58btc } from "multiformats/bases/base58"; 8 | import { base32 } from "multiformats/bases/base32"; 9 | import { CID } from 'multiformats'; 10 | import loadJSON from './load-json.js'; 11 | import saveJSON from './save-json.js'; 12 | import { dataDir } from './profile-data.js'; 13 | 14 | // 🚨🚨🚨 WARNING 🚨🚨🚨 15 | // nothing here is meant to be safe, this is all demo code, the keys are just stored on disk, etc. 16 | const password = 'Steps to an Ecology of Mind'; 17 | const cachePath = join(dataDir, 'ipns-cache.json'); 18 | let ipnsCache; 19 | 20 | export const node = await createNode(); 21 | 22 | export async function shutdown () { 23 | await node.stop(); 24 | } 25 | process.on('SIGINT', async () => { 26 | try { 27 | await shutdown(); 28 | } 29 | catch (err) {/**/} 30 | process.exit(); 31 | }); 32 | 33 | function cleanID (id) { 34 | return sanitize(id.replace(/:/g, '_')); 35 | } 36 | 37 | export async function initIPNSCache () { 38 | try { 39 | ipnsCache = await loadJSON(cachePath); 40 | } 41 | catch (err) { 42 | await saveJSON(cachePath, {}); 43 | } 44 | } 45 | 46 | export async function saveIPNSCache () { 47 | return saveJSON(cachePath, ipnsCache); 48 | } 49 | 50 | export async function putBlockAndPin (buffer) { 51 | const cid = await node.block.put(new Uint8Array(buffer), { format: 'raw', pin: true, version: 1 }); 52 | // await node.pin.add(cid, { recursive: false }); 53 | return cid; 54 | } 55 | 56 | export async function putDagAndPin (obj) { 57 | const cid = await node.dag.put(obj, { pin: true }); 58 | // await node.pin.add(cid); 59 | return cid; 60 | } 61 | 62 | export async function getDag (cid, path) { 63 | if (typeof cid === 'string') cid = CID.parse(cid); 64 | const { value } = await node.dag.get(cid, { path }); 65 | return value; 66 | } 67 | 68 | export async function dirCryptoKey (keyDir, name) { 69 | const cleanName = cleanID(name); 70 | const keyFile = join(keyDir, `${cleanName}.pem`); 71 | const keys = await node.key.list(); 72 | if (keys.find(({ name }) => name === cleanName)) { 73 | return provideKey(keyFile, cleanName); 74 | } 75 | try { 76 | await node.key.import(cleanName, await readFile(keyFile), password); 77 | return; 78 | } 79 | catch (err) { 80 | // console.warn(`generating key with name "${name}"`); 81 | await node.key.gen(cleanName); 82 | await provideKey(keyFile, cleanName); 83 | } 84 | } 85 | 86 | async function provideKey (keyFile, cleanName) { 87 | await writeFile(keyFile, await node.key.export(cleanName, password)); 88 | } 89 | 90 | // js-ipfs always produces (and only accepts) IPNS names that base58btc, unprefixed (because YOLO). 91 | // That doesn't work in URLs because the origin part has to be case-insensitive. 92 | // So we convert to base32, and then convert back (removing the prefix) for resolution. 93 | export async function publishIPNS (keyDir, name, cid) { 94 | await dirCryptoKey(keyDir, name); 95 | if (typeof cid === 'string') cid = CID.parse(cid); 96 | const { name: ipnsName } = await node.name.publish(cid, { key: cleanID(name) }); 97 | const b32name = base32.encode(base58btc.decode(`z${ipnsName}`)); 98 | ipnsCache[b32name] = cid.toString(base32); 99 | await saveIPNSCache(); 100 | return b32name; 101 | } 102 | 103 | export async function resolveIPNS (ipns) { 104 | try { 105 | const b58IPNS = base58btc.encode(base32.decode(ipns)).replace(/^z/, ''); 106 | const resolved = await node.name.resolve(`/ipns/${b58IPNS}`, { recursive: true }); 107 | // we get an iterable array back 108 | let res; 109 | for await (const target of resolved) res = target; 110 | return res.replace('/ipfs/', ''); 111 | } 112 | catch (err) { 113 | if (ipnsCache[ipns]) return CID.parse(ipnsCache[ipns]); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/data-source.js: -------------------------------------------------------------------------------- 1 | 2 | import { join } from 'path'; 3 | import { mkdir, access, writeFile, readdir } from "fs/promises"; 4 | import { Buffer } from 'buffer'; 5 | import { ipcMain } from 'electron'; 6 | import mime from 'mime-types'; 7 | import saveJSON from './save-json.js'; 8 | import loadJSON from './load-json.js'; 9 | import { putBlockAndPin, putDagAndPin, dirCryptoKey, publishIPNS } from './ipfs-node.js'; 10 | import { identitiesDir } from './profile-data.js'; 11 | 12 | const { handle } = ipcMain; 13 | const didRx = /^did:[\w-]+:\S+/; 14 | const ipnsFile = 'ipns.json'; 15 | 16 | export async function initDataSource () { 17 | await mkdir(identitiesDir, { recursive: true }); 18 | try { 19 | await loadIdentities(); 20 | } 21 | catch (err) { 22 | // await saveIdentities([]); 23 | } 24 | handle('identities:load', loadIdentities); 25 | handle('identities:create', createIdentity); 26 | // handle('identities:save', saveIdentity); 27 | // handle('identities:delete', deleteIdentity); 28 | } 29 | 30 | async function loadIdentities () { 31 | const ids = (await readdir(identitiesDir)).filter(dir => !/^\./.test(dir)); 32 | const identities = []; 33 | for (const idDir of ids) { 34 | identities.push(await loadJSON(join(identitiesDir, idDir, ipnsFile))); 35 | } 36 | return identities; 37 | } 38 | 39 | async function createIdentity (evt, { name, did, avatar, banner } = {}) { 40 | try { 41 | if (!name) return 'Missing name.'; 42 | if (!did || !didRx.test(did)) return 'Invalid or missing DID.'; 43 | const didDir = join(identitiesDir, encodeURIComponent(did)); 44 | const keyDir = join(didDir, 'keys'); 45 | try { 46 | await access(didDir); 47 | // eventually we'll have to check actual ownership of that DID… 48 | return 'DID already exists here.'; 49 | } 50 | catch (err) { /* noop */ } 51 | await mkdir(keyDir, { recursive: true }); 52 | const person = { 53 | $type: 'Person', 54 | $id: did, 55 | name, 56 | }; 57 | const applyImage = async (name, source) => { 58 | writeFile(join(didDir, `${name}.${mime.extension(source.mediaType)}`), Buffer.from(source.buffer)); 59 | person[name] = { 60 | $type: 'Image', 61 | mediaType: source.mediaType, 62 | src: await putBlockAndPin(source.buffer), 63 | }; 64 | }; 65 | if (avatar) await applyImage('avatar', avatar); 66 | if (banner) await applyImage('banner', banner); 67 | await dirCryptoKey(keyDir, did); 68 | // we have to ping pong so as to get a two-way IPNS: create a partial feed, get its IPNS, set that on the Person, 69 | // create the person, get their IPNS, set that on feed, update feed, republish its IPNS. 70 | const feed = { 71 | $type: 'Feed', 72 | $id: `${did}.root-feed`, 73 | items: [], 74 | }; 75 | const tmpFeedCID = await putDagAndPin(feed); 76 | const feedIPNS = await publishIPNS(keyDir, feed.$id, tmpFeedCID); 77 | person.feed = `ipns://${feedIPNS}`; 78 | const personCID = await putDagAndPin(person); 79 | const personIPNS = await publishIPNS(keyDir, did, personCID); 80 | feed.creator = `ipns://${personIPNS}`; 81 | const feedCID = await putDagAndPin(feed); 82 | await publishIPNS(keyDir, feed.$id, feedCID); 83 | await saveJSON(join(didDir, ipnsFile), { ipns: personIPNS }); 84 | return ''; 85 | } 86 | catch (err) { 87 | return err.message; 88 | } 89 | } 90 | 91 | // async function saveIdentity (evt, person) { 92 | // const ids = await loadIdentities(); 93 | // const idx = ids.findIndex(p => p.$id = person.$id); 94 | // if (idx >= 0) ids[idx] = person; 95 | // else ids.push(person); 96 | // // XXX 97 | // // - store images 98 | // // - create properly shaped JSON with image objects and embedded Buffers 99 | // // - check prior existence of root feed, otherwise mint one 100 | // // - put person on IPFS 101 | // // - create and store ipns for them, with a key matching the DID 102 | // await saveIdentities(ids); 103 | // return true; 104 | // } 105 | 106 | // // XXX probably eliminate this 107 | // async function saveIdentities (identities) { 108 | // return saveJSON(join(dataDir, 'identities.json'), identities); 109 | // } 110 | 111 | // async function deleteIdentity (evt, did) { 112 | // const ids = await loadIdentities(); 113 | // await saveIdentities(ids.filter(p => p.$id !== did)); 114 | // return true; 115 | // } 116 | -------------------------------------------------------------------------------- /client/el/envoyager/intent-list-modal.js: -------------------------------------------------------------------------------- 1 | 2 | import { LitElement, html, css } from '../../../deps/lit.js'; 3 | import { buttonStyles } from '../../button-styles.js'; 4 | import { formStyles } from '../../form-styles.js'; 5 | 6 | class EnvoyagerIntentListModal extends LitElement { 7 | static properties = { 8 | active: { type: Boolean, reflect: true }, 9 | handlerName: { attribute: false }, 10 | handlerURL: { attribute: false }, 11 | intents: { attribute: false }, 12 | action: { attribute: false }, 13 | type: { attribute: false }, 14 | data: { attribute: false }, 15 | intentID: { attribute: false }, 16 | }; 17 | static styles = [css` 18 | :host { 19 | display: none; 20 | } 21 | :host([active]) { 22 | display: flex; 23 | position: fixed; 24 | top: 0; 25 | left: 0; 26 | bottom: 0; 27 | right: 0; 28 | z-index: 9999; 29 | background: #0006; 30 | justify-content: center; 31 | align-items: center; 32 | } 33 | nv-box { 34 | background: #fff; 35 | min-width: 30rem; 36 | } 37 | .intent-action { 38 | display: flex; 39 | align-items: center; 40 | cursor: pointer; 41 | padding: 1rem 0; 42 | border-top: 1px solid #000; 43 | transition: all 0.15s ease-in 0s; 44 | } 45 | .intent-action:hover { 46 | background: var(--highlight); 47 | color: #fff; 48 | } 49 | .intent-action:first-of-type { 50 | border: none; 51 | } 52 | .icon { 53 | width: 50px; 54 | height: 50px; 55 | background-repeat: no-repeat; 56 | background-position: center; 57 | background-size: 40px; 58 | margin-left: 1rem; 59 | } 60 | .label { 61 | font-size: 1.2rem; 62 | font-family: var(--heading-font); 63 | padding-left: 1rem; 64 | } 65 | `, formStyles, buttonStyles]; 66 | 67 | constructor () { 68 | super(); 69 | this.resetState(); 70 | window.envoyager.onIntentList((ev, intents, action, type, data, id) => { 71 | console.warn(`onIntentList`, intents, action, type, data, id); 72 | this.intents = intents; 73 | this.action = action; 74 | this.type = type; 75 | this.data = data; 76 | this.intentID = id; 77 | this.active = true; 78 | }); 79 | } 80 | 81 | resetState () { 82 | this.active = false; 83 | this.intents = []; 84 | this.action = null; 85 | this.type = null; 86 | this.data = {}; 87 | this.handlerName = 'Action'; 88 | this.handlerURL = null; 89 | } 90 | 91 | selectHandler (ev) { 92 | const { name, url } = ev.target.dataset; 93 | this.handlerName = name; 94 | this.handlerURL = url; 95 | } 96 | 97 | onComplete () { 98 | console.warn(`Running onComplete(${this.intentID})`); 99 | window.intentListener.success(this.intentID); 100 | this.resetState(); 101 | } 102 | onCancel () { 103 | console.warn(`called onCancel`, this.intentID); 104 | window.intentListener.failure(this.intentID); 105 | this.resetState(); 106 | } 107 | close () { 108 | this.onCancel(); 109 | } 110 | 111 | render () { 112 | if (!this.handlerURL) { 113 | return html` 114 | ${ 115 | this.intents.length 116 | ? this.intents.map(nt => (html`
117 |
118 |
${nt.name}
119 |
`)) 120 | : html`

No available action matches this intent.

` 121 | } 122 |
123 |
`; 124 | } 125 | return html` 126 | this.onComplete()} 132 | .onCancel=${() => this.onCancel()} 133 | > 134 | `; 135 | } 136 | } 137 | customElements.define('nv-intent-list-modal', EnvoyagerIntentListModal); 138 | 139 | // singleton 140 | window.intentListener = new class IntentListener { 141 | constructor () { 142 | this.successHandlers = {}; 143 | this.failureHandlers = {}; 144 | this.completeHandlers = {}; 145 | } 146 | once (type, id, cb) { 147 | const handlers = this[`${type}Handlers`]; 148 | handlers[id] = cb; 149 | } 150 | runOnce (type, id) { 151 | console.warn(`Running once for ${type}:${id}`); 152 | const handlers = this[`${type}Handlers`]; 153 | if (handlers[id]) { 154 | console.warn(`Have handler, will run`); 155 | handlers[id](); 156 | delete handlers[id]; 157 | } 158 | } 159 | success (id) { 160 | this.runOnce('success', id); 161 | this.runOnce('complete', id); 162 | } 163 | failure (id) { 164 | console.warn(`failure…`, id); 165 | this.runOnce('failure', id); 166 | this.runOnce('complete', id); 167 | } 168 | }; 169 | -------------------------------------------------------------------------------- /client/el/envoyager/item-card.js: -------------------------------------------------------------------------------- 1 | 2 | import { LitElement, css, html, nothing } from '../../../deps/lit.js'; 3 | import { buttonStyles } from '../../button-styles.js'; 4 | 5 | class EnvoyagerItemCard extends LitElement { 6 | static properties = { 7 | src: {}, 8 | data: { attribute: false }, 9 | creator: { attribute: false }, 10 | loading: { attribute: false }, 11 | creatorLoading: { attribute: false }, 12 | }; 13 | static styles = [css` 14 | :host { 15 | display: block; 16 | } 17 | #root:hover { 18 | background: var(--lightest-bg); 19 | } 20 | .clickable { 21 | cursor: pointer; 22 | } 23 | #creator { 24 | padding: 0.5rem; 25 | display: flex; 26 | align-items: center; 27 | } 28 | #creator img { 29 | border-radius: 50%; 30 | margin-right: 1rem; 31 | } 32 | #creator span { 33 | font-family: var(--heading-font); 34 | font-variation-settings: "wght" 400; 35 | } 36 | #content { 37 | padding: 0rem 0.5rem 0.5rem calc(1.5rem + 32px) 38 | } 39 | #banner { 40 | width: 100%; 41 | min-width: 504px; 42 | height: 264px; 43 | background-size: cover; 44 | background-position: center; 45 | } 46 | h2 { 47 | font-family: var(--heading-font); 48 | font-size: 1.4rem; 49 | margin: 0; 50 | } 51 | #actions { 52 | text-align: right; 53 | padding-bottom: 2rem; 54 | } 55 | `, buttonStyles]; 56 | 57 | constructor () { 58 | super(); 59 | this.src = null; 60 | this.data = {}; 61 | this.creator = {}; 62 | this.loading = false; 63 | this.creatorLoading = false; 64 | } 65 | 66 | willUpdate (props) { 67 | if (props.has('src')) this.refresh(); 68 | } 69 | 70 | refresh () { 71 | console.warn(`Loading ${this.src}…`); 72 | this.loading = true; 73 | fetch(this.src, { headers: { Accept: 'application/json' }}) 74 | .then((r) => r.json()) 75 | .then((data) => { 76 | this.loading = false; 77 | this.data = data; 78 | if (data.creator) { 79 | this.creatorLoading = true; 80 | fetch(data.creator, { headers: { Accept: 'application/json' }}) 81 | .then((r) => r.json()) 82 | .then((creator) => { 83 | this.creatorLoading = false; 84 | this.creator = creator; 85 | }) 86 | ; 87 | } 88 | }) 89 | ; 90 | } 91 | 92 | showFeed (e) { 93 | alert(`Navigate to feed ${this.data.url}`); 94 | e.stopPropagation(); 95 | } 96 | 97 | openFullItem (e) { 98 | alert(`Open item in full ${this.data.src}`); 99 | e.stopPropagation(); 100 | } 101 | 102 | installIntent (e) { 103 | alert(`Install intent ${this.data.intent}`); 104 | e.stopPropagation(); 105 | } 106 | 107 | saveItem (e) { 108 | alert(`Save or install item ${this.data.url} to a feed`); 109 | e.stopPropagation(); 110 | } 111 | 112 | render () { 113 | if (this.loading) return html`
`; 114 | let banner = nothing; 115 | if (this.data?.banner?.src) { 116 | banner = html` `; 117 | } 118 | let creator = nothing; 119 | if (this.creatorLoading) { 120 | creator = html``; 121 | } 122 | else if (this.creator) { 123 | creator = this.creator?.name || 'Unknown Entity'; 124 | } 125 | const noop = () => {}; 126 | let clickAction; 127 | let actions = []; 128 | if (this.data?.$type === 'Feed') { 129 | clickAction = (e) => this.showFeed(e); 130 | actions.push(html``); 131 | } 132 | if (this.data?.src) { 133 | clickAction = (e) => this.openFullItem(e); 134 | actions.push(html``); 135 | } 136 | if (this.data?.intent) { 137 | actions.push(html``); 138 | } 139 | actions.push(html``); 140 | 141 | return html`
142 |
143 | ${this.data?.creator?.name} 144 | ${creator} 145 |
146 |
147 | ${banner} 148 | ${ 149 | this.data?.name 150 | ? html`

${this.data?.name}

` 151 | : nothing 152 | } 153 | ${ 154 | this.data?.description 155 | ? html`

${this.data.description}

` 156 | : nothing 157 | } 158 |
159 |
${actions}
160 |
`; 161 | } 162 | } 163 | customElements.define('nv-item-card', EnvoyagerItemCard); 164 | -------------------------------------------------------------------------------- /src/intents.js: -------------------------------------------------------------------------------- 1 | 2 | import { join } from 'path'; 3 | import { mkdir, readdir } from "fs/promises"; 4 | import { ipcMain } from 'electron'; 5 | import { nanoid } from 'nanoid'; 6 | import { getDag, putDagAndPin, publishIPNS, resolveIPNS } from './ipfs-node.js'; 7 | import { dataDir, did2keyDir } from './profile-data.js'; 8 | import loadJSON from './load-json.js'; 9 | 10 | const { handle } = ipcMain; 11 | const intentsDir = join(dataDir, 'intents'); 12 | const db = {}; 13 | 14 | export async function initIntents () { 15 | await mkdir(intentsDir, { recursive: true }); 16 | try { 17 | await loadIntents(); 18 | } 19 | catch (err) {/**/} 20 | handle('intent:show-matching-intents', showMatchingIntents); 21 | handle('intent:create-feed', createFeed); 22 | } 23 | 24 | async function loadIntents () { 25 | const savedIntents = (await readdir(intentsDir)).filter(dir => !/^\./.test(dir)); 26 | for (const nt of savedIntents) { 27 | registerIntent(await loadJSON(join(intentsDir, nt))); 28 | } 29 | // add the native ones 30 | registerIntent({ 31 | name: 'Create Feed', 32 | url: 'native:create-feed', 33 | icon: { 34 | $type: 'Image', 35 | mediaType: 'image/svg+xml', 36 | src: 'img/feed-icon.svg', 37 | }, 38 | actions: { 39 | create: { 40 | types: ['envoyager/feed'], 41 | }, 42 | }, 43 | }); 44 | } 45 | 46 | // intents come in like this: 47 | // { 48 | // name: 'Photo Album', 49 | // url: ... 50 | // icon: { 51 | // $type: 'Image', 52 | // mediaType: 'image/png', 53 | // src: ..., 54 | // }, 55 | // actions: { 56 | // pick: { 57 | // name: 'Pick Image', 58 | // types: ['image/*'], 59 | // path: '/picker.html', 60 | // icon: { 61 | // $type: 'Image', 62 | // mediaType: 'image/png', 63 | // src: ..., 64 | // }, 65 | // }, 66 | // } 67 | // } 68 | // If the intent is native its url will be native:$name and there will be no paths. 69 | // This allows the intent rendering component to bring in the right thing without 70 | // making it available otherwise. 71 | function registerIntent (intent) { 72 | const defaultName = intent.name; 73 | const defaultIcon = intent.icon; 74 | const url = intent.url; 75 | Object.keys(intent.actions || {}).forEach(action => { 76 | if (!db[action]) db[action] = {}; 77 | const handler = { 78 | name: intent.actions[action].name || defaultName || action, 79 | icon: intent.actions[action].icon || defaultIcon, 80 | url, 81 | }; 82 | if (intent.actions[action].path && !/native:/.test(url)) { 83 | handler.url = new URL(intent.actions[action].path, url).href; 84 | } 85 | const seenTypes = new Set(); 86 | (intent.actions[action].types || []).forEach(t => { 87 | if (seenTypes.has(t)) return; 88 | seenTypes.add(t); 89 | if (!db[action][t]) db[action][t] = []; 90 | db[action][t].push(handler); 91 | }); 92 | }); 93 | } 94 | 95 | async function showMatchingIntents (ev, action, type, data, id) { 96 | console.warn(`showMatchingIntents`, action, type, data, id); 97 | const intents = []; 98 | const win = ev.senderFrame.top; 99 | const done = () => win.send('intent-list', intents, action, type, data, id); 100 | // make sure to match foo/* in both directions 101 | console.warn(`in db`, db[action]); 102 | if (!db[action]) return done(); 103 | // get all those that start with foo/ 104 | if (/\/\*$/.test(type)) { 105 | const [major,] = type.split('/'); 106 | Object.keys(db[action]) 107 | .filter(type => type.startsWith(`${major}/`)) 108 | .forEach(type => intents.push(...db[action][type])) 109 | ; 110 | } 111 | // get foo/bar and foo/* 112 | else { 113 | intents.push(...(db[action][type] || []), ...(db[action][type.replace(/\/.*/, '/*')] || [])); 114 | } 115 | console.warn(`found intents`, intents); 116 | return done(); 117 | } 118 | 119 | async function createFeed (ev, data) { 120 | const creatorDID = data.creator?.$id; 121 | if (!creatorDID) throw new Error(`Cannot create a feed that does not have a creator with an $id.`); 122 | const keyDir = did2keyDir(creatorDID); 123 | const feed = { 124 | $type: 'Feed', 125 | $id: `envoyager:feed.${nanoid()}`, 126 | name: data.name, 127 | creator: data.creator?.url, 128 | items: [], 129 | }; 130 | console.warn(`creating feed`, feed); 131 | const tempCID = await putDagAndPin(feed); 132 | const ipns = await publishIPNS(keyDir, feed.$id, tempCID); 133 | feed.url = `ipns://${ipns}/`; 134 | console.warn(`published to ${feed.url}`); 135 | const cid = await putDagAndPin(feed); 136 | await publishIPNS(keyDir, feed.$id, cid); 137 | console.warn(`republished`); 138 | if (data.parent) { 139 | console.warn(`PARENT`); 140 | let cid; 141 | if (/^ipns:/.test(data.parent)) cid = await resolveIPNS(data.parent.replace(/^ipns:\/\//, '').replace(/\/.*/, '')); 142 | else if (/^ipfs:/.test(data.parent)) cid = data.parent.replace(/^ipfs:\/\//, '').replace(/\/.*/, ''); 143 | console.warn(`parent cid=${cid}`); 144 | const parentFeed = await getDag(cid); 145 | if (!parentFeed.items) parentFeed.items = []; 146 | if (data.position === 'prepend') parentFeed.items.unshift(feed.url); 147 | else parentFeed.items.push(feed.url); 148 | console.warn(`parent updated feed`, parentFeed); 149 | const newCID = await putDagAndPin(parentFeed); 150 | if (/^ipns:/.test(data.parent)) await publishIPNS(keyDir, parentFeed.$id || `${creatorDID}.root-feed`, newCID); 151 | console.warn(`parent republished`); 152 | return newCID; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /client/db/model.js: -------------------------------------------------------------------------------- 1 | 2 | const registry = {}; 3 | const subscriberQueue = []; 4 | 5 | export function registerStore (name, store) { 6 | if (registry[name]) throw new Error(`Store "${name}" already registered.`); 7 | registry[name] = store; 8 | } 9 | 10 | export function getStore (name) { 11 | if (!registry[name]) throw new Error(`Store "${name}" not found.`); 12 | return registry[name]; 13 | } 14 | 15 | export function getStoreName (store) { 16 | return Object.keys(registry).find(n => registry[n] === store); 17 | } 18 | 19 | // Creates a store that can fetch from HTTP. 20 | // The value this store captures is from an HTTP result. It is structured thus: 21 | // - state: 22 | // - error: error message, if any 23 | // - errorCode: error code, if any 24 | // - value: the value returned 25 | // This API expects the server to send back some JSON, with the following structure: 26 | // - ok: true | false 27 | // - error and errorCode: as above 28 | // - data: the value 29 | // export function fetchable (url, value = {}) { 30 | // if (!value.state) value.state = 'unknown'; 31 | // let load = (set) => { 32 | // let xhr = new XMLHttpRequest(); 33 | // xhr.addEventListener('load', () => { 34 | // try { 35 | // let { ok, error, errorCode, data } = xhr.responseText 36 | // ? JSON.parse(xhr.responseText) 37 | // : {} 38 | // ; 39 | // if (xhr.status < 400) return set({ state: ok ? 'loaded' : 'error', error, errorCode, data }); 40 | // return set({ state: 'error', error: error || xhr.statusText, errorCode: errorCode || xhr.status }); 41 | // } 42 | // catch (err) { 43 | // return set({ state: 'error', error: err.message || err.toString(), errorCode: 'exception' }); 44 | // } 45 | // }); 46 | // xhr.addEventListener('error', () => { 47 | // set({ state: 'error', error: 'Network-level error', errorCode: 'network' }); 48 | // }); 49 | // xhr.addEventListener('progress', (evt) => { 50 | // let { lengthComputable, loaded, total } = evt; 51 | // set({ state: 'loading', lengthComputable, loaded, total }); 52 | // }); 53 | // xhr.open('GET', url); 54 | // set({ state: 'loading', lengthComputable: false, loaded: 0, total: 0 }); 55 | // xhr.send(); 56 | // // this will only actually stop anyting if it's really long 57 | // return () => xhr.abort(); 58 | // } 59 | // , { subscribe, set } = writable(value, load) 60 | // , reload = () => { 61 | // set({ state: 'unknown' }); 62 | // return load(set); 63 | // } 64 | // ; 65 | // return { subscribe, reload }; 66 | // } 67 | 68 | // --- What follows is largely taken from Svelte (https://svelte.dev/docs#readable). Thanks Rich! 69 | 70 | // Creates a read-only store. 71 | // - `value` is the initial value, which may be null/undefined. 72 | // - `start` is a function that gets called when the first subscriber subscribes. It receives a 73 | // `set` function which should be called with the new value whenever it is updated. It must also 74 | // return a `stop` function that will get called when the last subscriber unsubscribes. 75 | // Returns an object with .subscribe(cb) exposed as an API, where `cb` will received the value when 76 | // it changes. This method returns a function to call to unsubscribe. 77 | export function readable (value, start) { 78 | return { subscribe: writable(value, start).subscribe }; 79 | } 80 | 81 | // Creates a regular read/write store. 82 | // The parameters are the same as for `readable` except that `start` is optional because you can 83 | // write to the value through the API. 84 | // It returns an object with: 85 | // - .subscribe(cb), which is the same as for readable() 86 | // - .set(val) which sets the store's value directly 87 | // - .update(updater) which gets a function that receives the value and returns it updated 88 | export function writable (value, start = () => {}) { 89 | let stop 90 | , subs = [] 91 | , set = (newValue) => { 92 | if (safeNotEqual(value, newValue)) { 93 | value = newValue; 94 | if (stop) { // store is ready 95 | let runQueue = !subscriberQueue.length; 96 | subs.forEach(s => { 97 | s[1](); 98 | subscriberQueue.push(s, value); 99 | }); 100 | if (runQueue) { 101 | for (let i = 0; i < subscriberQueue.length; i += 2) { 102 | subscriberQueue[i][0](subscriberQueue[i + 1]); 103 | } 104 | subscriberQueue.length = 0; 105 | } 106 | } 107 | } 108 | } 109 | , update = (fn) => set(fn(value)) 110 | , subscribe = (run, invalidate = () => {}) => { 111 | let subscriber = [run, invalidate]; 112 | subs.push(subscriber); 113 | if (subs.length === 1) stop = start(set) || (() => {}); 114 | run(value); 115 | return () => { 116 | let index = subs.indexOf(subscriber); 117 | if (index !== -1) subs.splice(index, 1); 118 | if (subs.length === 0) { 119 | stop(); 120 | stop = null; 121 | } 122 | }; 123 | } 124 | ; 125 | return { set, update, subscribe }; 126 | } 127 | 128 | // Reads a store once 129 | export function get (store) { 130 | if (!store) return; 131 | let value; 132 | store.subscribe(v => value = v)(); 133 | return value; 134 | } 135 | 136 | export function derived (stores, fn, initialValue) { 137 | if (!Array.isArray(stores)) stores = [stores]; 138 | let auto = fn.length < 2; 139 | 140 | return readable(initialValue, (set) => { 141 | let inited = false 142 | , values = [] 143 | , pending = 0 144 | , noop = () => {} 145 | , cleanup = noop 146 | , sync = () => { 147 | if (pending) return; 148 | cleanup(); 149 | let result = fn(values, set); 150 | if (auto) set(result); 151 | else cleanup = typeof result === 'function' ? result : noop; 152 | } 153 | , unsubscribers = stores.map((store, i) => store.subscribe( 154 | (value) => { 155 | values[i] = value; 156 | pending &= ~(1 << i); 157 | if (inited) sync(); 158 | }, 159 | () => pending |= (1 << i) 160 | )) 161 | ; 162 | 163 | inited = true; 164 | sync(); 165 | 166 | return function stop () { 167 | unsubscribers.forEach(fun => fun()); 168 | cleanup(); 169 | }; 170 | }); 171 | } 172 | 173 | // Equality function stolen from Svelte 174 | function safeNotEqual (a, b) { 175 | return a != a ? b == b : a !== b || 176 | ( 177 | (a && typeof a === 'object') || 178 | typeof a === 'function' 179 | ); 180 | } 181 | -------------------------------------------------------------------------------- /deps/lit.js: -------------------------------------------------------------------------------- 1 | // 2.2.4 2 | // update from https://cdn.jsdelivr.net/gh/lit/dist@2/all/lit-all.min.js 3 | 4 | /** 5 | * @license 6 | * Copyright 2019 Google LLC 7 | * SPDX-License-Identifier: BSD-3-Clause 8 | */ 9 | const t=window.ShadowRoot&&(void 0===window.ShadyCSS||window.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,i=Symbol(),s=new Map;class e{constructor(t,s){if(this._$cssResult$=!0,s!==i)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t}get styleSheet(){let i=s.get(this.cssText);return t&&void 0===i&&(s.set(this.cssText,i=new CSSStyleSheet),i.replaceSync(this.cssText)),i}toString(){return this.cssText}}const n=t=>new e("string"==typeof t?t:t+"",i),o=(t,...s)=>{const n=1===t.length?t[0]:s.reduce(((i,s,e)=>i+(t=>{if(!0===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(s)+t[e+1]),t[0]);return new e(n,i)},r=(i,s)=>{t?i.adoptedStyleSheets=s.map((t=>t instanceof CSSStyleSheet?t:t.styleSheet)):s.forEach((t=>{const s=document.createElement("style"),e=window.litNonce;void 0!==e&&s.setAttribute("nonce",e),s.textContent=t.cssText,i.appendChild(s)}))},l=t?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let i="";for(const s of t.cssRules)i+=s.cssText;return n(i)})(t):t 10 | /** 11 | * @license 12 | * Copyright 2017 Google LLC 13 | * SPDX-License-Identifier: BSD-3-Clause 14 | */;var h;const u=window.trustedTypes,c=u?u.emptyScript:"",a=window.reactiveElementPolyfillSupport,d={toAttribute(t,i){switch(i){case Boolean:t=t?c:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t)}return t},fromAttribute(t,i){let s=t;switch(i){case Boolean:s=null!==t;break;case Number:s=null===t?null:Number(t);break;case Object:case Array:try{s=JSON.parse(t)}catch(t){s=null}}return s}},v=(t,i)=>i!==t&&(i==i||t==t),f={attribute:!0,type:String,converter:d,reflect:!1,hasChanged:v};class p extends HTMLElement{constructor(){super(),this.t=new Map,this.isUpdatePending=!1,this.hasUpdated=!1,this.i=null,this.o()}static addInitializer(t){var i;null!==(i=this.l)&&void 0!==i||(this.l=[]),this.l.push(t)}static get observedAttributes(){this.finalize();const t=[];return this.elementProperties.forEach(((i,s)=>{const e=this.u(s,i);void 0!==e&&(this.v.set(e,s),t.push(e))})),t}static createProperty(t,i=f){if(i.state&&(i.attribute=!1),this.finalize(),this.elementProperties.set(t,i),!i.noAccessor&&!this.prototype.hasOwnProperty(t)){const s="symbol"==typeof t?Symbol():"__"+t,e=this.getPropertyDescriptor(t,s,i);void 0!==e&&Object.defineProperty(this.prototype,t,e)}}static getPropertyDescriptor(t,i,s){return{get(){return this[i]},set(e){const n=this[t];this[i]=e,this.requestUpdate(t,n,s)},configurable:!0,enumerable:!0}}static getPropertyOptions(t){return this.elementProperties.get(t)||f}static finalize(){if(this.hasOwnProperty("finalized"))return!1;this.finalized=!0;const t=Object.getPrototypeOf(this);if(t.finalize(),this.elementProperties=new Map(t.elementProperties),this.v=new Map,this.hasOwnProperty("properties")){const t=this.properties,i=[...Object.getOwnPropertyNames(t),...Object.getOwnPropertySymbols(t)];for(const s of i)this.createProperty(s,t[s])}return this.elementStyles=this.finalizeStyles(this.styles),!0}static finalizeStyles(t){const i=[];if(Array.isArray(t)){const s=new Set(t.flat(1/0).reverse());for(const t of s)i.unshift(l(t))}else void 0!==t&&i.push(l(t));return i}static u(t,i){const s=i.attribute;return!1===s?void 0:"string"==typeof s?s:"string"==typeof t?t.toLowerCase():void 0}o(){var t;this.p=new Promise((t=>this.enableUpdating=t)),this._$AL=new Map,this.g(),this.requestUpdate(),null===(t=this.constructor.l)||void 0===t||t.forEach((t=>t(this)))}addController(t){var i,s;(null!==(i=this.m)&&void 0!==i?i:this.m=[]).push(t),void 0!==this.renderRoot&&this.isConnected&&(null===(s=t.hostConnected)||void 0===s||s.call(t))}removeController(t){var i;null===(i=this.m)||void 0===i||i.splice(this.m.indexOf(t)>>>0,1)}g(){this.constructor.elementProperties.forEach(((t,i)=>{this.hasOwnProperty(i)&&(this.t.set(i,this[i]),delete this[i])}))}createRenderRoot(){var t;const i=null!==(t=this.shadowRoot)&&void 0!==t?t:this.attachShadow(this.constructor.shadowRootOptions);return r(i,this.constructor.elementStyles),i}connectedCallback(){var t;void 0===this.renderRoot&&(this.renderRoot=this.createRenderRoot()),this.enableUpdating(!0),null===(t=this.m)||void 0===t||t.forEach((t=>{var i;return null===(i=t.hostConnected)||void 0===i?void 0:i.call(t)}))}enableUpdating(t){}disconnectedCallback(){var t;null===(t=this.m)||void 0===t||t.forEach((t=>{var i;return null===(i=t.hostDisconnected)||void 0===i?void 0:i.call(t)}))}attributeChangedCallback(t,i,s){this._$AK(t,s)}_(t,i,s=f){var e,n;const o=this.constructor.u(t,s);if(void 0!==o&&!0===s.reflect){const r=(null!==(n=null===(e=s.converter)||void 0===e?void 0:e.toAttribute)&&void 0!==n?n:d.toAttribute)(i,s.type);this.i=t,null==r?this.removeAttribute(o):this.setAttribute(o,r),this.i=null}}_$AK(t,i){var s,e;const n=this.constructor,o=n.v.get(t);if(void 0!==o&&this.i!==o){const t=n.getPropertyOptions(o),r=t.converter,l=null!==(e=null!==(s=null==r?void 0:r.fromAttribute)&&void 0!==s?s:"function"==typeof r?r:null)&&void 0!==e?e:d.fromAttribute;this.i=o,this[o]=l(i,t.type),this.i=null}}requestUpdate(t,i,s){let e=!0;void 0!==t&&(((s=s||this.constructor.getPropertyOptions(t)).hasChanged||v)(this[t],i)?(this._$AL.has(t)||this._$AL.set(t,i),!0===s.reflect&&this.i!==t&&(void 0===this.S&&(this.S=new Map),this.S.set(t,s))):e=!1),!this.isUpdatePending&&e&&(this.p=this.$())}async $(){this.isUpdatePending=!0;try{await this.p}catch(t){Promise.reject(t)}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){var t;if(!this.isUpdatePending)return;this.hasUpdated,this.t&&(this.t.forEach(((t,i)=>this[i]=t)),this.t=void 0);let i=!1;const s=this._$AL;try{i=this.shouldUpdate(s),i?(this.willUpdate(s),null===(t=this.m)||void 0===t||t.forEach((t=>{var i;return null===(i=t.hostUpdate)||void 0===i?void 0:i.call(t)})),this.update(s)):this.C()}catch(t){throw i=!1,this.C(),t}i&&this._$AE(s)}willUpdate(t){}_$AE(t){var i;null===(i=this.m)||void 0===i||i.forEach((t=>{var i;return null===(i=t.hostUpdated)||void 0===i?void 0:i.call(t)})),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(t)),this.updated(t)}C(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this.p}shouldUpdate(t){return!0}update(t){void 0!==this.S&&(this.S.forEach(((t,i)=>this._(i,this[i],t))),this.S=void 0),this.C()}updated(t){}firstUpdated(t){}} 15 | /** 16 | * @license 17 | * Copyright 2017 Google LLC 18 | * SPDX-License-Identifier: BSD-3-Clause 19 | */ 20 | var y;p.finalized=!0,p.elementProperties=new Map,p.elementStyles=[],p.shadowRootOptions={mode:"open"},null==a||a({ReactiveElement:p}),(null!==(h=globalThis.reactiveElementVersions)&&void 0!==h?h:globalThis.reactiveElementVersions=[]).push("1.3.2");const b=globalThis.trustedTypes,w=b?b.createPolicy("lit-html",{createHTML:t=>t}):void 0,m=`lit$${(Math.random()+"").slice(9)}$`,g="?"+m,_=`<${g}>`,$=document,S=(t="")=>$.createComment(t),T=t=>null===t||"object"!=typeof t&&"function"!=typeof t,x=Array.isArray,E=t=>x(t)||"function"==typeof(null==t?void 0:t[Symbol.iterator]),C=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,A=/-->/g,k=/>/g,M=/>|[ \n \r](?:([^\s"'>=/]+)([ \n \r]*=[ \n \r]*(?:[^ \n \r"'`<>=]|("|')|))|$)/g,P=/'/g,U=/"/g,V=/^(?:script|style|textarea|title)$/i,R=t=>(i,...s)=>({_$litType$:t,strings:i,values:s}),N=R(1),O=R(2),L=Symbol.for("lit-noChange"),j=Symbol.for("lit-nothing"),z=new WeakMap,H=(t,i,s)=>{var e,n;const o=null!==(e=null==s?void 0:s.renderBefore)&&void 0!==e?e:i;let r=o._$litPart$;if(void 0===r){const t=null!==(n=null==s?void 0:s.renderBefore)&&void 0!==n?n:null;o._$litPart$=r=new q(i.insertBefore(S(),t),t,void 0,null!=s?s:{})}return r._$AI(t),r},I=$.createTreeWalker($,129,null,!1),B=(t,i)=>{const s=t.length-1,e=[];let n,o=2===i?"":"",r=C;for(let i=0;i"===h[0]?(r=null!=n?n:C,u=-1):void 0===h[1]?u=-2:(u=r.lastIndex-h[2].length,l=h[1],r=void 0===h[3]?M:'"'===h[3]?U:P):r===U||r===P?r=M:r===A||r===k?r=C:(r=M,n=void 0);const a=r===M&&t[i+1].startsWith("/>")?" ":"";o+=r===C?s+_:u>=0?(e.push(l),s.slice(0,u)+"$lit$"+s.slice(u)+m+a):s+m+(-2===u?(e.push(void 0),i):a)}const l=o+(t[s]||"")+(2===i?"":"");if(!Array.isArray(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return[void 0!==w?w.createHTML(l):l,e]};class D{constructor({strings:t,_$litType$:i},s){let e;this.parts=[];let n=0,o=0;const r=t.length-1,l=this.parts,[h,u]=B(t,i);if(this.el=D.createElement(h,s),I.currentNode=this.el.content,2===i){const t=this.el.content,i=t.firstChild;i.remove(),t.append(...i.childNodes)}for(;null!==(e=I.nextNode())&&l.length0){e.textContent=b?b.emptyScript:"";for(let s=0;s2||""!==s[0]||""!==s[1]?(this._$AH=Array(s.length-1).fill(new String),this.strings=s):this._$AH=j}get tagName(){return this.element.tagName}get _$AU(){return this._$AM._$AU}_$AI(t,i=this,s,e){const n=this.strings;let o=!1;if(void 0===n)t=W(this,t,i,0),o=!T(t)||t!==this._$AH&&t!==L,o&&(this._$AH=t);else{const e=t;let r,l;for(t=n[0],r=0;r{t._$AK(i,s)},_$AL:t=>t._$AL};(null!==(st=globalThis.litElementVersions)&&void 0!==st?st:globalThis.litElementVersions=[]).push("3.2.0"); 27 | /** 28 | * @license 29 | * Copyright 2020 Google LLC 30 | * SPDX-License-Identifier: BSD-3-Clause 31 | */ 32 | const{W:lt}=X,ht=t=>null===t||"object"!=typeof t&&"function"!=typeof t,ut={HTML:1,SVG:2},ct=(t,i)=>void 0===i?void 0!==(null==t?void 0:t._$litType$):(null==t?void 0:t._$litType$)===i,at=t=>void 0!==(null==t?void 0:t._$litDirective$),dt=t=>null==t?void 0:t._$litDirective$,vt=t=>void 0===t.strings,ft=()=>document.createComment(""),pt=(t,i,s)=>{var e;const n=t._$AA.parentNode,o=void 0===i?t._$AB:i._$AA;if(void 0===s){const i=n.insertBefore(ft(),o),e=n.insertBefore(ft(),o);s=new lt(i,e,t,t.options)}else{const i=s._$AB.nextSibling,r=s._$AM,l=r!==t;if(l){let i;null===(e=s._$AQ)||void 0===e||e.call(s,t),s._$AM=t,void 0!==s._$AP&&(i=t._$AU)!==r._$AU&&s._$AP(i)}if(i!==o||l){let t=s._$AA;for(;t!==i;){const i=t.nextSibling;n.insertBefore(t,o),t=i}}}return s},yt=(t,i,s=t)=>(t._$AI(i,s),t),bt={},wt=(t,i=bt)=>t._$AH=i,mt=t=>t._$AH,gt=t=>{var i;null===(i=t._$AP)||void 0===i||i.call(t,!1,!0);let s=t._$AA;const e=t._$AB.nextSibling;for(;s!==e;){const t=s.nextSibling;s.remove(),s=t}},_t=t=>{t._$AR()},$t={ATTRIBUTE:1,CHILD:2,PROPERTY:3,BOOLEAN_ATTRIBUTE:4,EVENT:5,ELEMENT:6},St=t=>(...i)=>({_$litDirective$:t,values:i});class Tt{constructor(t){}get _$AU(){return this._$AM._$AU}_$AT(t,i,s){this.st=t,this._$AM=i,this.et=s}_$AS(t,i){return this.update(t,i)}update(t,i){return this.render(...i)}} 33 | /** 34 | * @license 35 | * Copyright 2017 Google LLC 36 | * SPDX-License-Identifier: BSD-3-Clause 37 | */const xt=(t,i)=>{var s,e;const n=t._$AN;if(void 0===n)return!1;for(const t of n)null===(e=(s=t)._$AO)||void 0===e||e.call(s,i,!1),xt(t,i);return!0},Et=t=>{let i,s;do{if(void 0===(i=t._$AM))break;s=i._$AN,s.delete(t),t=i}while(0===(null==s?void 0:s.size))},Ct=t=>{for(let i;i=t._$AM;t=i){let s=i._$AN;if(void 0===s)i._$AN=s=new Set;else if(s.has(t))break;s.add(t),Mt(i)}};function At(t){void 0!==this._$AN?(Et(this),this._$AM=t,Ct(this)):this._$AM=t}function kt(t,i=!1,s=0){const e=this._$AH,n=this._$AN;if(void 0!==n&&0!==n.size)if(i)if(Array.isArray(e))for(let t=s;t{var i,s,e,n;2==t.type&&(null!==(i=(e=t)._$AP)&&void 0!==i||(e._$AP=kt),null!==(s=(n=t)._$AQ)&&void 0!==s||(n._$AQ=At))};class Pt extends Tt{constructor(){super(...arguments),this._$AN=void 0}_$AT(t,i,s){super._$AT(t,i,s),Ct(this),this.isConnected=t._$AU}_$AO(t,i=!0){var s,e;t!==this.isConnected&&(this.isConnected=t,t?null===(s=this.reconnected)||void 0===s||s.call(this):null===(e=this.disconnected)||void 0===e||e.call(this)),i&&(xt(this,t),Et(this))}setValue(t){if(vt(this.st))this.st._$AI(t,this);else{const i=[...this.st._$AH];i[this.et]=t,this.st._$AI(i,this,0)}}disconnected(){}reconnected(){}} 38 | /** 39 | * @license 40 | * Copyright 2021 Google LLC 41 | * SPDX-License-Identifier: BSD-3-Clause 42 | */class Ut{constructor(t){this.nt=t}disconnect(){this.nt=void 0}reconnect(t){this.nt=t}deref(){return this.nt}}class Vt{constructor(){this.ot=void 0,this.rt=void 0}get(){return this.ot}pause(){var t;null!==(t=this.ot)&&void 0!==t||(this.ot=new Promise((t=>this.rt=t)))}resume(){var t;null===(t=this.rt)||void 0===t||t.call(this),this.ot=this.rt=void 0}} 43 | /** 44 | * @license 45 | * Copyright 2017 Google LLC 46 | * SPDX-License-Identifier: BSD-3-Clause 47 | */class Rt extends Pt{constructor(){super(...arguments),this.lt=new Ut(this),this.ht=new Vt}render(t,i){return L}update(t,[i,s]){if(this.isConnected||this.disconnected(),i===this.ut)return;this.ut=i;let e=0;const{lt:n,ht:o}=this;return(async(t,i)=>{for await(const s of t)if(!1===await i(s))return})(i,(async t=>{for(;o.get();)await o.get();const r=n.deref();if(void 0!==r){if(r.ut!==i)return!1;void 0!==s&&(t=s(t,e)),r.commitValue(t,e),e++}return!0})),L}commitValue(t,i){this.setValue(t)}disconnected(){this.lt.disconnect(),this.ht.pause()}reconnected(){this.lt.reconnect(this),this.ht.resume()}}const Nt=St(Rt),Ot=St( 48 | /** 49 | * @license 50 | * Copyright 2017 Google LLC 51 | * SPDX-License-Identifier: BSD-3-Clause 52 | */ 53 | class extends Rt{constructor(t){if(super(t),2!==t.type)throw Error("asyncAppend can only be used in child expressions")}update(t,i){return this.it=t,super.update(t,i)}commitValue(t,i){0===i&&_t(this.it);const s=pt(this.it);yt(s,t)}}),Lt=St( 54 | /** 55 | * @license 56 | * Copyright 2017 Google LLC 57 | * SPDX-License-Identifier: BSD-3-Clause 58 | */ 59 | class extends Tt{constructor(t){super(t),this.ct=new WeakMap}render(t){return[t]}update(t,[i]){if(ct(this.dt)&&(!ct(i)||this.dt.strings!==i.strings)){const i=mt(t).pop();let s=this.ct.get(this.dt.strings);if(void 0===s){const t=document.createDocumentFragment();s=H(j,t),s.setConnected(!1),this.ct.set(this.dt.strings,s)}wt(s,[i]),pt(s,void 0,i)}if(ct(i)){if(!ct(this.dt)||this.dt.strings!==i.strings){const s=this.ct.get(i.strings);if(void 0!==s){const i=mt(s).pop();_t(t),pt(t,void 0,i),wt(t,[i])}}this.dt=i}else this.dt=void 0;return this.render(i)}}),jt=(t,i,s)=>{for(const s of i)if(s[0]===t)return(0,s[1])();return null==s?void 0:s()},zt=St( 60 | /** 61 | * @license 62 | * Copyright 2018 Google LLC 63 | * SPDX-License-Identifier: BSD-3-Clause 64 | */ 65 | class extends Tt{constructor(t){var i;if(super(t),1!==t.type||"class"!==t.name||(null===(i=t.strings)||void 0===i?void 0:i.length)>2)throw Error("`classMap()` can only be used in the `class` attribute and must be the only part in the attribute.")}render(t){return" "+Object.keys(t).filter((i=>t[i])).join(" ")+" "}update(t,[i]){var s,e;if(void 0===this.vt){this.vt=new Set,void 0!==t.strings&&(this.ft=new Set(t.strings.join(" ").split(/\s/).filter((t=>""!==t))));for(const t in i)i[t]&&!(null===(s=this.ft)||void 0===s?void 0:s.has(t))&&this.vt.add(t);return this.render(i)}const n=t.element.classList;this.vt.forEach((t=>{t in i||(n.remove(t),this.vt.delete(t))}));for(const t in i){const s=!!i[t];s===this.vt.has(t)||(null===(e=this.ft)||void 0===e?void 0:e.has(t))||(s?(n.add(t),this.vt.add(t)):(n.remove(t),this.vt.delete(t)))}return L}}),Ht={},It=St(class extends Tt{constructor(){super(...arguments),this.yt=Ht}render(t,i){return i()}update(t,[i,s]){if(Array.isArray(i)){if(Array.isArray(this.yt)&&this.yt.length===i.length&&i.every(((t,i)=>t===this.yt[i])))return L}else if(this.yt===i)return L;return this.yt=Array.isArray(i)?Array.from(i):i,this.render(i,s)}}),Bt=t=>null!=t?t:j 66 | /** 67 | * @license 68 | * Copyright 2021 Google LLC 69 | * SPDX-License-Identifier: BSD-3-Clause 70 | */;function*Dt(t,i){const s="function"==typeof i;if(void 0!==t){let e=-1;for(const n of t)e>-1&&(yield s?i(e):i),e++,yield n}} 71 | /** 72 | * @license 73 | * Copyright 2021 Google LLC 74 | * SPDX-License-Identifier: BSD-3-Clause 75 | */const Wt=St(class extends Tt{constructor(){super(...arguments),this.key=j}render(t,i){return this.key=t,i}update(t,[i,s]){return i!==this.key&&(wt(t),this.key=i),s}}),Zt=St( 76 | /** 77 | * @license 78 | * Copyright 2020 Google LLC 79 | * SPDX-License-Identifier: BSD-3-Clause 80 | */ 81 | class extends Tt{constructor(t){if(super(t),3!==t.type&&1!==t.type&&4!==t.type)throw Error("The `live` directive is not allowed on child or event bindings");if(!vt(t))throw Error("`live` bindings can only contain a single expression")}render(t){return t}update(t,[i]){if(i===L||i===j)return i;const s=t.element,e=t.name;if(3===t.type){if(i===s[e])return L}else if(4===t.type){if(!!i===s.hasAttribute(e))return L}else if(1===t.type&&s.getAttribute(e)===i+"")return L;return wt(t),i}}); 82 | /** 83 | * @license 84 | * Copyright 2021 Google LLC 85 | * SPDX-License-Identifier: BSD-3-Clause 86 | */ 87 | function*qt(t,i){if(void 0!==t){let s=0;for(const e of t)yield i(e,s++)}} 88 | /** 89 | * @license 90 | * Copyright 2021 Google LLC 91 | * SPDX-License-Identifier: BSD-3-Clause 92 | */function*Ft(t,i,s=1){const e=void 0===i?0:t;null!=i||(i=t);for(let t=e;s>0?tnew Jt;class Jt{}const Kt=new WeakMap,Yt=St(class extends Pt{render(t){return j}update(t,[i]){var s;const e=i!==this.nt;return e&&void 0!==this.nt&&this.bt(void 0),(e||this.wt!==this.gt)&&(this.nt=i,this._t=null===(s=t.options)||void 0===s?void 0:s.host,this.bt(this.gt=t.element)),j}bt(t){var i;if("function"==typeof this.nt){const s=null!==(i=this._t)&&void 0!==i?i:globalThis;let e=Kt.get(s);void 0===e&&(e=new WeakMap,Kt.set(s,e)),void 0!==e.get(this.nt)&&this.nt.call(this._t,void 0),e.set(this.nt,t),void 0!==t&&this.nt.call(this._t,t)}else this.nt.value=t}get wt(){var t,i,s;return"function"==typeof this.nt?null===(i=Kt.get(null!==(t=this._t)&&void 0!==t?t:globalThis))||void 0===i?void 0:i.get(this.nt):null===(s=this.nt)||void 0===s?void 0:s.value}disconnected(){this.wt===this.gt&&this.bt(void 0)}reconnected(){this.bt(this.gt)}}),Qt=(t,i,s)=>{const e=new Map;for(let n=i;n<=s;n++)e.set(t[n],n);return e},Xt=St(class extends Tt{constructor(t){if(super(t),2!==t.type)throw Error("repeat() can only be used in text expressions")}$t(t,i,s){let e;void 0===s?s=i:void 0!==i&&(e=i);const n=[],o=[];let r=0;for(const i of t)n[r]=e?e(i,r):r,o[r]=s(i,r),r++;return{values:o,keys:n}}render(t,i,s){return this.$t(t,i,s).values}update(t,[i,s,e]){var n;const o=mt(t),{values:r,keys:l}=this.$t(i,s,e);if(!Array.isArray(o))return this.St=l,r;const h=null!==(n=this.St)&&void 0!==n?n:this.St=[],u=[];let c,a,d=0,v=o.length-1,f=0,p=r.length-1;for(;d<=v&&f<=p;)if(null===o[d])d++;else if(null===o[v])v--;else if(h[d]===l[f])u[f]=yt(o[d],r[f]),d++,f++;else if(h[v]===l[p])u[p]=yt(o[v],r[p]),v--,p--;else if(h[d]===l[p])u[p]=yt(o[d],r[p]),pt(t,u[p+1],o[d]),d++,p--;else if(h[v]===l[f])u[f]=yt(o[v],r[f]),pt(t,o[d],o[v]),v--,f++;else if(void 0===c&&(c=Qt(l,f,p),a=Qt(h,d,v)),c.has(h[d]))if(c.has(h[v])){const i=a.get(l[f]),s=void 0!==i?o[i]:null;if(null===s){const i=pt(t,o[d]);yt(i,r[f]),u[f]=i}else u[f]=yt(s,r[f]),pt(t,o[d],s),o[i]=null;f++}else gt(o[v]),v--;else gt(o[d]),d++;for(;f<=p;){const i=pt(t,u[p+1]);yt(i,r[f]),u[f++]=i}for(;d<=v;){const t=o[d++];null!==t&>(t)}return this.St=l,wt(t,u),L}}),ti=St( 98 | /** 99 | * @license 100 | * Copyright 2018 Google LLC 101 | * SPDX-License-Identifier: BSD-3-Clause 102 | */ 103 | class extends Tt{constructor(t){var i;if(super(t),1!==t.type||"style"!==t.name||(null===(i=t.strings)||void 0===i?void 0:i.length)>2)throw Error("The `styleMap` directive must be used in the `style` attribute and must be the only part in the attribute.")}render(t){return Object.keys(t).reduce(((i,s)=>{const e=t[s];return null==e?i:i+`${s=s.replace(/(?:^(webkit|moz|ms|o)|)(?=[A-Z])/g,"-$&").toLowerCase()}:${e};`}),"")}update(t,[i]){const{style:s}=t.element;if(void 0===this.Tt){this.Tt=new Set;for(const t in i)this.Tt.add(t);return this.render(i)}this.Tt.forEach((t=>{null==i[t]&&(this.Tt.delete(t),t.includes("-")?s.removeProperty(t):s[t]="")}));for(const t in i){const e=i[t];null!=e&&(this.Tt.add(t),t.includes("-")?s.setProperty(t,e):s[t]=e)}return L}}),ii=St( 104 | /** 105 | * @license 106 | * Copyright 2020 Google LLC 107 | * SPDX-License-Identifier: BSD-3-Clause 108 | */ 109 | class extends Tt{constructor(t){if(super(t),2!==t.type)throw Error("templateContent can only be used in child bindings")}render(t){return this.xt===t?L:(this.xt=t,document.importNode(t.content,!0))}});class si extends Tt{constructor(t){if(super(t),this.dt=j,2!==t.type)throw Error(this.constructor.directiveName+"() can only be used in child bindings")}render(t){if(t===j||null==t)return this.Et=void 0,this.dt=t;if(t===L)return t;if("string"!=typeof t)throw Error(this.constructor.directiveName+"() called with a non-string value");if(t===this.dt)return this.Et;this.dt=t;const i=[t];return i.raw=i,this.Et={_$litType$:this.constructor.resultType,strings:i,values:[]}}}si.directiveName="unsafeHTML",si.resultType=1;const ei=St(si); 110 | /** 111 | * @license 112 | * Copyright 2017 Google LLC 113 | * SPDX-License-Identifier: BSD-3-Clause 114 | */class ni extends si{}ni.directiveName="unsafeSVG",ni.resultType=2;const oi=St(ni),ri=t=>!ht(t)&&"function"==typeof t.then; 115 | /** 116 | * @license 117 | * Copyright 2017 Google LLC 118 | * SPDX-License-Identifier: BSD-3-Clause 119 | */class li extends Pt{constructor(){super(...arguments),this.Ct=1073741823,this.At=[],this.lt=new Ut(this),this.ht=new Vt}render(...t){var i;return null!==(i=t.find((t=>!ri(t))))&&void 0!==i?i:L}update(t,i){const s=this.At;let e=s.length;this.At=i;const n=this.lt,o=this.ht;this.isConnected||this.disconnected();for(let t=0;tthis.Ct);t++){const r=i[t];if(!ri(r))return this.Ct=t,r;t{for(;o.get();)await o.get();const i=n.deref();if(void 0!==i){const s=i.At.indexOf(r);s>-1&&s{if((null==t?void 0:t.r)===ci)return null==t?void 0:t._$litStatic$},di=t=>({_$litStatic$:t,r:ci}),vi=(t,...i)=>({_$litStatic$:i.reduce(((i,s,e)=>i+(t=>{if(void 0!==t._$litStatic$)return t._$litStatic$;throw Error(`Value passed to 'literal' function must be a 'literal' result: ${t}. Use 'unsafeStatic' to pass non-literal values, but\n take care to ensure page security.`)})(s)+t[e+1]),t[0]),r:ci}),fi=new Map,pi=t=>(i,...s)=>{const e=s.length;let n,o;const r=[],l=[];let h,u=0,c=!1;for(;u