-
Notifications
You must be signed in to change notification settings - Fork 9
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
=
committed
Mar 6, 2023
1 parent
8c73b7a
commit d696b10
Showing
22 changed files
with
5,187 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' | ||
}, | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
node_modules | ||
dist | ||
tmp |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
}, | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>', | ||
]); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}; |
Oops, something went wrong.