initial version

This commit is contained in:
=
2023-03-06 16:10:41 +00:00
parent 8c73b7a5a6
commit d696b108d5
22 changed files with 5187 additions and 0 deletions

18
.eslintrc.js Normal file
View File

@@ -0,0 +1,18 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
plugins: [
'@typescript-eslint',
],
// extends: [
// 'eslint:recommended',
// 'plugin:@typescript-eslint/recommended',
// ],
extends: [
'airbnb-base',
'airbnb-typescript/base'
],
parserOptions: {
project: './tsconfig.json'
},
};

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
tmp

10
jest.config.js Normal file
View File

@@ -0,0 +1,10 @@
module.exports = {
roots: ['<rootDir>/src'],
testMatch: [
"**/__tests__/**/*.+(ts|tsx|js)",
"**/?(*.)+(spec|test).+(ts|tsx|js)"
],
transform: {
"^.+\\.(ts|tsx)$": "ts-jest"
},
}

37
package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "nostr-profile-manager",
"version": "1.0.0",
"main": "index.ts",
"author": "= <=>",
"license": "MIT",
"scripts": {
"lint": "eslint src/ --ext .js,.jsx,.ts,.tsx",
"build-js": "esbuild src/index.ts --bundle --minify --sourcemap=external --outfile=dist/index.js",
"build-css": "yarn sass src/style.scss dist/style.css --style compressed",
"build-html": "cp src/index.htm dist/index.htm",
"build": "rm -rf dist && yarn build-js && yarn build-css && yarn build-html && cp -r src/img dist/img",
"test": "yarn jest",
"serve": "rm -rf dist && yarn build-css && yarn build-html && cp -r src/img dist/img && yarn build-js --servedir=dist",
"watch": "rm -rf dist && yarn build-css && yarn build-html && cp -r src/img dist/img && yarn build-js --servedir=dist --watch"
},
"devDependencies": {
"@types/jest": "^29.4.0",
"@typescript-eslint/eslint-plugin": "^5.54.0",
"@typescript-eslint/parser": "^5.54.0",
"esbuild": "^0.17.8",
"eslint": "^8.34.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-airbnb-typescript": "^17.0.0",
"eslint-plugin-import": "^2.27.5",
"jest": "^29.4.3",
"sass": "^1.58.2",
"ts-jest": "^29.0.5",
"typescript": "^4.9.5"
},
"dependencies": {
"@picocss/pico": "^1.5.7",
"nostr-tools": "^1.5.0",
"timeago.js": "^4.0.2",
"websocket-polyfill": "^0.0.3"
}
}

50
readme.md Normal file
View File

