diff --git a/readme.md b/readme.md index 709785b..d3e5f8b 100644 --- a/readme.md +++ b/readme.md @@ -13,7 +13,7 @@ Supported profile events: kind `0`, `2`, `10002` and `3`. - [x] backup your profile events to offline browser storage - [x] review changes between backups - [x] `0` - - [ ] `10002` and `2` + - [x] `10002` - [x] `3` - [ ] selectively restore previous versions - [x] download profile backup history as JSON file @@ -35,8 +35,8 @@ Supported profile events: kind `0`, `2`, `10002` and `3`. - [ ] 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 +- [x] Relays + - [x] 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 diff --git a/src/LoadHistory.test.ts b/src/LoadHistory.test.ts index 3acb0d5..920c6b8 100644 --- a/src/LoadHistory.test.ts +++ b/src/LoadHistory.test.ts @@ -1,5 +1,6 @@ import { - generateContactsChanges, generateHistoryTable, generateMetadataChanges, Kind3Event, + generateContactsChanges, generateHistoryTable, generateMetadataChanges, generateRelayChanges, + Kind10002Event, Kind3Event, } from './LoadHistory'; import { MetadataFlex } from './LoadMetadataPage'; import SampleEvents from './SampleEvents'; @@ -124,3 +125,121 @@ describe('generateContactsChanges', () => { ]); }); }); + +describe('generateRelaysChanges', () => { + test('the oldest event list all the relays', () => { + const r = generateRelayChanges([ + { + ...SampleEvents.kind10002, + tags: [ + ['r', 'wss://alicerelay.example.com'], + ['r', 'wss://brando-relay.com'], + ['r', 'wss://brando-relay1.com'], + ], + } as Kind10002Event, + ]); + expect(r[0].changes).toEqual([ + 'wss://alicerelay.example.com', + 'wss://brando-relay.com', + 'wss://brando-relay1.com', + ]); + }); + test('read only and write only relays are maked as such', () => { + const r = generateRelayChanges([ + { + ...SampleEvents.kind10002, + tags: [ + ['r', 'wss://alicerelay.example.com'], + ['r', 'wss://brando-relay.com', 'read'], + ['r', 'wss://brando-relay1.com', 'write'], + ], + } as Kind10002Event, + ]); + expect(r[0].changes).toEqual([ + 'wss://alicerelay.example.com', + 'wss://brando-relay.com read only', + 'wss://brando-relay1.com write only', + ]); + }); + test('when a relay is added, the addition is listed in the changes array', () => { + const r = generateRelayChanges([ + { + ...SampleEvents.kind10002, + tags: [ + ['r', 'wss://alicerelay.example.com'], + ['r', 'wss://brando-relay.com', 'read'], + ['r', 'wss://brando-relay1.com', 'write'], + ], + } as Kind10002Event, + { + ...SampleEvents.kind10002, + tags: [ + ['r', 'wss://alicerelay.example.com'], + ['r', 'wss://brando-relay.com', 'read'], + ], + } as Kind10002Event, + ]); + expect(r[0].changes).toEqual(['
added wss://brando-relay1.com write only
']); + }); + test('when a relay is removed, the removal is listed in the changes array', () => { + const r = generateRelayChanges([ + { + ...SampleEvents.kind10002, + tags: [ + ['r', 'wss://alicerelay.example.com'], + ['r', 'wss://brando-relay.com', 'read'], + ], + } as Kind10002Event, + { + ...SampleEvents.kind10002, + tags: [ + ['r', 'wss://alicerelay.example.com'], + ['r', 'wss://brando-relay.com', 'read'], + ['r', 'wss://brando-relay1.com', 'write'], + ], + } as Kind10002Event, + ]); + expect(r[0].changes).toEqual(['
removed wss://brando-relay1.com write only
']); + }); + test('when a relay is modified, the removal is listed in the changes array', () => { + const r = generateRelayChanges([ + { + ...SampleEvents.kind10002, + tags: [ + ['r', 'wss://alicerelay.example.com'], + ['r', 'wss://brando-relay.com', 'read'], + ], + } as Kind10002Event, + { + ...SampleEvents.kind10002, + tags: [ + ['r', 'wss://alicerelay.example.com'], + ['r', 'wss://brando-relay.com', 'write'], + ], + } as Kind10002Event, + ]); + expect(r[0].changes).toEqual(['
modified wss://brando-relay.com read only
']); + }); + test('when a contact is added and another removed, both events are listed in the changes array', () => { + const r = generateRelayChanges([ + { + ...SampleEvents.kind10002, + tags: [ + ['r', 'wss://brando-relay1.com', 'write'], + ['r', 'wss://brando-relay.com', 'read'], + ], + } as Kind10002Event, + { + ...SampleEvents.kind10002, + tags: [ + ['r', 'wss://alicerelay.example.com'], + ['r', 'wss://brando-relay.com', 'read'], + ], + } as Kind10002Event, + ]); + expect(r[0].changes).toEqual([ + '
added wss://brando-relay1.com write only
', + '
removed wss://alicerelay.example.com
', + ]); + }); +}); diff --git a/src/LoadHistory.ts b/src/LoadHistory.ts index 75088ae..6c2aba3 100644 --- a/src/LoadHistory.ts +++ b/src/LoadHistory.ts @@ -104,11 +104,63 @@ export const generateContactsChanges = ( }; }); +export type Kind10002Tag = ['r', string] | ['r', string, 'read' | 'write' ]; + +export interface Kind10002Event extends Event { + kind:3; + tags:Kind10002Tag[] +} + +const summariseRelay = (r: Kind10002Tag): string => r[1] + (r[2] ? ` ${r[2]} only` : ''); + +export const generateRelayChanges = ( + history: Kind10002Event[], +):VersionChange[] => history.map((e, i, a) => { + const changes:string[] = []; + const current = e.tags.filter((t) => t[0] === 'r'); + // if first backup list all relays + if (i === a.length - 1) e.tags.forEach((r) => changes.push(summariseRelay(r))); + else { + const next = a[i + 1].tags; + // list adds + const added = current.filter((c) => !next.some((n) => n[1] === c[1])); + if (added.length > 0) { + added.forEach((r) => changes.push( + `
added ${summariseRelay(r)}
`, + )); + } + // list modified + const modified = current.filter( + (c) => next.filter((n) => n[1] === c[1]).some((n) => c[2] !== n[2]), + ); + if (modified.length > 0) { + modified.forEach((r) => changes.push( + `
modified ${summariseRelay(r)}
`, + )); + } + // list deletes + const removed = next.filter((c) => !current.some((n) => n[1] === c[1])); + if (removed.length > 0) { + removed.forEach((r) => changes.push( + `
removed ${summariseRelay(r)}
`, + )); + } + } + return { + ago: e.created_at, + changes, + option: i === 0 + ? 'Backup Complete' + : `Restore`, + }; +}); + export const generateHistoryTable = (history: Event[] | null):string => { if (!history || history.length === 0) return '

none

'; let changes:VersionChange[]; if (history[0].kind === 0) changes = generateMetadataChanges(history); else if (history[0].kind === 3) changes = generateContactsChanges(history as Kind3Event[]); + else if (history[0].kind === 10002) changes = generateRelayChanges(history as Kind10002Event[]); else changes = []; return generateChangesTable(changes); }; diff --git a/src/LoadRelaysPage.ts b/src/LoadRelaysPage.ts index 29e105d..2009da6 100644 --- a/src/LoadRelaysPage.ts +++ b/src/LoadRelaysPage.ts @@ -1,8 +1,136 @@ +import { fetchCachedProfileEvent, fetchCachedProfileEventHistory, publishEvent } from './fetchEvents'; +import { generateHistoryTable, Kind10002Event, Kind10002Tag } from './LoadHistory'; +import { localStorageGetItem } from './LocalStorage'; + +const generateRelayFormRow = (index:number, tag?:Kind10002Tag):string => ` + + + + + + + + + Remove + +`; + +const generateRelayForm = (event: Kind10002Event | null):string => ` +
+ + + ${event?.tags.map((a, i) => generateRelayFormRow(i, a)).join('')} + ${generateRelayFormRow(event ? event.tags.length : 0)} + + + + + + +
+ + + + + +
+
+`; + +const SubmitRelayForm = async () => { + // set loading status + const b = document.getElementById('relayssubmitbutton') 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('relaysform') as HTMLFormElement); + const tags = Array.from(Array(100)).map((_e, i) => { + const url = fd.get(`PM-form-relay-${i}-address`); + if (!url || url === '') return null; + const w = !!fd.get(`PM-form-relay-${i}-write`); + const r = !!fd.get(`PM-form-relay-${i}-read`); + const base:Kind10002Tag = ['r', url as string]; + if (w && r) return base; + return ['r', fd.get(`PM-form-relay-${i}-address`), r ? 'read' : 'write'] as Kind10002Tag; + }).filter((v) => v !== null); + // sign event + if (!window.nostr) return; + const ne = await window.nostr.signEvent({ + pubkey: localStorageGetItem('pubkey') as string, + kind: 10002, + created_at: Math.floor(Date.now() / 1000), + content: '', + tags: tags as Kind10002Tag[], + }); + // 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 loadRelayForm = (RootElementID:string) => { + (document.getElementById(RootElementID) as HTMLDivElement) + .innerHTML = `
+

Relays

+ ${generateRelayForm(fetchCachedProfileEvent(10002) as Kind10002Event)} +
`; + // form submit event + (document.getElementById('relayssubmitbutton') as HTMLButtonElement).onclick = (event) => { + SubmitRelayForm(); + event.preventDefault(); + }; + // reset form + (document.getElementById('relaysresetbutton') as HTMLButtonElement).onclick = (event) => { + loadRelayForm(RootElementID); + event.preventDefault(); + }; + // add button + (document.getElementById('relaysaddbutton') as HTMLButtonElement).onclick = (event) => { + const count = (document.getElementById('relayformtbody') as HTMLElement).childElementCount - 1; + const tr = document.createElement('tr'); + (document.getElementById('relaybuttons') as HTMLElement).before(tr); + tr.outerHTML = generateRelayFormRow(count); + event.preventDefault(); + }; +}; + const loadRelaysBackupHistory = (RootElementID:string) => { + const table = generateHistoryTable(fetchCachedProfileEventHistory(10002)); (document.getElementById(RootElementID) as HTMLDivElement) .innerHTML = `
-

Relays

-

TODO

+

Relays

${table}
`; }; @@ -10,9 +138,11 @@ const LoadRelaysPage = () => { const o:HTMLElement = document.getElementById('PM-container') as HTMLElement; o.innerHTML = `
+
`; + loadRelayForm('relayforcontainer'); loadRelaysBackupHistory('relaysbackuphistory'); }; diff --git a/src/style.scss b/src/style.scss index b6d9351..8884a05 100644 --- a/src/style.scss +++ b/src/style.scss @@ -66,3 +66,9 @@ img[src=""] { margin: 10px 10px 10px 0; } } + +#relayform { + input[type="text"] { + margin-top: 17px; + } +} \ No newline at end of file