├── 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`reload `;
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``;
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``;
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 | debug
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 `` addition). When the user
21 | chooses the intent they want, it gets rendered and its `window` object has an `intent` event
22 | dispatched to it with the `action`, `type`, `data` provided but also a `responseChannel` (used
23 | to signal success or failure, some limited things for now — it talks to the `intent` object
24 | returned from `envoyager.intent()`) as well as a `wican` object which is a form of UCAN token
25 | (except Web Intents Controlled Authorization Networks — gettit?). That `wican` can be used to
26 | carry out actions like signing an item as the initiator.
27 |
28 | ## Declaring Intent Handlers
29 |
30 | Any item can have an `intents` field which is an object looking like this:
31 |
32 | ```js
33 | {
34 | name: 'Photo Album',
35 | icon: {
36 | $type: 'Image',
37 | mediaType: 'image/png',
38 | src: CID(…),
39 | },
40 | actions: {
41 | pick: {
42 | name: 'Pick Image',
43 | types: ['image/*'],
44 | path: '/picker.html',
45 | icon: {
46 | $type: 'Image',
47 | mediaType: 'image/png',
48 | src: CID(…),
49 | },
50 | },
51 | }
52 | }
53 | ```
54 |
55 | When an item is _installed_, its intents become registered for use with Envoyager (correspondingly,
56 | uninstalled). When looking for intents, Envoyager looks for some that support the action and type,
57 | and present them using the applicable name and icon (falling back to parent ones). When loaded, the
58 | path (if given) is what gets loaded.
59 |
60 | Note that some intents are internal.
61 |
--------------------------------------------------------------------------------
/client/el/envoyager/feed-list.js:
--------------------------------------------------------------------------------
1 |
2 | import { LitElement, css, html, nothing } from '../../../deps/lit.js';
3 | import { buttonStyles } from '../../button-styles.js';
4 |
5 | class EnvoyagerFeedList extends LitElement {
6 | static properties = {
7 | src: {},
8 | data: { attribute: false },
9 | user: { attribute: false },
10 | addable: { type: Boolean },
11 | creator: { type: Boolean },
12 | loading: { attribute: false },
13 | };
14 | static styles = [css`
15 | :host {
16 | display: block;
17 | }
18 | #creator {
19 | text-align: right;
20 | }
21 | #creator img {
22 | border-radius: 50%;
23 | }
24 | #actions {
25 | text-align: right;
26 | }
27 | nv-item-card {
28 | border-bottom: 1px solid var(--lightest);
29 | }
30 | nv-item-card:last-of-type {
31 | border-bottom: none;
32 | }
33 | `, buttonStyles];
34 |
35 | constructor () {
36 | super();
37 | this.src = null;
38 | this.data = {};
39 | this.user = null;
40 | this.addable = false;
41 | this.creator = false;
42 | this.loading = false;
43 | }
44 |
45 | willUpdate (props) {
46 | if (props.has('src')) this.refresh();
47 | }
48 |
49 | refresh () {
50 | console.warn(`Refreshing…`);
51 | this.loading = true;
52 | fetch(this.src, { headers: { Accept: 'application/json' }})
53 | .then((r) => r.json())
54 | .then((feed) => {
55 | this.loading = false;
56 | this.data = feed;
57 | })
58 | ;
59 | }
60 |
61 | async intendToAddFeed () {
62 | const intentID = await window.envoyager.intent('create', 'envoyager/feed', { parent: this.src, position: 'prepend', creator: this.user });
63 | window.intentListener.once('success', intentID, () => this.refresh());
64 | }
65 |
66 | render () {
67 | if (this.loading) return html`
`;
68 | return html`
69 | ${
70 | this.data?.name
71 | ? html`
${this.data.name} `
72 | : nothing
73 | }
74 | ${
75 | this.creator && this.data?.creator
76 | ? html`
`
77 | : nothing
78 | }
79 | ${
80 | this.data?.items?.length
81 | ? this.data.items.map(it => html`
`)
82 | : nothing
83 | }
84 | ${
85 | this.addable
86 | ? html`
+ Add Feed
`
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 |
79 | envoy ager
80 |
86 |
87 |
88 |
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 |
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 | Cancel
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` this.installIntent(e)} class="small discreet"> `);
138 | }
139 | actions.push(html` this.saveItem(e)} class="small discreet"> `);
140 |
141 | return html`
142 |
143 |
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