@@ -0,0 +1,50 @@
# Nostr Profile Manager
Lightweight and efficent typescript micro app for basic nostr profile management. Current USP is offline backup and restore.
Only javascript dependancy is [nostr-tools](https://github.com/nbd-wtf/nostr-tools). no JS frameworks. no state management tools.
## Features
Supported profile events: kind `0`, `2`, `10002` and `3`.
##### Backup and Restore
- [x] backup your profile events to offline browser storage
- [x] review changes between backups
- [x] `0`
- [ ] `10002` and `2`
- [x] `3`
- [ ] selectively restore previous versions
- [x] download profile backup history as JSON file
- [ ] restore backups from JSON file
##### Refine
- [x] Metadata
- [x] basic editing
- [x] nip05 verifiation
- [x] profile and banner previews
- [x] preserve, edit and remove custom properties
- [ ] Contacts
- [ ] Add Contacts based on nip05, npub or hex
- [ ] Remove Contacts
- [ ] Edit petname and relay
- [ ] Suggestions Engine
- [ ] Contacts recommendation based off social graph
- [ ] Suggest updates to contact relay based on Contact's kind `10002` and `2` events
- [ ] Relays
- [ ] editable table of read / write relays kind `10002` event
- [ ] auto suggestion of `10002` event based on contact's relays if no event present
- [ ] evaluation of `10002` based on contact's
- [ ] decentralisation score to encourage users not to use the same relay
##### Lightweight and Efficent
- [ ] only javascript dependancy is nostr-tools (TODO: remove timeago)
- [x] connects to the minimum number of relays
- [ ] connect relays specified in `10002` or `2`
- [ ] if no `10002` or `2` events are found it crawls through a number of popular relays to ensure it has your latest profile events. (currently it just connects to damus)
- [x] minimises the number of open websockets
- [ ] use blastr relay to send profile events far and wide

22
src/LoadContactsPage.ts Normal file
View File

@@ -0,0 +1,22 @@
import { fetchCachedProfileEventHistory } from './fetchEvents';
import { generateHistoryTable } from './LoadHistory';
const loadContactsBackupHistory = (RootElementID:string) => {
(document.getElementById(RootElementID) as HTMLDivElement)
.innerHTML = `<div class="contactsbackuphistory">
<h3>Contacts Backup History</h3>
${generateHistoryTable(fetchCachedProfileEventHistory(3))}
</div>`;
};
const LoadContactsPage = () => {
const o:HTMLElement = document.getElementById('PM-container') as HTMLElement;
o.innerHTML = `
<div id="contactspage" class="container">
<div id="contactsbackuphistory"></div>
<div>
`;
loadContactsBackupHistory('contactsbackuphistory');
};
export default LoadContactsPage;

126
src/LoadHistory.test.ts Normal file
View File

@@ -0,0 +1,126 @@
import {
generateContactsChanges, generateHistoryTable, generateMetadataChanges, Kind3Event,
} from './LoadHistory';
import { MetadataFlex } from './LoadMetadataPage';
import SampleEvents from './SampleEvents';
const weekago = Math.round((Date.now() / 1000) - (60 * 60 * 24 * 7.1));
const monthsago = Math.round((Date.now() / 1000) - (60 * 60 * 24 * 7 * 5));
describe('generateHistoryTable', () => {
test('null history parameter returns <p>none</p>', () => {
expect(generateHistoryTable(null)).toEqual('<p>none</p>');
});
test('ago property reflects created_at date for each change', () => {
const r = generateHistoryTable([
{ ...SampleEvents.kind0, created_at: weekago },
{ ...SampleEvents.kind0, created_at: monthsago },
]);
expect(r).toContain('1 week ago');
expect(r).toContain('1 month ago');
});
});
describe('generateMetadataChanges', () => {
const kind0content = JSON.parse(SampleEvents.kind0.content) as MetadataFlex;
test('last event list all the fields, one per item in change array', () => {
const r = generateMetadataChanges([
{ ...SampleEvents.kind0 },
{
...SampleEvents.kind0,
content: JSON.stringify({
name: 'Bob',
about: 'my profile is great!',
picture: 'https://example.com/profile.png',
}),
},
]);
expect(r[1].changes).toEqual([
'name: Bob',
'about: my profile is great!',
'picture: https://example.com/profile.png',
]);
});
test('when a content property is added, the addition is listed in the changes array', () => {
const r = generateMetadataChanges([
{
...SampleEvents.kind0,
content: JSON.stringify({ ...kind0content, custom: 'custom property value' }),
},
{ ...SampleEvents.kind0 },
]);
expect(r[0].changes).toEqual(['added custom: custom property value']);
});
test('when a content property is modified, the modification is listed in the changes array', () => {
const r = generateMetadataChanges([
{
...SampleEvents.kind0,
content: JSON.stringify({ ...kind0content, name: 'Bob' }),
},
{ ...SampleEvents.kind0 },
]);
expect(r[0].changes).toEqual(['modified name: Bob']);
});
test('when a content property is removed, the removal is listed in the changes array', () => {
const c = { ...kind0content };
delete c.about;
const r = generateMetadataChanges([
{ ...SampleEvents.kind0, content: JSON.stringify(c) },
{ ...SampleEvents.kind0 },
]);
expect(r[0].changes).toEqual(['removed about']);
});
test('when a content properties are added, modified and removed, this is all referenced in the changes array', () => {
const c = { ...kind0content, name: 'Bob', custom: 'custom property value' };
delete c.about;
const r = generateMetadataChanges([
{ ...SampleEvents.kind0, content: JSON.stringify(c) },
{ ...SampleEvents.kind0 },
]);
expect(r[0].changes).toEqual([
'added custom: custom property value',
'modified name: Bob',
'removed about',
]);
});
});
describe('generateContactsChanges', () => {
test('the oldest event list all the contacts as a single change', () => {
const r = generateContactsChanges([
{ ...SampleEvents.kind3 } as Kind3Event,
]);
expect(r[0].changes).toEqual(['<mark>alice</mark>, <mark>bob</mark>, <mark>carol</mark>']);
});
test('when a contact is added, the addition is listed in the changes array', () => {
const s = JSON.parse(JSON.stringify(SampleEvents.kind3));
s.tags.push(['p', '3248364987321649321', '', 'fred']);
const r = generateContactsChanges([
s,
{ ...SampleEvents.kind3 },
]);
expect(r[0].changes).toEqual(['<div class="added">added <mark>fred</mark></div>']);
});
test('when a contact is removed, the removal is listed in the changes array', () => {
const s = JSON.parse(JSON.stringify(SampleEvents.kind3));
delete s.tags[2];
const r = generateContactsChanges([
s,
{ ...SampleEvents.kind3 },
]);
expect(r[0].changes).toEqual(['<div class="removed">removed <mark>carol</mark></div>']);
});
test('when a contact is added and another removed, both events are listed in the changes array', () => {
const s = JSON.parse(JSON.stringify(SampleEvents.kind3));
delete s.tags[2];
s.tags.push(['p', '3248364987321649321', '', 'fred']);
const r = generateContactsChanges([
s,
{ ...SampleEvents.kind3 },
]);
expect(r[0].changes).toEqual([
'<div class="added">added <mark>fred</mark></div>',
'<div class="removed">removed <mark>carol</mark></div>',
]);
});
});

114
src/LoadHistory.ts Normal file
View File

@@ -0,0 +1,114 @@
import * as timeago from 'timeago.js';
import { Event } from 'nostr-tools';
export type VersionChange = {
ago:number;
changes:string[];
option:string;
};
const generateChangesTable = (changes:VersionChange[]) => `
<table role="grid" class="historytable">
<tbody>${changes.map((c) => `
<tr>
<td><small>${timeago.format(c.ago * 1000)}</small></td>
<td><ul>${c.changes.map((v) => `<li>${v}</li>`).join('')}</ul></td>
<td>${c.option}</td>
</tr>
`)}
</tbody>
</table>
`;
export const generateMetadataChanges = (
history: Event[],
):VersionChange[] => history.map((e, i, a) => {
const changes:string[] = [];
const c = JSON.parse(e.content);
const clean = (s:string | number) => (typeof s === 'string' ? s.replace(/\r?\n|\r/, '') : s);
// if first backup list all fields and values
if (i === a.length - 1) {
Object.keys(c).forEach((k) => changes.push(`${k}: ${clean(c[k])}`));
} else {
const nextc = JSON.parse(a[i + 1].content);
// list adds
Object.keys(c)
.filter((k) => !Object.keys(nextc).some((v) => v === k))
.forEach((k) => { changes.push(`added ${k}: ${clean(c[k])}`); });
// list modified
Object.keys(c)
.filter((k) => Object.keys(nextc).some((v) => v === k && nextc[k] !== c[k]))
.forEach((k) => { changes.push(`modified ${k}: ${clean(c[k])}`); });
// list deletes
Object.keys(nextc)
.filter((k) => !Object.keys(c).some((v) => v === k))
.forEach((k) => { changes.push(`removed ${k}`); });
}
return {
ago: e.created_at,
changes,
option: i === 0
? '<ins>Backup Complete<ins>'
: `<a href="#" id="restore-metadata-${i}" class="secondary" onclick="event.preventDefault();alert('feature coming soon...');">Restore</a>`,
};
});
export interface Kind3Event extends Event {
kind:3;
tags:['p', string, string, string][]
}
const sameContact = (
x:['p', string, string, string],
y:['p', string, string, string],
):boolean => !!(
x[1] === y[1]
|| (x[3] && y[3] && x[3] === y[3])
);
const getPetname = (a:['p', string, string, string]):string => {
if (a[3] && a[3].length > 0) return `<mark>${a[3]}</mark>`;
return `<mark>${(a[1]).substring(0, 10)}...</mark>`;
/**
* todo: add npubEncode
* npubEncode is imported from nostr-tools and causes the jest test runner to fail with:
* SyntaxError: Cannot use import statement outside a module
*/
// return `<mark>${npubEncode(a[1]).substring(0, 10)}...</mark>`;
};
export const generateContactsChanges = (
history: Kind3Event[],
):VersionChange[] => history.map((e, i, a) => {
const changes:string[] = [];
const current = e.tags.filter((t) => t[0] === 'p');
// if first backup list all contacts
if (i === a.length - 1) changes.push(current.map(getPetname).join(', '));
else {
const next = a[i + 1].tags.filter((t) => t[0] === 'p');
// list adds
const added = current.filter((c) => !next.some((n) => sameContact(c, n)));
if (added.length > 0) changes.push(`<div class="added">added ${added.map(getPetname).join(', ')}</div>`);
// TODO: list modified
// current.map((c) => JSON.stringify(c))
// list deletes
const removed = next.filter((c) => !current.some((n) => sameContact(c, n)));
if (removed.length > 0) changes.push(`<div class="removed">removed ${removed.map(getPetname).join(', ')}</div>`);
}
return {
ago: e.created_at,
changes,
option: i === 0
? '<ins>Backup Complete<ins>'
: `<a href="#" id="restore-contacts-${i}" class="secondary" onclick="event.preventDefault()">Restore</a>`,
};
});
export const generateHistoryTable = (history: Event[] | null):string => {
if (!history || history.length === 0) return '<p>none</p>';
let changes:VersionChange[];
if (history[0].kind === 0) changes = generateMetadataChanges(history);
else if (history[0].kind === 3) changes = generateContactsChanges(history as Kind3Event[]);
else changes = [];
return generateChangesTable(changes);
};

167
src/LoadMetadataPage.ts Normal file
View File

@@ -0,0 +1,167 @@
import { nip05 } from 'nostr-tools';
import { fetchCachedProfileEvent, fetchCachedProfileEventHistory, publishEvent } from './fetchEvents';
import { generateHistoryTable } from './LoadHistory';
import { localStorageGetItem } from './LocalStorage';
type MetadataCore = {
name: string;
profile?: string;
about?: string;
banner?: string;
nip05?: string;
lud06?: string;
lud16?: string;
};
export type MetadataFlex = MetadataCore & {
[x: string | number | symbol]: unknown;
};
const toTextInput = (prop:string, m:MetadataFlex | null, displayname?:string) => `
<label for="PM-form-${prop}">
${displayname || prop}
<input
type="text"
name="PM-form-${prop}"
id="PM-form-${prop}"
placeholder="${displayname || prop}" ${m && m[prop] ? `value="${m[prop]}"` : ''}
/>
</label>
`;
const toTextarea = (prop:string, m:MetadataFlex | null, displayname?:string) => `
<label for="PM-form-${prop}">
${displayname || prop}
<textarea
id="PM-form-${prop}"
name="PM-form-${prop}"
placeholder="${displayname || prop}"
>${m && m[prop] ? m[prop] : ''}</textarea>
</label>
`;
const standardkeys = [
'name',
'nip05',
'about',
'picture',
'banner',
'lud06',
'lud16',
];
const generateForm = (c:MetadataFlex | null):string => {
const customkeys = !c ? [] : Object.keys(c).filter(((k) => !standardkeys.some((s) => s === k)));
return `<form id="metadataform">
<div class="grid">
${toTextInput('name', c)}
${toTextInput('nip05', c)}
</div>
${toTextarea('about', c)}
<img id="metadata-form-picture" src="${c && c.picture ? c.picture : ''}">
${toTextInput('picture', c)}
<img id="metadata-form-banner" src="${c && c.banner ? c.banner : ''}">
${toTextInput('banner', c)}
${toTextInput('lud06', c, 'lud06 (LNURL)')}
${toTextInput('lud16', c)}
${customkeys.map((k) => toTextInput(k, c))}
<button id="metadatasubmitbutton" type="submit">${c ? 'Update' : 'Save'}</button>
<button id="metadataresetbutton" class="secondary outline" type="reset">Reset Form</button>
</form>`;
};
const SubmitMetadataForm = async () => {
// set loading status
const b = document.getElementById('metadatasubmitbutton') as HTMLFormElement;
b.setAttribute('disabled', '');
b.setAttribute('aria-busy', 'true');
b.innerHTML = 'Signing...';
// construct and populate new content object with form data. avoid reordering properties
const fd = new FormData(document.getElementById('metadataform') as HTMLFormElement);
const n:{ [x: string]: unknown; } = {};
const e = fetchCachedProfileEvent(0);
(e ? [...(Object.keys(JSON.parse(e.content))), ...standardkeys] : standardkeys)
.forEach((k) => {
const d = fd.get(`PM-form-${k}`);
if (d && d !== '') n[k] = d;
});
// sign event
if (!window.nostr) return;
const ne = await window.nostr.signEvent({
pubkey: localStorageGetItem('pubkey') as string,
kind: 0,
created_at: Math.floor(Date.now() / 1000),
content: JSON.stringify(n),
tags: [],
});
// publish
b.innerHTML = 'Sending...';
await publishEvent(ne);
b.removeAttribute('aria-busy');
b.innerHTML = 'Recieved by Relays!';
setTimeout(() => {
b.innerHTML = 'Update';
b.removeAttribute('disabled');
}, 1000);
};
const loadMetadataForm = (RootElementID:string) => {
const e = fetchCachedProfileEvent(0);
const MetadataContent = !e ? null : JSON.parse(e.content) as MetadataFlex;
(document.getElementById(RootElementID) as HTMLDivElement)
.innerHTML = `<div class="profileform">
<h3>Metadata</h3>
${generateForm(MetadataContent)}
</div>`;
// refresh picture and banner on change event
['picture', 'banner'].forEach((n) => {
const input = document.getElementById(`PM-form-${n}`) as HTMLInputElement;
input.onchange = () => {
(document.getElementById(`metadata-form-${n}`) as HTMLImageElement)
.setAttribute('src', input.value);
};
});
// check nip05
const nip05input = document.getElementById('PM-form-nip05') as HTMLInputElement;
const checkNip05 = async () => {
if (nip05input.value === '') {
nip05input.removeAttribute('aria-invalid');
} else {
let verified:boolean = false;
try {
const r = await nip05.queryProfile(nip05input.value);
verified = !!r && r.pubkey === localStorageGetItem('pubkey');
} catch { /* empty */ }
nip05input.setAttribute('aria-invalid', verified ? 'false' : 'true');
}
};
checkNip05();
nip05input.onchange = checkNip05;
// form submit event
(document.getElementById('metadataform') as HTMLButtonElement).onsubmit = (event) => {
SubmitMetadataForm();
event.preventDefault();
};
// reset form
(document.getElementById('metadataresetbutton') as HTMLButtonElement).onsubmit = (event) => {
loadMetadataForm(RootElementID);
event.preventDefault();
};
};
const loadMetadataBackupHistory = (RootElementID:string) => {
const table = generateHistoryTable(fetchCachedProfileEventHistory(0));
(document.getElementById(RootElementID) as HTMLDivElement)
.innerHTML = `<h4>Backup History</h4>${table}`;
};
export const LoadMetadataPage = () => {
const o:HTMLElement = document.getElementById('PM-container') as HTMLElement;
o.innerHTML = `
<div id="metadatapage" class="container">
<div id="metadataform"></div>
<div id="metadatahistory"></div>
<div>
`;
loadMetadataForm('metadataform');
loadMetadataBackupHistory('metadatahistory');
};

190
src/LoadProfileHome.ts Normal file
View File

@@ -0,0 +1,190 @@
import { Event } from 'nostr-tools';
import {
fetchCachedProfileEvent, fetchCachedProfileEventHistory, hadLatest, isUptodate,
} from './fetchEvents';
import LoadContactsPage from './LoadContactsPage';
import { LoadMetadataPage, MetadataFlex } from './LoadMetadataPage';
import LoadRelaysPage from './LoadRelaysPage';
export const generateLogoHero = () => (
'<div><img class="hero-logo" src="./img/nostr-profile-manage-logo.png"></div>'
);
const injectLoading = (loading:boolean = true) => `${loading ? 'aria-busy="true"' : ''}`;
const generateMetadataSummary = (e:Event | null, loading = false) => {
if (e == null) {
return `<div>
<button ${injectLoading(loading)} class="outline contrast">No Metadata</button>
</div>`;
}
return `<div>
<button
${injectLoading(loading)}
class="outline contrast"
id="metadatabutton"
>
${Object.keys(JSON.parse(e.content)).length} Metadata Fields
</button>
</div>`;
};
const generateContactsSummary = (e:Event | null, loading = false) => {
if (e == null) {
return `<div><button
${injectLoading(loading)}
id="contactsbutton"
>No Contacts</button></div>`;
}
return `<div>
<button
${injectLoading(loading)}
id="contactsbutton"
>${e.tags.length} Contacts</button>
</div>`;
};
const generateRelaysSummary = (e:Event | null, loading = false) => {
if (e == null) {
return `<div>
<button
${injectLoading(loading)}
class="outline secondary"
>No Relay</button>
</div>`;
}
return `<div><button ${injectLoading(loading)} class="outline secondary">
Relay: ${e.content}
</button></div>`;
};
const generateRelays10002Summary = (e:Event | null, loading = false) => {
if (e == null) {
return `<div>
<button
${injectLoading(loading)}
id="relaysbutton"
class="outline secondary"
>No Relays</button>
</div>`;
}
const read = e.tags.filter((t) => typeof t[2] === 'undefined' || t[2] === 'read').length;
const write = e.tags.filter((t) => typeof t[2] === 'undefined' || t[2] === 'write').length;
return `<div><button
${injectLoading(loading)}
id="relaysbutton"
class="outline secondary"
>
${e.tags.length} Relay${e.tags.length === 1 ? '' : 's'} (${read} read ${write} write)
</button></div>`;
};
const generateMetadataHeader = (e:Event) => {
const c = JSON.parse(e.content) as MetadataFlex;
// remove new lines from about
let about = c.about ? c.about.replace(/\r?\n|\r/, '') : '';
if (about.length > 50) about = `${about.substring(0, 47)}...`;
return `
<div>
<img src="${c.picture ? c.picture : ''}">
<strong>${c.name ? c.name : ''}</strong> <small>${c.nip05 ? c.nip05 : ''}</small>
<div><small>${about}</small></div>
</div>
`;
};
export const generateBackupHeroHeading = (
uptodate:boolean,
noprofileinfo:boolean,
hadlatest:boolean,
) => {
let content = '';
if (!uptodate) {
if (noprofileinfo) {
content = `
<h1 aria-busy="true">Finding Profile...</h1>
<p>It's your first time here and we are backing up your metadata, contacts and relays to your offline browser data.</p>
`;
} else {
content = `
<h1 aria-busy="true">Finding Latest Profile...</h1>
<p>We backing up your latest metadata, contacts and relays to your offline browser data.</p>
`;
}
} else if (noprofileinfo) {
content = `
<h1>No Profile Events Found</h1>
<p>We didn't find any profile info for you. Either wedidn't look on the right relays or you have just created a key pair.</p>
<p>Only proceed if you are setting your profile up for the first time.</p>
`;
} else if (hadlatest) {
content = `
<h1>Backup is up to date!</h1>
<p>
We already had backed up your profile to your offline browser data.
<a href="#" class="secondary" onclick="event.preventDefault()">Download</a> for safe keeping.
</p>
<p>If your profile ever gets wiped by a nostr client, come back here on this device to restore. Come back from time to time to update your backup.</p>
`;
} else {
content = `
<h1>Profile Backup Up!</h1>
<p>
We just backed up your latest profile to your offline browser data.
<a id="downloadprofile" href="#" class="secondary" onclick="event.preventDefault()">Download</a> for safe keeping.
</p>
<p>If your profile ever gets wiped by a nostr client, come back here on this device to restore. Come back from time to time to update your backup.</p>
`;
}
return `<div>${content}</div<`;
};
export const LoadProfileHome = () => {
const noprofileinfo = !fetchCachedProfileEvent(0) && !fetchCachedProfileEvent(3);
const uptodate = isUptodate();
const hadlatest = hadLatest();
const o:HTMLElement = document.getElementById('PM-container') as HTMLElement;
o.innerHTML = `
<div class="container">
<div class="hero grid">
${noprofileinfo ? generateLogoHero() : `<div><article class="profile-summary-card">
${generateMetadataHeader(fetchCachedProfileEvent(0) as Event)}
<div>
${generateMetadataSummary(fetchCachedProfileEvent(0), !uptodate)}
${generateContactsSummary(fetchCachedProfileEvent(3), !uptodate)}
${generateRelaysSummary(fetchCachedProfileEvent(2), !uptodate)}
${generateRelays10002Summary(fetchCachedProfileEvent(10002), !uptodate)}
</div>
</article></div>`}
<div>${generateBackupHeroHeading(uptodate, noprofileinfo, hadlatest)}</div>
</div>
</div>
`;
const mbutton = document.getElementById('metadatabutton');
if (mbutton) mbutton.onclick = () => LoadMetadataPage();
const cbutton = document.getElementById('contactsbutton');
if (cbutton) cbutton.onclick = () => LoadContactsPage();
const rbutton = document.getElementById('relaysbutton');
if (rbutton) rbutton.onclick = () => LoadRelaysPage();
// enable download link
const donwloada = document.getElementById('downloadprofile');
if (donwloada) {
donwloada.onclick = (event) => {
event.preventDefault();
const jsonStr = JSON.stringify([
...(fetchCachedProfileEventHistory(0) || []),
...(fetchCachedProfileEventHistory(2) || []),
...(fetchCachedProfileEventHistory(10002) || []),
...(fetchCachedProfileEventHistory(3) || []),
]);
const element = document.createElement('a');
element.setAttribute('href', `data:text/plain;charset=utf-8,${encodeURIComponent(jsonStr)}`);
element.setAttribute('download', `my-nostr-profile-events-${Date.now()}.json`);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
};
}
};

19
src/LoadRelaysPage.ts Normal file
View File

@@ -0,0 +1,19 @@
const loadRelaysBackupHistory = (RootElementID:string) => {
(document.getElementById(RootElementID) as HTMLDivElement)
.innerHTML = `<div class="relaysbackuphistory">
<h3>Relays</h3>
<p>TODO</p>
</div>`;
};
const LoadRelaysPage = () => {
const o:HTMLElement = document.getElementById('PM-container') as HTMLElement;
o.innerHTML = `
<div id="relayspage" class="container">
<div id="relaysbackuphistory"></div>
<div>
`;
loadRelaysBackupHistory('relaysbackuphistory');
};
export default LoadRelaysPage;

7
src/LocalStorage.ts Normal file
View File

@@ -0,0 +1,7 @@
/** abstracted to improve testability */
export const localStorageGetItem = (key:string):string | null => localStorage.getItem(key);
/** abstracted to improve testability */
export const localStorageSetItem = (key:string, value:string):void => {
localStorage.setItem(key, value);
};

44
src/RelayManagement.ts Normal file
View File

@@ -0,0 +1,44 @@
import { Event, Relay, relayInit } from 'nostr-tools';
let drelay: Relay;
export const setupDefaultRelays = async ():Promise<void> => {
if (typeof drelay !== 'undefined') return new Promise((r) => { r(); });
drelay = relayInit('wss://relay.damus.io');
return drelay.connect();
};
/** setupMyRelays TODO */
export const setupMyRelays = async () => setupDefaultRelays();
export const requestMyProfileFromRelays = async (
pubkey:string,
eventProcesser: (event: Event) => void,
) => {
await setupDefaultRelays();
const sub = drelay.sub([{
kinds: [0, 2, 10002, 3],
authors: [pubkey],
}]);
return new Promise<void>((r) => {
sub.on('event', (event:Event) => {
if (
event.pubkey === pubkey
&& (event.kind === 0 || event.kind === 2 || event.kind === 10002 || event.kind === 3)
) {
eventProcesser(event);
}
});
sub.on('eose', () => {
// sub.unsub();
r();
});
});
};
export const publishEventToRelay = async (event:Event):Promise<boolean> => {
await setupDefaultRelays();
const pub = drelay.publish(event);
return new Promise((r) => {
pub.on('ok', () => r(true));
pub.on('failed', () => r(false));
});
};

58
src/SampleEvents.ts Normal file
View File

@@ -0,0 +1,58 @@
import { Event } from 'nostr-tools';
import { Kind3Event } from './LoadHistory';
const SampleEvents: {
kind0: Event;
kind2: Event;
kind10002: Event;
kind3: Event;
[x: string | number | symbol]: Event;
} = {
kind0: {
content: '{"name":"ZEUS","picture":"https://pbs.twimg.com/profile_images/1610442178546831364/UModnpXI_400x400.jpg","about":"A mobile bitcoin experience fit for the gods\\n\\nEst. 563345.\\n\\nhttps://zeusln.app","lud06":"lnurl1dp68gurn8ghj7urp0yh85et4wdkxutnpwpcz7tnhv4kxctttdehhwm30d3h82unvwqhhg6tswvdmd5dx","lud16":"tips@pay.zeusln.app","nip05":"zeus@zeusln.app","banner":"https://void.cat/d/PU4ErBhWjeUBmYsLg2sTLQ"}',
created_at: 1674925722,
id: '6d737844ff86a389d771d27732d09ec0bb537cef702b3c76bad67fabfd14e600',
kind: 0,
pubkey: '34d2f5274f1958fcd2cb2463dabeaddf8a21f84ace4241da888023bf05cc8095',
sig: 'f9127cc3640af80b57d691f5e2001b9119ed3cd25a0aaa786c6fafb6a14199391b554f46056840c43427c6c8f9a1011bca62af1c148c7fb6a79f847e5952f3be',
tags: [],
},
kind2: {
content: 'wss://relay.damus.io',
created_at: 1674925722,
id: '12413421',
kind: 2,
pubkey: '34d2f5274f1958fcd2cb2463dabeaddf8a21f84ace4241da888023bf05cc8095',
sig: 'f9127cc3640af80b57d691f5e2001b9119ed3cd25a0aaa786c6fafb6a14199391b554f46056840c43427c6c8f9a1011bca62af1c148c7fb6a79f847e5952f3be',
tags: [],
},
kind10002: {
kind: 10002,
content: '',
tags: [
['r', 'wss://alicerelay.example.com'],
['r', 'wss://brando-relay.com'],
['r', 'wss://expensive-relay.example2.com', 'write'],
['r', 'wss://nostr-relay.example.com', 'read'],
],
created_at: 1674925722,
id: '7687687',
pubkey: '34d2f5274f1958fcd2cb2463dabeaddf8a21f84ace4241da888023bf05cc8095',
sig: '678676587',
},
kind3: {
kind: 3,
tags: [
['p', '91cf9..4e5ca', 'wss://alicerelay.com/', 'alice'],
['p', '14aeb..8dad4', 'wss://bobrelay.com/nostr', 'bob'],
['p', '612ae..e610f', 'ws://carolrelay.com/ws', 'carol'],
],
content: '',
created_at: 1674925722,
id: '7687687',
pubkey: '34d2f5274f1958fcd2cb2463dabeaddf8a21f84ace4241da888023bf05cc8095',
sig: '678676587',
} as Kind3Event,
};
export default SampleEvents;

246
src/fetchEvents.test.ts Normal file
View File

@@ -0,0 +1,246 @@
import { Event } from 'nostr-tools';
import SampleEvents from './SampleEvents';
import {
storeMyProfileEvent, fetchCachedProfileEventHistory, fetchCachedProfileEvent,
fetchMyProfileEvents,
} from './fetchEvents';
import * as LocalStorage from './LocalStorage';
import * as FetchEvents from './fetchEvents';
import * as RelayManagement from './RelayManagement';
let storage:{ [x: string | number | symbol]: unknown; } = {};
jest.spyOn(LocalStorage, 'localStorageSetItem').mockImplementation((key, value) => {
const k = key as string;
storage[k] = value;
});
jest.spyOn(LocalStorage, 'localStorageGetItem').mockImplementation((key): string | null => {
const k = key as string;
if (typeof storage[k] === 'undefined') return null;
return storage[k] as string;
});
const updateLastUpdatedSpy = jest.spyOn(FetchEvents, 'updateLastUpdateDate');
describe('', () => {
beforeEach(() => {
storage = {};
storage.pubkey = SampleEvents.kind0.pubkey;
updateLastUpdatedSpy.mockReset();
});
afterEach(() => {
storage = {};
});
describe('storeMyProfileEvent', () => {
test('throw if no pubkey stored', () => {
delete storage.pubkey;
expect(LocalStorage.localStorageGetItem('pubkey')).toBeNull();
expect(() => { storeMyProfileEvent({ ...SampleEvents.kind0 }); })
.toThrow('storeMyProfileEvent no pubkey in localStorage');
});
describe.each([0, 2, 10002, 3])('events of kind %s', (k) => {
let kind: 0 | 2 | 10002 | 3;
beforeEach(() => { kind = k as 0 | 2 | 10002 | 3; });
describe('stores event, returns true and calls updateLastUpdateDate() when', () => {
test('first event submitted', () => {
const r = storeMyProfileEvent({ ...SampleEvents[`kind${kind}`] });
expect(fetchCachedProfileEventHistory(kind)).toEqual([{ ...SampleEvents[`kind${kind}`] }]);
expect(r).toBeTruthy();
expect(updateLastUpdatedSpy).toHaveBeenCalledTimes(1);
});
test('subsequent (second and third) events submitted', () => {
const a = [
{ ...SampleEvents[`kind${kind}`] },
{ ...SampleEvents[`kind${kind}`], id: '2' },
{ ...SampleEvents[`kind${kind}`], id: '3' },
];
storeMyProfileEvent(a[0]);
const r2 = storeMyProfileEvent(a[1]);
const r3 = storeMyProfileEvent(a[2]);
const r = fetchCachedProfileEventHistory(kind);
expect(r).toContainEqual(a[0]);
expect(r).toContainEqual(a[1]);
expect(r).toContainEqual(a[2]);
expect(r2).toBeTruthy();
expect(r3).toBeTruthy();
expect(updateLastUpdatedSpy).toHaveBeenCalledTimes(3);
});
});
describe('returns false, does not store and doesnt call updateLastUpdateDate() when', () => {
test('event from a different pubkey submitted', () => {
const r = storeMyProfileEvent({ ...SampleEvents[`kind${kind}`], pubkey: '1' });
expect(r).toBeFalsy();
expect(fetchCachedProfileEventHistory(kind)).toBeNull();
expect(updateLastUpdatedSpy).toHaveBeenCalledTimes(0);
});
test('duplicate events (events with the same id)', () => {
storeMyProfileEvent({ ...SampleEvents[`kind${kind}`] });
const r = storeMyProfileEvent({ ...SampleEvents[`kind${kind}`], content: 'different' });
expect(r).toBeFalsy();
expect(fetchCachedProfileEventHistory(kind)).toEqual([{ ...SampleEvents[`kind${kind}`] }]);
expect(updateLastUpdatedSpy).toHaveBeenCalledTimes(1);
});
});
});
test('events of an unsupported kind (eg 1) returns false, are not stored and updateLastUpdateDate() not called', () => {
expect(storeMyProfileEvent({ ...SampleEvents.kind0, kind: 1 })).toBeFalsy();
expect(updateLastUpdatedSpy).toHaveBeenCalledTimes(0);
});
});
describe('fetchCachedProfileEventHistory', () => {
describe('returns array of events', () => {
test('single event', () => {
storeMyProfileEvent({ ...SampleEvents.kind0 });
expect(fetchCachedProfileEventHistory(0)).toEqual([{ ...SampleEvents.kind0 }]);
});
test('multiple events', () => {
const a = [
{ ...SampleEvents.kind0 },
{ ...SampleEvents.kind0, id: '2' },
{ ...SampleEvents.kind0, id: '3' },
];
storeMyProfileEvent(a[0]);
storeMyProfileEvent(a[1]);
storeMyProfileEvent(a[2]);
const r = fetchCachedProfileEventHistory(0);
expect(r).toContainEqual(a[0]);
expect(r).toContainEqual(a[1]);
expect(r).toContainEqual(a[2]);
});
test('events only of specified kind', () => {
const a = [
{ ...SampleEvents.kind0 },
{ ...SampleEvents.kind0, id: '2' },
{ ...SampleEvents.kind3, id: '3' },
];
storeMyProfileEvent(a[0]);
storeMyProfileEvent(a[1]);
storeMyProfileEvent(a[2]);
const r = fetchCachedProfileEventHistory(0);
expect(r).toContainEqual(a[0]);
expect(r).toContainEqual(a[1]);
expect(fetchCachedProfileEventHistory(3)).toContainEqual(a[2]);
});
test('ordered by created_at decending', () => {
const a = [
{ ...SampleEvents.kind0 },
{ ...SampleEvents.kind0, id: '2', created_at: SampleEvents.kind0.created_at + 100 },
{ ...SampleEvents.kind0, id: '3', created_at: SampleEvents.kind0.created_at + 50 },
];
storeMyProfileEvent({ ...a[0] });
storeMyProfileEvent({ ...a[1] });
storeMyProfileEvent({ ...a[2] });
const r = fetchCachedProfileEventHistory(0) as Event[];
expect(r[0]).toEqual(a[1]);
expect(r[1]).toEqual(a[2]);
expect(r[2]).toEqual(a[0]);
});
});
test('returns null if no events of kind present', () => {
storeMyProfileEvent({ ...SampleEvents.kind0 });
expect(fetchCachedProfileEventHistory(3)).toBeNull();
});
});
describe('fetchCachedProfileEvent', () => {
test('returns event of specified kind with largest created_at value', () => {
const a = [
{ ...SampleEvents.kind0 },
{ ...SampleEvents.kind0, id: '2', created_at: SampleEvents.kind0.created_at + 100 },
{ ...SampleEvents.kind0, id: '3', created_at: SampleEvents.kind0.created_at + 50 },
];
storeMyProfileEvent({ ...a[0] });
storeMyProfileEvent({ ...a[1] });
storeMyProfileEvent({ ...a[2] });
const r = fetchCachedProfileEvent(0) as Event;
expect(r).toEqual(a[1]);
});
test('returns null if no events of kind present', () => {
storeMyProfileEvent({ ...SampleEvents.kind0 });
expect(fetchCachedProfileEventHistory(3)).toBeNull();
});
});
describe('fetchMyProfileEvents', () => {
const fetchCachedProfileEventSpy = jest.spyOn(FetchEvents, 'fetchCachedProfileEvent');
const mockEventProcessor = jest.fn();
const mockrequestMyProfileFromRelays = jest.spyOn(RelayManagement, 'requestMyProfileFromRelays');
const mockupdateLastFetchDate = jest.spyOn(FetchEvents, 'updateLastFetchDate');
const mocklastFetchDate = jest.spyOn(FetchEvents, 'lastFetchDate');
const mockisUptodate = jest.spyOn(FetchEvents, 'isUptodate');
beforeEach(async () => {
fetchCachedProfileEventSpy.mockReset();
mockEventProcessor.mockReset();
mockrequestMyProfileFromRelays.mockReset();
mockupdateLastFetchDate.mockReset();
mocklastFetchDate.mockReset();
mockisUptodate.mockReset();
});
describe('when isUptodate returns true', () => {
beforeEach(async () => {
mockisUptodate.mockReturnValue(true);
fetchCachedProfileEventSpy.mockImplementation((kind) => {
if (kind === 0) return { ...SampleEvents.kind0 };
if (kind === 10002) return null;
if (kind === 2) return null;
if (kind === 3) return { ...SampleEvents.kind3 };
return null;
});
await fetchMyProfileEvents(SampleEvents.kind0.pubkey, mockEventProcessor);
});
test('requestMyProfileFromRelays never called', () => {
expect(mockrequestMyProfileFromRelays).toBeCalledTimes(0);
});
test('updateLastFetchDate never called', () => {
expect(mockupdateLastFetchDate).toBeCalledTimes(0);
});
test('eventProcessor called with latest event from cache for each profile kind with event(s)', async () => {
expect(fetchCachedProfileEventSpy).toHaveBeenCalledWith(0);
expect(fetchCachedProfileEventSpy).toHaveBeenCalledWith(3);
expect(mockEventProcessor).toBeCalledWith({ ...SampleEvents.kind0 });
expect(mockEventProcessor).toBeCalledWith({ ...SampleEvents.kind3 });
expect(mockEventProcessor).toHaveBeenCalledTimes(2);
});
test('eventProcessor not called on profile event kind that isn\'t present', async () => {
expect(fetchCachedProfileEventSpy).toHaveBeenCalledWith(10002);
expect(fetchCachedProfileEventSpy).toHaveBeenCalledWith(2);
expect(mockEventProcessor).toBeCalledTimes(2);
});
});
describe('when isUptodate returns false', () => {
const mockstoreMyProfileEvent = jest.spyOn(FetchEvents, 'storeMyProfileEvent');
beforeEach(async () => {
mockisUptodate.mockReturnValue(false);
mockrequestMyProfileFromRelays.mockReset()
.mockImplementation(async (_pubkey, eventProcessor) => {
eventProcessor({ ...SampleEvents.kind0 });
eventProcessor({ ...SampleEvents.kind3 });
});
await fetchMyProfileEvents(SampleEvents.kind0.pubkey, mockEventProcessor);
});
test('updateLastFetchDate called once', () => {
expect(mockupdateLastFetchDate).toBeCalledTimes(1);
});
test('fetchCachedProfileEvent never called', () => {
expect(fetchCachedProfileEventSpy).toBeCalledTimes(0);
});
test('requestMyProfileFromRelays called', () => {
expect(mockrequestMyProfileFromRelays).toBeCalledTimes(1);
});
test('mockrequestMyProfileFromRelays called with correct pubkey', () => {
expect(mockrequestMyProfileFromRelays).toBeCalledWith(
SampleEvents.kind0.pubkey,
expect.anything(),
);
});
test('eventProcessor called with events passed through by requestMyProfileFromRelays\'s event processor', async () => {
expect(mockEventProcessor).toBeCalledWith({ ...SampleEvents.kind0 });
expect(mockEventProcessor).toBeCalledWith({ ...SampleEvents.kind3 });
});
test('storeMyProfileEvent called with events passed through by requestMyProfileFromRelays\'s event processor', async () => {
expect(mockstoreMyProfileEvent).toBeCalledWith({ ...SampleEvents.kind0 });
expect(mockstoreMyProfileEvent).toBeCalledWith({ ...SampleEvents.kind3 });
});
test('eventProcessor not called when profile event kind isn\'t found by requestMyProfileFromRelays', async () => {
expect(mockEventProcessor).toBeCalledTimes(2);
});
});
});
});

125
src/fetchEvents.ts Normal file
View File

@@ -0,0 +1,125 @@
import { Event } from 'nostr-tools';
import { localStorageGetItem, localStorageSetItem } from './LocalStorage';
import { publishEventToRelay, requestMyProfileFromRelays } from './RelayManagement';
export const lastFetchDate = ():number | null => {
const d = localStorageGetItem('my-profile-last-fetch-date');
if (d === null) return null;
return Number(d);
};
let fetchedthissession: boolean = false;
export const updateLastFetchDate = ():void => {
fetchedthissession = true;
localStorageSetItem('my-profile-last-fetch-date', Date.now().toString());
};
export const lastUpdateDate = ():number | null => {
const d = localStorageGetItem('my-profile-last-update-date');
if (d === null) return null;
return Number(d);
};
export const updateLastUpdateDate = ():void => {
localStorageSetItem('my-profile-last-update-date', Date.now().toString());
};
export const isUptodate = ():boolean => fetchedthissession;
// const f = lastFetchDate();
// // uptodate - fetched within 10 seconds
// return !(f === null || f < (Date.now() - 10000));
export const hadLatest = ():boolean => {
if (!isUptodate()) return false;
const f = lastFetchDate();
const u = lastUpdateDate();
// hadlatest - last update was no more than 10 seconds before fetch complete
return !(u === null || f === null || u > (f - 10000));
};
/**
* storeMyProfileEvent
* @returns true if stored and false duplicate, wrong kind or wrong pubkey
*/
export const storeMyProfileEvent = (event:Event): boolean => {
// thrown on no pubkey in localStorage
if (localStorageGetItem('pubkey') === null) {
throw new Error('storeMyProfileEvent no pubkey in localStorage');
}
// return false if...
if (
// event is of an unsupported kind
!(event.kind === 0 || event.kind === 2 || event.kind === 10002 || event.kind === 3)
// or fron a different pubkey
|| event.pubkey !== localStorageGetItem('pubkey')
) return false;
const arrayname = `my-profile-event-${event.kind}`;
const ls = localStorageGetItem(arrayname);
// if localStorage my-profile-event-[kind] doesnt exist, create it with new event in.
if (ls === null) localStorageSetItem(arrayname, JSON.stringify([event]));
else {
const a = JSON.parse(ls) as Event[];
// if event is already stored return false
if (a.some((e) => e.id === event.id)) return false;
// add event, store array
a.push(event);
localStorageSetItem(arrayname, JSON.stringify(a));
}
// update last updated date
updateLastUpdateDate();
// return true as event saved
return true;
};
export const fetchCachedProfileEventHistory = (
kind: 0 | 2 | 10002 | 3,
): null | [Event, ...Event[]] => {
// get data from local storage
const arrayname = `my-profile-event-${kind}`;
const ls = localStorageGetItem(arrayname);
// if no events are cached return null
if (ls === null) return null;
const a = JSON.parse(ls) as [Event, ...Event[]];
// return as Events array
return a.sort((x, y) => y.created_at - x.created_at);
};
export const fetchCachedProfileEvent = (kind: 0 | 2 | 10002 | 3): null | Event => {
const a = fetchCachedProfileEventHistory(kind);
if (a === null) return null;
// return Event in array with most recent created_at date
return a[0];
};
/** get my latest profile events either from cache (if isUptodate) or from relays */
export const fetchMyProfileEvents = async (
pubkey:string,
profileEventProcesser: (event: Event) => void,
): Promise<void> => {
// get events from relays, store them and run profileEventProcesser
if (!isUptodate()) {
/**
* TODO also run this if we havn't checked for x minutes and we aren't already
* listening on my write relays
*/
await requestMyProfileFromRelays(pubkey, (event: Event) => {
storeMyProfileEvent(event);
profileEventProcesser(event);
});
// update last-fetch-from-relays date
updateLastFetchDate();
} else {
// for kinds 0, 2, 10002 and 3
[0, 2, 10002, 3].forEach((k) => {
const e = fetchCachedProfileEvent(k as 0 | 2 | 10002 | 3);
if (e !== null) profileEventProcesser(e);
});
}
};
export const publishEvent = async (event:Event):Promise<boolean> => {
const r = await publishEventToRelay(event);
if (r) storeMyProfileEvent(event);
return r;
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

27
src/index.htm Normal file
View File

@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="style.css">
<title>Nostr Profile Manager</title>
<script async src="index.js"></script>
</head>
<body>
<div class="nav-container">
<div class="container">
<nav>
<ul>
<li><strong><a href="#" id="navhome" class="secondary">Nostr Profile Manager</a></strong></li>
</ul>
<ul id="mainnav" class="inactive">
<li><a href="#" id="navmetadata" class="secondary">Metadata</a></li>
<li><a href="#" id="navcontacts" class="secondary">Contacts</a></li>
<li><a href="#" id="navrelays" class="secondary">Relays</a></li>
</ul>
</nav>
</div>
</div>
<div id="PM-container"></div>
</body>
</html>

99
src/index.ts Normal file
View File

@@ -0,0 +1,99 @@
import { Event, UnsignedEvent } from 'nostr-tools';
import { generateLogoHero, LoadProfileHome } from './LoadProfileHome';
import { setupDefaultRelays, setupMyRelays } from './RelayManagement';
import { fetchCachedProfileEvent, fetchMyProfileEvents } from './fetchEvents';
import { localStorageGetItem, localStorageSetItem } from './LocalStorage';
import { LoadMetadataPage } from './LoadMetadataPage';
import LoadContactsPage from './LoadContactsPage';
import LoadRelaysPage from './LoadRelaysPage';
declare global {
interface Window {
nostr?: {
getPublicKey: () => Promise<string>;
signEvent: (event:UnsignedEvent) => Promise<Event>;
}
}
}
const loadProfile = async () => {
// turn on nav
(document.getElementById('mainnav') as HTMLElement).classList.remove('inactive');
(document.getElementById('navhome') as HTMLElement).onclick = LoadProfileHome;
(document.getElementById('navmetadata') as HTMLElement).onclick = LoadMetadataPage;
(document.getElementById('navcontacts') as HTMLElement).onclick = LoadContactsPage;
(document.getElementById('navrelays') as HTMLElement).onclick = LoadRelaysPage;
// load profile page (in loading mode)
LoadProfileHome();
// if my relays are known, connect to them
if (
fetchCachedProfileEvent(10002) !== null
|| fetchCachedProfileEvent(2) !== null
) await setupMyRelays();
// otherwise connect to default relays
else await setupDefaultRelays();
// load profile data
await fetchMyProfileEvents(
localStorageGetItem('pubkey') as string,
LoadProfileHome,
);
// load profile page (in complete mode)
LoadProfileHome();
};
const LoadLandingPage = () => {
const aboutcontent = `
<div class="container">
<div class="hero grid">
${generateLogoHero()}
<div id="herocontent">
<h1>Nostr Profile Manager</h1>
<p>Backup /&nbsp;Refine /&nbsp;Restore profile events</p>
<a id="loadextension" href="#" onclick="return false;" role="button" class="contrast">Load My Profile</a>
</div>
</div>
</div>
<div class="container">
<div class="grid">
<article>
<h5>Backup</h5>
<p>Save your profile in your offline browser data. Backup all your notes. Download in a zip.</p>
</article>
<article>
<h5>Refine</h5>
<p>Perfect your profile. Refine your relays. Clean up your contacts.</p>
</article>
<article>
<h5>Restore</h5>
<p>View profile backups and restore your favourate</p>
</article>
</div>
</div>
`;
const o:HTMLElement = document.getElementById('PM-container') as HTMLElement;
o.innerHTML = aboutcontent;
const a = document.getElementById('loadextension');
if (a) {
a.onclick = async () => {
if (window.nostr) {
const pubkey = await window.nostr.getPublicKey();
localStorageSetItem('pubkey', pubkey);
loadProfile();
} else {
a.outerHTML = `
<p>You need a NIP-07 browser extension like nos2x to use this webapp.</p>
<a href="https://github.com/nostr-protocol/nips/blob/master/07.md#nip-07" role="button" class="contrast">Get Browser Extension</a>
`;
}
};
}
};
const load = async () => {
// if new users
if (!localStorageGetItem('pubkey')) LoadLandingPage();
else loadProfile();
};
if (document.getElementById('PM-container') !== null) load();
else document.addEventListener('DOMContentLoaded', () => load());

68
src/style.scss Normal file
View File

@@ -0,0 +1,68 @@
.hero {
// background-image: url("https://i.imgur.com/DYDX9DU.jpg");
// background-size:cover
width: 100%;
};
@import "../node_modules/@picocss/pico/scss/pico.scss";
#mainnav.inactive a {
display:none;
}
.nav-container {
border-bottom: 1px solid #eee;
}
.historytable {
ul {
padding: 0;
margin: 0;
}
li {
list-style: none;
margin:0;
}
}
.hero {
> div {
margin:auto;
}
img.hero-logo {
margin:100px 0;
width:300px;
text-align: center;
}
h1 {
margin-bottom: 16px;
}
}
img[src=""] {
display: none;
}
.profileform {
max-width: 800px;
img#metadata-form-banner {
max-height: 200px;
}
img#metadata-form-picture {
max-height:150px;
max-width:150px;
}
}
.added mark {
background-color:rgb(29, 255, 116)
}
.removed mark {
background-color:rgb(255, 125, 125)
}
.profile-summary-card {
img {
max-width: 75px;
border-radius: 38px;
float: left;
margin: 10px 10px 10px 0;
}
}

37
tsconfig.json Normal file
View File

@@ -0,0 +1,37 @@
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"moduleResolution": "node",
"declaration": true,
"strict": true,
"noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
"strictNullChecks": true /* Enable strict null checks. */,
"strictFunctionTypes": true /* Enable strict checking of function types. */,
"noUnusedLocals": true /* Report errors on unused locals. */,
"noUnusedParameters": true /* Report errors on unused parameters. */,
"noImplicitReturns": true /* Report error when not all code paths in function return a value. */,
"noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */,
"importHelpers": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"sourceMap": true,
"outDir": "./dist/tsc/",
"types": [
"node",
"jest"
],
"lib": [
"ES6",
"DOM"
]
},
"include": [
"src/**/*.ts"
],
"exclude": [
"node_modules"
]
}

3720
yarn.lock Normal file

File diff suppressed because it is too large Load Diff