mirror of
https://github.com/aljazceru/nostr-profile-manager.git
synced 2025-12-17 21:54:19 +01:00
initial version
This commit is contained in:
18
.eslintrc.js
Normal file
18
.eslintrc.js
Normal 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
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
tmp
|
||||||
10
jest.config.js
Normal file
10
jest.config.js
Normal 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
37
package.json
Normal 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
50
readme.md
Normal 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
22
src/LoadContactsPage.ts
Normal 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
126
src/LoadHistory.test.ts
Normal 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
114
src/LoadHistory.ts
Normal 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
167
src/LoadMetadataPage.ts
Normal 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
190
src/LoadProfileHome.ts
Normal 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
19
src/LoadRelaysPage.ts
Normal 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
7
src/LocalStorage.ts
Normal 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
44
src/RelayManagement.ts
Normal 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
58
src/SampleEvents.ts
Normal 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
246
src/fetchEvents.test.ts
Normal 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
125
src/fetchEvents.ts
Normal 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;
|
||||||
|
};
|
||||||
BIN
src/img/nostr-profile-manage-logo.png
Normal file
BIN
src/img/nostr-profile-manage-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 432 KiB |
27
src/index.htm
Normal file
27
src/index.htm
Normal 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
99
src/index.ts
Normal 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 / Refine / 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
68
src/style.scss
Normal 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
37
tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user