Skip to content

Commit

Permalink
initial version
Browse files Browse the repository at this point in the history
  • Loading branch information
= committed Mar 6, 2023
1 parent 8c73b7a commit d696b10
Show file tree
Hide file tree
Showing 22 changed files with 5,187 additions and 0 deletions.
18 changes: 18 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
@@ -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 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
dist
tmp
10 changes: 10 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -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 changes: 37 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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 changes: 50 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
@@ -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 changes: 22 additions & 0 deletions src/LoadContactsPage.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 126 additions & 0 deletions src/LoadHistory.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 114 additions & 0 deletions src/LoadHistory.ts
Original file line number Diff line number Diff line change
@@ -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);
};
Loading

0 comments on commit d696b10

Please sign in to comment.