added relay form and backup history

This commit is contained in:
=
2023-03-07 23:29:31 +00:00
parent d696b108d5
commit 27e7fd31ab
5 changed files with 313 additions and 6 deletions

View File

@@ -13,7 +13,7 @@ Supported profile events: kind `0`, `2`, `10002` and `3`.
- [x] backup your profile events to offline browser storage - [x] backup your profile events to offline browser storage
- [x] review changes between backups - [x] review changes between backups
- [x] `0` - [x] `0`
- [ ] `10002` and `2` - [x] `10002`
- [x] `3` - [x] `3`
- [ ] selectively restore previous versions - [ ] selectively restore previous versions
- [x] download profile backup history as JSON file - [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 - [ ] Contacts recommendation based off social graph
- [ ] Suggest updates to contact relay based on Contact's kind `10002` and `2` events - [ ] Suggest updates to contact relay based on Contact's kind `10002` and `2` events
- [ ] Relays - [x] Relays
- [ ] editable table of read / write relays kind `10002` event - [x] editable table of read / write relays kind `10002` event
- [ ] auto suggestion of `10002` event based on contact's relays if no event present - [ ] auto suggestion of `10002` event based on contact's relays if no event present
- [ ] evaluation of `10002` based on contact's - [ ] evaluation of `10002` based on contact's
- [ ] decentralisation score to encourage users not to use the same relay - [ ] decentralisation score to encourage users not to use the same relay

View File

@@ -1,5 +1,6 @@
import { import {
generateContactsChanges, generateHistoryTable, generateMetadataChanges, Kind3Event, generateContactsChanges, generateHistoryTable, generateMetadataChanges, generateRelayChanges,
Kind10002Event, Kind3Event,
} from './LoadHistory'; } from './LoadHistory';
import { MetadataFlex } from './LoadMetadataPage'; import { MetadataFlex } from './LoadMetadataPage';
import SampleEvents from './SampleEvents'; 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(['<div class="added">added <mark>wss://brando-relay1.com write only</mark></div>']);
});
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(['<div class="removed">removed <mark>wss://brando-relay1.com write only</mark></div>']);
});
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(['<div class="modified">modified <mark>wss://brando-relay.com read only</mark></div>']);
});
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([
'<div class="added">added <mark>wss://brando-relay1.com write only</mark></div>',
'<div class="removed">removed <mark>wss://alicerelay.example.com</mark></div>',
]);
});
});

View File

@@ -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(
`<div class="added">added <mark>${summariseRelay(r)}</mark></div>`,
));
}
// 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(
`<div class="modified">modified <mark>${summariseRelay(r)}</mark></div>`,
));
}
// list deletes
const removed = next.filter((c) => !current.some((n) => n[1] === c[1]));
if (removed.length > 0) {
removed.forEach((r) => changes.push(
`<div class="removed">removed <mark>${summariseRelay(r)}</mark></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 => { export const generateHistoryTable = (history: Event[] | null):string => {
if (!history || history.length === 0) return '<p>none</p>'; if (!history || history.length === 0) return '<p>none</p>';
let changes:VersionChange[]; let changes:VersionChange[];
if (history[0].kind === 0) changes = generateMetadataChanges(history); if (history[0].kind === 0) changes = generateMetadataChanges(history);
else if (history[0].kind === 3) changes = generateContactsChanges(history as Kind3Event[]); else if (history[0].kind === 3) changes = generateContactsChanges(history as Kind3Event[]);
else if (history[0].kind === 10002) changes = generateRelayChanges(history as Kind10002Event[]);
else changes = []; else changes = [];
return generateChangesTable(changes); return generateChangesTable(changes);
}; };

View File

@@ -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 => `
<tr id="PM-form-relay-${index}-row" class="relayformrow">
<td>
<input
type="text"
name="PM-form-relay-${index}-address"
id="PM-form-relay-${index}-address"
placeholder="Relay address: wss://..."
${tag ? `value="${tag[1]}"` : ''}
/>
</td>
<td>
<label for="PM-form-relay-${index}-read">
<input
type="checkbox"
id="PM-form-relay-${index}-read"
name="PM-form-relay-${index}-read"
${!tag || !tag[2] || tag[2] === 'read' ? 'checked="checked"' : ''}
>
read
</label>
<label for="PM-form-relay-${index}-write">
<input
type="checkbox"
id="PM-form-relay-${index}-write"
name="PM-form-relay-${index}-write"
${!tag || !tag[2] || tag[2] === 'write' ? 'checked="checked"' : ''}
>
write
</label>
</td>
<td><a
href="#"
onclick="this.parentNode.parentNode.remove();return false;"
class="button outline secondary"
>Remove</a></td>
</tr>
`;
const generateRelayForm = (event: Kind10002Event | null):string => `
<form id="relaysform">
<table role="grid">
<tbody id="relayformtbody">
${event?.tags.map((a, i) => generateRelayFormRow(i, a)).join('')}
${generateRelayFormRow(event ? event.tags.length : 0)}
<tr id="relaybuttons">
<td>
<button id="relayssubmitbutton" type="button">${event ? 'Update' : 'Save'}</button>
</td>
<td>
<button id="relaysresetbutton" class="secondary outline" type="reset">Reset Form</button>
</td>
<td>
<button id="relaysaddbutton" class="secondary" type="button">Add</button>
</td>
</tr>
</tbody>
</table>
</form>
`;
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 = `<div class="relayform">
<h3>Relays</h3>
${generateRelayForm(fetchCachedProfileEvent(10002) as Kind10002Event)}
</div>`;
// 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 loadRelaysBackupHistory = (RootElementID:string) => {
const table = generateHistoryTable(fetchCachedProfileEventHistory(10002));
(document.getElementById(RootElementID) as HTMLDivElement) (document.getElementById(RootElementID) as HTMLDivElement)
.innerHTML = `<div class="relaysbackuphistory"> .innerHTML = `<div class="relaysbackuphistory">
<h3>Relays</h3> <h3>Relays</h3>${table}
<p>TODO</p>
</div>`; </div>`;
}; };
@@ -10,9 +138,11 @@ const LoadRelaysPage = () => {
const o:HTMLElement = document.getElementById('PM-container') as HTMLElement; const o:HTMLElement = document.getElementById('PM-container') as HTMLElement;
o.innerHTML = ` o.innerHTML = `
<div id="relayspage" class="container"> <div id="relayspage" class="container">
<div id="relayforcontainer"></div>
<div id="relaysbackuphistory"></div> <div id="relaysbackuphistory"></div>
<div> <div>
`; `;
loadRelayForm('relayforcontainer');
loadRelaysBackupHistory('relaysbackuphistory'); loadRelaysBackupHistory('relaysbackuphistory');
}; };

View File

@@ -66,3 +66,9 @@ img[src=""] {
margin: 10px 10px 10px 0; margin: 10px 10px 10px 0;
} }
} }
#relayform {
input[type="text"] {
margin-top: 17px;
}
}