added contacts features

This commit is contained in:
=
2023-03-12 23:58:31 +00:00
parent 8264199f86
commit 68c6d4e778
10 changed files with 656 additions and 91 deletions

View File

@@ -1,6 +1,6 @@
# Nostr Profile Manager
Lightweight and efficent typescript micro app for basic nostr profile management. Current USP is offline backup and restore.
Lightweight 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.
@@ -27,10 +27,12 @@ Supported profile events: kind `0`, `10002` and `3`.
- [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
- [x] Contacts
- [x] Add Contacts based on nip05, nip19 (npub, nprofile or naddr) or hex
- [x] keyword search profiles to find contacts
- [ ] keyword search profiles to find contacts of contacts
- [x] Remove Contacts
- [x] 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
@@ -41,9 +43,17 @@ Supported profile events: kind `0`, `10002` and `3`.
- [ ] evaluation of `10002` based on contact's
- [ ] decentralisation score to encourage users not to use the same relay
##### Lightweight and Efficent
- [ ] manage event distribution to relays
- [ ] Show which and how many relays return an each event (and including historic events)
- [ ] Show warning if selected write relays don't
- [ ] suggest republishing events (particularly `10002`) to spread them to more relays if appropriate
- [ ] look far and wide for events
- cycle through all known relays to find current and previous versions of profile events to enable restoration. reccommended only when accessed through a VPN
##### Lightweight
- [ ] only javascript dependancy is nostr-tools (TODO: remove timeago)
- [x] connects to the minimum number of relays
- [x] connect relays specified in `10002` or 3 default relays
- [x] minimises the number of open websockets
- [ ] minimises the number of open websockets
- [x] use blastr relay to send profile events far and wide
- [ ] efficent (TODO: currently the 'Contacts' functionality is very inefficent)

View File

@@ -1,13 +1,400 @@
import { loadBackupHistory } from './LoadHistory';
import { Event, nip05, nip19 } from 'nostr-tools';
import {
fetchAllCachedProfileEvents, fetchCachedMyProfileEvent, fetchCachedProfileEvent,
fetchProfileEvent, getContactMostPopularPetname, getContactName, getMyPetnameForUser,
getMyRelayForUser, isUserMyContact, submitUnsignedEvent,
} from './fetchEvents';
import { Kind3Event, loadBackupHistory } from './LoadHistory';
import { localStorageGetItem } from './LocalStorage';
/**
* TODO
* > 'Suggested Housekeeping' section that:
* > highligts out suggestions (deleted, do not use, compromised, old profile)
* > add relays
*/
const getPubkey = (e:Event | string) => (typeof e === 'string' ? e : e.pubkey);
const generateMicroCardLi = (eventorpubkey:Event | string):string => {
const pubkey = getPubkey(eventorpubkey);
return `
<li>
<a
href="#"
onclick="return false;"
class="microcard ${typeof eventorpubkey === 'string' ? 'nokind0' : ''}"
id="contact-microcard-${pubkey}"
>
${getContactName(pubkey)}
</a>
</li>
`;
};
const generateMicroCardList = (eventorpubkeys:(Event | string)[]):string => `
<ul id="maincontactlist">${eventorpubkeys.map(
(e) => generateMicroCardLi(typeof e === 'string' ? e : e.pubkey),
).join('')}</ul>
`;
const generateContactDetails = (pubkey:string):string => {
const e = fetchCachedProfileEvent(pubkey, 0);
if (!e) {
return `
<article>
<strong>${getContactName(pubkey)}</strong>
<p>loading users metadata...</p>
</article>
`;
}
const m = JSON.parse(e.content);
const otherspetname = getContactMostPopularPetname(pubkey);
const ismycontact = isUserMyContact(pubkey);
return `
<article>
<div>
${m && !!m.picture ? `<img src="${m.picture}" /> ` : ''}
<div class="contactdetailsmain">
<strong>${m.name ? m.name : '[unknown name]'}</strong>
${m.nip05 ? `<small id="nip05-${pubkey}">${m.nip05} </small>` : ''}<span id="nip05-${pubkey}-verified"></span>
${otherspetname && otherspetname !== m.name ? `<div>popular petname: ${otherspetname}</div>` : ''}
<div><small>${m.about ? m.about : ''}</small></div>
</div>
</div>
<footer class="contactdetailsform">
<div class="grid">
<label for="mypetname">
petname
<input
type="text"
name="mypetname"
placeholder="petname"
value="${getMyPetnameForUser(pubkey) || ''}"
id="petname-contact-form-${pubkey}"
onkeypress = "this.onchange();"
onpaste = "this.onchange();"
oninput = "this.onchange();"
>
</label>
<label for="myrelay">
relay
<input
type="text"
name="myrelay"
placeholder="relay"
value="${getMyRelayForUser(pubkey) || ''}"
id="relay-contact-form-${pubkey}"
onkeypress = "this.onchange();"
onpaste = "this.onchange();"
oninput = "this.onchange();"
>
</label>
<div class="contact-form-buttons">
<button id="add-contact-${pubkey}" class="${ismycontact ? 'hide' : ''}">Add</button>
<button id="update-contact-${pubkey}" class="hide">Update</button>
<button id="remove-contact-${pubkey}" class="${ismycontact ? '' : 'hide'}">Remove Contact</button>
</div>
</div>
<div class="grid">
</div>
</footer>
</article>
`;
};
const loadContactDetails = (pubkey:string):void => {
// load html
(document.getElementById('contactdetails') as HTMLDivElement)
.innerHTML = generateContactDetails(pubkey);
// scroll to top
window.scrollTo(0, 0);
// reload if no kind0
const reload = () => setTimeout(() => loadContactDetails(pubkey), 500);
if (!fetchCachedProfileEvent(pubkey, 0)) reload();
// on form change show update button instead of remove button
const onchange = () => {
if (!isUserMyContact(pubkey)) return;
(document.getElementById(`update-contact-${pubkey}`) as HTMLButtonElement)
.classList.remove('hide');
(document.getElementById(`remove-contact-${pubkey}`) as HTMLButtonElement)
.classList.add('hide');
};
(document.getElementById(`relay-contact-form-${pubkey}`) as HTMLInputElement)
.onchange = onchange;
(document.getElementById(`petname-contact-form-${pubkey}`) as HTMLInputElement)
.onchange = onchange;
// nip05
const checkUserNip05 = async () => {
const nip05el = document.getElementById(`nip05-${pubkey}`);
if (nip05el) {
const addr = nip05el.innerHTML.trim();
let verified:boolean = false;
try {
const r = await nip05.queryProfile(addr);
verified = !!r && r.pubkey === pubkey;
} catch { /* empty */ }
const verifiedel = (document.getElementById(`nip05-${pubkey}-verified`) as HTMLElement);
if (verified) verifiedel.innerHTML = '<ins>&#10004; verified</ins>';
else verifiedel.innerHTML = '<del>&#10004; verified</del>';
}
};
checkUserNip05();
// add / update / remove buttons
const generateTag = (): ['p', string, string, string] => [
'p',
pubkey,
(document.getElementById(`relay-contact-form-${pubkey}`) as HTMLInputElement).value || '',
(document.getElementById(`petname-contact-form-${pubkey}`) as HTMLInputElement).value || '',
];
const addUpdateOrRemoveContact = async (tags:['p', string, string, string][], ButtonID: string) => {
await submitUnsignedEvent(
{
pubkey: localStorageGetItem('pubkey') as string,
kind: 3,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags,
},
ButtonID,
);
loadContactDetails(pubkey);
};
// add button
(document.getElementById(`add-contact-${pubkey}`) as HTMLButtonElement).onclick = (event) => {
event.preventDefault();
const ce = fetchCachedMyProfileEvent(3) as Kind3Event || null;
const tags = ce ? [...ce.tags, generateTag()] : [generateTag()];
addUpdateOrRemoveContact(tags, `add-contact-${pubkey}`);
};
// update button
(document.getElementById(`update-contact-${pubkey}`) as HTMLButtonElement).onclick = (event) => {
event.preventDefault();
const ce = fetchCachedMyProfileEvent(3) as Kind3Event;
const tags = [...ce.tags.map((t) => (t[1] === pubkey ? generateTag() : t))];
addUpdateOrRemoveContact(tags, `update-contact-${pubkey}`);
};
// remove button
(document.getElementById(`remove-contact-${pubkey}`) as HTMLButtonElement).onclick = (event) => {
event.preventDefault();
const ce = fetchCachedMyProfileEvent(3) as Kind3Event;
const tags = [...ce.tags.filter((t) => (t[1] !== pubkey))];
addUpdateOrRemoveContact(tags, `remove-contact-${pubkey}`);
};
};
const processHexOrNip19 = (s:string):{ pubkey: string | null; relays: string[] | null } => {
let pubkey:string | null = null;
let relays:string[] | null = null;
// is hex string?
const regexhex64 = /^[a-fA-F0-9]{64}$/i;
if (regexhex64.test(s)) {
pubkey = s; // this could be an event id?
return { pubkey, relays };
}
// check for nip19
try {
const { data, type } = nip19.decode(s) as {
type: string, data: {
pubkey?:string, // not present in nevent, nsec or note
relays?: string[];
}
};
if (typeof data === 'string') {
if (type === 'npub') pubkey = data;
else throw new Error('no pubkey');
} else {
if (data.pubkey) pubkey = data.pubkey;
if (data.relays) relays = data.relays;
}
} catch { /* empty */ }
return { pubkey, relays };
};
const nip05cache:{ [nip05Search: string]: string | null } = {};
let nip05Searching = '';
const searchNip05 = async (input: HTMLInputElement):Promise<null | 'searching' | string> => {
const s = input.value;
const searchstatus = document.getElementById('searchstatus') as HTMLDivElement;
const setLoading = () => { searchstatus.innerHTML = '<p aria-busy="true">Searching nip05...<p>'; };
// check valid NIP05 string
if (!!s || (s.indexOf('.') === -1 && s.indexOf('@') === -1)) return null;
// check cache
if (nip05cache[s]) return nip05cache[s];
if (nip05cache[s] === null) return null;
// check if already searching
if (s === nip05Searching) return 'searching';
// wait for typing to pause
const repsonse = await new Promise((finished) => {
setTimeout(async () => {
// if value hasn't changed
if (input.value === s) {
// set loading
setLoading();
// prevent duplicate queries
nip05Searching = s;
// make request call
try {
const r = await nip05.queryProfile(s);
if (!!r && r.pubkey) nip05cache[s] = r.pubkey;
else nip05cache[s] = null;
} catch {
// mark as not valid
nip05cache[s] = null;
}
// if search query has changed return 'searching' to end
if (s === nip05Searching) finished('searching');
else {
// reset nip05Searching;
nip05Searching = '';
// return value
finished(nip05cache[s]);
}
}
}, 500);
}) as null | string;
return repsonse;
};
const generateResults = (eventorpubkeys:(Event | string)[]) => {
const mycontacts = eventorpubkeys;
return `
<div id="contactdetails"></div>
<div id="mycontacts">
<h6>My Contacts</h6>
${generateMicroCardList(mycontacts)}
</div>
<div id="mycontactscontacts"></div>
<div id="otherusers"></div>
`;
};
const refreshResults = (eventorpubkeys?:(Event | string)[]) => {
(document.getElementById('searchstatus') as HTMLDivElement).innerHTML = '';
// if called without array load my contacts
if (!eventorpubkeys) {
const e3 = fetchCachedMyProfileEvent(3);
if (e3) refreshResults(e3.tags.map((c) => c[1]));
return;
}
// display results
(document.getElementById('searchresults') as HTMLDivElement)
.innerHTML = eventorpubkeys.length === 0
? 'no results'
: generateResults(eventorpubkeys);
eventorpubkeys.forEach((e) => {
const microcarda = document.getElementById(`contact-microcard-${getPubkey(e)}`) as HTMLAnchorElement;
// activate buttons to view details.
microcarda.onclick = () => {
loadContactDetails(getPubkey(e));
return false;
};
// replace pubkey with metadata when loaded
const recheckForKind0 = () => {
setTimeout(() => {
try {
const ne = fetchCachedProfileEvent(getPubkey(e), 0);
// this will add with kind 0 name or if its not found petnames from other users
const name = getContactName(getPubkey(ne || e));
if (name !== microcarda.innerHTML) microcarda.innerHTML = name;
if (!ne) recheckForKind0();
} catch { /* empty - assume element has been removed */ }
}, 750);
};
if (microcarda.classList.contains('nokind0')) recheckForKind0();
});
// display details if only result
if (eventorpubkeys.length === 1) loadContactDetails(getPubkey(eventorpubkeys[0]));
};
const setSearchInputOnChangeEvent = () => {
const input = document.getElementById('searchinput') as HTMLInputElement;
const searchstatus = document.getElementById('searchstatus') as HTMLDivElement;
input.onchange = async () => {
const s = input.value;
let pubkey: string | null = null;
let relays: string[] | null = null;
// no search
if (!s || s.trim().length === 0) {
const e3 = fetchCachedMyProfileEvent(3);
if (e3) refreshResults(e3.tags.map((c) => c[1]));
return;
}
// if 61+ assume hex, or nip19 (npub, nprofile, naddr) and get pubkey / relays
if (s.length > 60) {
({ pubkey, relays } = processHexOrNip19(s));
if (!pubkey) {
searchstatus.innerHTML = 'invalid search input - try npub, nprofile, naddr or hex';
return;
}
searchstatus.innerHTML = 'extracted pubkey. searching for profile...';
} else {
// keyword search kind 0s
const allkind0s = fetchAllCachedProfileEvents(0);
const words = s.split(' ');
const matches = allkind0s
.filter((e) => words.map((w) => e.content.indexOf(w)).some((v) => v > -1));
refreshResults(matches);
// search nip05
if (!!s && (s.indexOf('.') > -1 && s.indexOf('@') > -1)) {
const r = await searchNip05(input);
if (r === 'searching') return;
if (!r) searchstatus.innerHTML = '<p>not a verified nip05 address</p>';
else {
searchstatus.innerHTML = '<p>Verified nip05 address. loading profile...</p>';
pubkey = r;
}
}
}
// if extracted pubkey - hex, nip19 or nip05
if (pubkey) {
const ce = fetchCachedProfileEvent(pubkey, 0);
// kind 0 not found for user
if (!ce) {
// request from relay
const r = await fetchProfileEvent(pubkey, 0, relays);
// found not profile
if (!r) {
searchstatus.innerHTML = 'extracted pubkey. but couldn\'t find profile.';
return;
}
}
refreshResults();
loadContactDetails(pubkey);
}
};
};
const loadViewContacts = (RootElementID:string) => {
(document.getElementById(RootElementID) as HTMLDivElement)
.innerHTML = `
<div id="contactsearch">
<input
type="search"
id="searchinput"
placeholder="nip05, npub, nprofile or keywords for contacts of contacts"
onkeypress = "this.onchange();"
onpaste = "this.onchange();"
oninput = "this.onchange();"
>
<div id="searchstatus"></div>
</div>
<div id="searchresults"></div>
`;
setSearchInputOnChangeEvent();
refreshResults();
};
const LoadContactsPage = () => {
const o:HTMLElement = document.getElementById('PM-container') as HTMLElement;
o.innerHTML = `
<div id="contactspage" class="container">
<div id="viewcontacts"></div>
<div id="contactsbackuphistory"></div>
<div>
`;
loadBackupHistory('contactsbackuphistory', 3);
loadViewContacts('viewcontacts');
};
export default LoadContactsPage;

View File

@@ -1,6 +1,6 @@
import * as timeago from 'timeago.js';
import { Event } from 'nostr-tools';
import { fetchCachedProfileEventHistory, submitUnsignedEvent } from './fetchEvents';
import { fetchCachedMyProfileEventHistory, submitUnsignedEvent } from './fetchEvents';
export type VersionChange = {
ago:number;
@@ -69,13 +69,7 @@ const sameContact = (
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>`;
return `<mark id="history-petname-${a[1]}">${(a[1]).substring(0, 10)}...</mark>`;
};
export const generateContactsChanges = (
@@ -209,7 +203,7 @@ export const activateRestoreButtons = (history: Event[] | null, afterRestore: ()
};
export const loadBackupHistory = (RootElementID:string, kind: 0 | 10002 | 3) => {
const h = fetchCachedProfileEventHistory(kind);
const h = fetchCachedMyProfileEventHistory(kind);
const table = generateHistoryTable(h);
(document.getElementById(RootElementID) as HTMLDivElement)
.innerHTML = `<h4>Backup History</h4>${table}`;

View File

@@ -1,5 +1,5 @@
import { nip05 } from 'nostr-tools';
import { fetchCachedProfileEvent, submitUnsignedEvent } from './fetchEvents';
import { fetchCachedMyProfileEvent, submitUnsignedEvent } from './fetchEvents';
import { loadBackupHistory } from './LoadHistory';
import { localStorageGetItem } from './LocalStorage';
@@ -73,7 +73,7 @@ const SubmitMetadataForm = async () => {
// 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);
const e = fetchCachedMyProfileEvent(0);
(e ? [...(Object.keys(JSON.parse(e.content))), ...standardkeys] : standardkeys)
.forEach((k) => {
const d = fd.get(`PM-form-${k}`);
@@ -95,7 +95,7 @@ const SubmitMetadataForm = async () => {
};
const loadMetadataForm = (RootElementID:string) => {
const e = fetchCachedProfileEvent(0);
const e = fetchCachedMyProfileEvent(0);
const MetadataContent = !e ? null : JSON.parse(e.content) as MetadataFlex;
(document.getElementById(RootElementID) as HTMLDivElement)
.innerHTML = `<div class="profileform">

View File

@@ -1,6 +1,6 @@
import { Event } from 'nostr-tools';
import {
fetchCachedProfileEvent, fetchCachedProfileEventHistory, hadLatest, isUptodate,
fetchCachedMyProfileEvent, fetchCachedMyProfileEventHistory, hadLatest, isUptodate,
} from './fetchEvents';
import LoadContactsPage from './LoadContactsPage';
import { LoadMetadataPage, MetadataFlex } from './LoadMetadataPage';
@@ -126,7 +126,7 @@ export const generateBackupHeroHeading = (
};
export const LoadProfileHome = () => {
const noprofileinfo = !fetchCachedProfileEvent(0) && !fetchCachedProfileEvent(3);
const noprofileinfo = !fetchCachedMyProfileEvent(0) && !fetchCachedMyProfileEvent(3);
const uptodate = isUptodate();
const hadlatest = hadLatest();
const o:HTMLElement = document.getElementById('PM-container') as HTMLElement;
@@ -134,11 +134,11 @@ export const LoadProfileHome = () => {
<div class="container">
<div class="hero grid">
${noprofileinfo ? generateLogoHero() : `<div><article class="profile-summary-card">
${generateMetadataHeader(fetchCachedProfileEvent(0) as Event)}
${generateMetadataHeader(fetchCachedMyProfileEvent(0) as Event)}
<div>
${generateMetadataSummary(fetchCachedProfileEvent(0), !uptodate)}
${generateContactsSummary(fetchCachedProfileEvent(3), !uptodate)}
${generateRelaysSummary(fetchCachedProfileEvent(10002), !uptodate)}
${generateMetadataSummary(fetchCachedMyProfileEvent(0), !uptodate)}
${generateContactsSummary(fetchCachedMyProfileEvent(3), !uptodate)}
${generateRelaysSummary(fetchCachedMyProfileEvent(10002), !uptodate)}
</div>
</article></div>`}
<div>${generateBackupHeroHeading(uptodate, noprofileinfo, hadlatest)}</div>
@@ -158,10 +158,10 @@ export const LoadProfileHome = () => {
donwloada.onclick = (event) => {
event.preventDefault();
const jsonStr = JSON.stringify([
...(fetchCachedProfileEventHistory(0) || []),
...(fetchCachedProfileEventHistory(2) || []),
...(fetchCachedProfileEventHistory(10002) || []),
...(fetchCachedProfileEventHistory(3) || []),
...(fetchCachedMyProfileEventHistory(0) || []),
...(fetchCachedMyProfileEventHistory(2) || []),
...(fetchCachedMyProfileEventHistory(10002) || []),
...(fetchCachedMyProfileEventHistory(3) || []),
]);
const element = document.createElement('a');
element.setAttribute('href', `data:text/plain;charset=utf-8,${encodeURIComponent(jsonStr)}`);

View File

@@ -1,4 +1,4 @@
import { fetchCachedProfileEvent, submitUnsignedEvent } from './fetchEvents';
import { fetchCachedMyProfileEvent, submitUnsignedEvent } from './fetchEvents';
import { Kind10002Event, Kind10002Tag, loadBackupHistory } from './LoadHistory';
import { localStorageGetItem } from './LocalStorage';
@@ -94,7 +94,7 @@ const loadRelayForm = (RootElementID:string) => {
(document.getElementById(RootElementID) as HTMLDivElement)
.innerHTML = `<div class="relayform">
<h3>Relays</h3>
${generateRelayForm(fetchCachedProfileEvent(10002) as Kind10002Event)}
${generateRelayForm(fetchCachedMyProfileEvent(10002) as Kind10002Event)}
</div>`;
// form submit event
(document.getElementById('relayssubmitbutton') as HTMLButtonElement).onclick = (event) => {

View File

@@ -1,7 +1,7 @@
import { Event } from 'nostr-tools';
import SampleEvents from './SampleEvents';
import {
storeMyProfileEvent, fetchCachedProfileEventHistory, fetchCachedProfileEvent,
storeMyProfileEvent, fetchCachedMyProfileEventHistory, fetchCachedMyProfileEvent,
fetchMyProfileEvents,
} from './fetchEvents';
import * as LocalStorage from './LocalStorage';
@@ -43,7 +43,7 @@ describe('', () => {
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(fetchCachedMyProfileEventHistory(kind)).toEqual([{ ...SampleEvents[`kind${kind}`] }]);
expect(r).toBeTruthy();
expect(updateLastUpdatedSpy).toHaveBeenCalledTimes(1);
});
@@ -56,7 +56,7 @@ describe('', () => {
storeMyProfileEvent(a[0]);
const r2 = storeMyProfileEvent(a[1]);
const r3 = storeMyProfileEvent(a[2]);
const r = fetchCachedProfileEventHistory(kind);
const r = fetchCachedMyProfileEventHistory(kind);
expect(r).toContainEqual(a[0]);
expect(r).toContainEqual(a[1]);
expect(r).toContainEqual(a[2]);
@@ -69,14 +69,14 @@ describe('', () => {
test('event from a different pubkey submitted', () => {
const r = storeMyProfileEvent({ ...SampleEvents[`kind${kind}`], pubkey: '1' });
expect(r).toBeFalsy();
expect(fetchCachedProfileEventHistory(kind)).toBeNull();
expect(fetchCachedMyProfileEventHistory(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(fetchCachedMyProfileEventHistory(kind)).toEqual([{ ...SampleEvents[`kind${kind}`] }]);
expect(updateLastUpdatedSpy).toHaveBeenCalledTimes(1);
});
});
@@ -86,11 +86,11 @@ describe('', () => {
expect(updateLastUpdatedSpy).toHaveBeenCalledTimes(0);
});
});
describe('fetchCachedProfileEventHistory', () => {
describe('fetchCachedMyProfileEventHistory', () => {
describe('returns array of events', () => {
test('single event', () => {
storeMyProfileEvent({ ...SampleEvents.kind0 });
expect(fetchCachedProfileEventHistory(0)).toEqual([{ ...SampleEvents.kind0 }]);
expect(fetchCachedMyProfileEventHistory(0)).toEqual([{ ...SampleEvents.kind0 }]);
});
test('multiple events', () => {
const a = [
@@ -101,7 +101,7 @@ describe('', () => {
storeMyProfileEvent(a[0]);
storeMyProfileEvent(a[1]);
storeMyProfileEvent(a[2]);
const r = fetchCachedProfileEventHistory(0);
const r = fetchCachedMyProfileEventHistory(0);
expect(r).toContainEqual(a[0]);
expect(r).toContainEqual(a[1]);
expect(r).toContainEqual(a[2]);
@@ -115,10 +115,10 @@ describe('', () => {
storeMyProfileEvent(a[0]);
storeMyProfileEvent(a[1]);
storeMyProfileEvent(a[2]);
const r = fetchCachedProfileEventHistory(0);
const r = fetchCachedMyProfileEventHistory(0);
expect(r).toContainEqual(a[0]);
expect(r).toContainEqual(a[1]);
expect(fetchCachedProfileEventHistory(3)).toContainEqual(a[2]);
expect(fetchCachedMyProfileEventHistory(3)).toContainEqual(a[2]);
});
test('ordered by created_at decending', () => {
const a = [
@@ -129,7 +129,7 @@ describe('', () => {
storeMyProfileEvent({ ...a[0] });
storeMyProfileEvent({ ...a[1] });
storeMyProfileEvent({ ...a[2] });
const r = fetchCachedProfileEventHistory(0) as Event[];
const r = fetchCachedMyProfileEventHistory(0) as Event[];
expect(r[0]).toEqual(a[1]);
expect(r[1]).toEqual(a[2]);
expect(r[2]).toEqual(a[0]);
@@ -137,10 +137,10 @@ describe('', () => {
});
test('returns null if no events of kind present', () => {
storeMyProfileEvent({ ...SampleEvents.kind0 });
expect(fetchCachedProfileEventHistory(3)).toBeNull();
expect(fetchCachedMyProfileEventHistory(3)).toBeNull();
});
});
describe('fetchCachedProfileEvent', () => {
describe('fetchCachedMyProfileEvent', () => {
test('returns event of specified kind with largest created_at value', () => {
const a = [
{ ...SampleEvents.kind0 },
@@ -150,23 +150,23 @@ describe('', () => {
storeMyProfileEvent({ ...a[0] });
storeMyProfileEvent({ ...a[1] });
storeMyProfileEvent({ ...a[2] });
const r = fetchCachedProfileEvent(0) as Event;
const r = fetchCachedMyProfileEvent(0) as Event;
expect(r).toEqual(a[1]);
});
test('returns null if no events of kind present', () => {
storeMyProfileEvent({ ...SampleEvents.kind0 });
expect(fetchCachedProfileEventHistory(3)).toBeNull();
expect(fetchCachedMyProfileEventHistory(3)).toBeNull();
});
});
describe('fetchMyProfileEvents', () => {
const fetchCachedProfileEventSpy = jest.spyOn(FetchEvents, 'fetchCachedProfileEvent');
const fetchCachedMyProfileEventSpy = jest.spyOn(FetchEvents, 'fetchCachedMyProfileEvent');
const mockEventProcessor = jest.fn();
const mockrequestEventsFromRelays = jest.spyOn(RelayManagement, 'requestEventsFromRelays');
const mockupdateLastFetchDate = jest.spyOn(FetchEvents, 'updateLastFetchDate');
const mocklastFetchDate = jest.spyOn(FetchEvents, 'lastFetchDate');
const mockisUptodate = jest.spyOn(FetchEvents, 'isUptodate');
beforeEach(async () => {
fetchCachedProfileEventSpy.mockReset();
fetchCachedMyProfileEventSpy.mockReset();
mockEventProcessor.mockReset();
mockrequestEventsFromRelays.mockReset();
mockupdateLastFetchDate.mockReset();
@@ -176,7 +176,7 @@ describe('', () => {
describe('when isUptodate returns true', () => {
beforeEach(async () => {
mockisUptodate.mockReturnValue(true);
fetchCachedProfileEventSpy.mockImplementation((kind) => {
fetchCachedMyProfileEventSpy.mockImplementation((kind) => {
if (kind === 0) return { ...SampleEvents.kind0 };
if (kind === 10002) return null;
if (kind === 2) return null;
@@ -192,15 +192,15 @@ describe('', () => {
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(fetchCachedMyProfileEventSpy).toHaveBeenCalledWith(0);
expect(fetchCachedMyProfileEventSpy).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(fetchCachedMyProfileEventSpy).toHaveBeenCalledWith(10002);
expect(fetchCachedMyProfileEventSpy).toHaveBeenCalledWith(2);
expect(mockEventProcessor).toBeCalledTimes(2);
});
});
@@ -215,7 +215,7 @@ describe('', () => {
});
await fetchMyProfileEvents(SampleEvents.kind0.pubkey, mockEventProcessor);
};
test('1 write relays, function called with custom relay and 2 default relays', async () => {
test('1 relay, function called with custom relay and 2 default relays', async () => {
storeMyProfileEvent({
...SampleEvents.kind10002,
tags: [
@@ -234,7 +234,7 @@ describe('', () => {
expect.anything(),
);
});
test('2 write relays function called with custom relays and 1 default relays', async () => {
test('2 relays function called with custom relays and 1 default relays', async () => {
storeMyProfileEvent({
...SampleEvents.kind10002,
tags: [
@@ -254,7 +254,7 @@ describe('', () => {
expect.anything(),
);
});
test('2 write relays including first defauly relay. function called with custom relays and 1 different default relays', async () => {
test('2 relays including first default relay. function called with custom relays and 1 different default relays', async () => {
storeMyProfileEvent({
...SampleEvents.kind10002,
tags: [
@@ -274,13 +274,13 @@ describe('', () => {
expect.anything(),
);
});
test('with 4 write relays function called with all custom relays', async () => {
test('with 4 relays function called with all custom relays', async () => {
storeMyProfileEvent({
...SampleEvents.kind10002,
tags: [
['r', 'wss://alicerelay.example.com'],
['r', 'wss://brando-relay.com'],
['r', 'wss://expensive-relay.example2.com', 'write'],
['r', 'wss://expensive-relay.example2.com', 'read'],
['r', 'wss://alicerelay.example3.com'],
],
});
@@ -297,28 +297,6 @@ describe('', () => {
expect.anything(),
);
});
test('custom read relays ignored', async () => {
storeMyProfileEvent({
...SampleEvents.kind10002,
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'],
],
});
await doBefore();
expect(mockrequestEventsFromRelays).toBeCalledWith(
expect.anything(),
expect.anything(),
[
'wss://alicerelay.example.com',
'wss://brando-relay.com',
'wss://expensive-relay.example2.com',
],
expect.anything(),
);
});
});
describe('and when no cached 10002 events are present and none are return', () => {
const mockstoreMyProfileEvent = jest.spyOn(FetchEvents, 'storeMyProfileEvent');

View File

@@ -71,7 +71,7 @@ export const storeMyProfileEvent = (event:Event): boolean => {
return true;
};
export const fetchCachedProfileEventHistory = (
export const fetchCachedMyProfileEventHistory = (
kind: 0 | 2 | 10002 | 3,
): null | [Event, ...Event[]] => {
// get data from local storage
@@ -85,19 +85,19 @@ export const fetchCachedProfileEventHistory = (
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);
export const fetchCachedMyProfileEvent = (kind: 0 | 2 | 10002 | 3): null | Event => {
const a = fetchCachedMyProfileEventHistory(kind);
if (a === null) return null;
// return Event in array with most recent created_at date
return a[0];
};
const getRelays = () => {
const e = fetchCachedProfileEvent(10002);
const mywriterelays = !e ? [] : e.tags.filter((r) => !r[2] || r[2] === 'write').map((r) => r[1]);
const e = fetchCachedMyProfileEvent(10002);
const myrelays = !e ? [] : e.tags.map((r) => r[1]);
// return minimum of 3 relays, filling in with default relays (removing duplicates)
return mywriterelays.length > 3 ? mywriterelays : [...new Set([
...mywriterelays,
return myrelays.length > 3 ? myrelays : [...new Set([
...myrelays,
'wss://relay.damus.io',
'wss://nostr-pub.wellorder.net',
'wss://nostr-relay.wlvs.space',
@@ -118,7 +118,7 @@ export const fetchMyProfileEvents = async (
}, starterrelays, [0, 2, 10002, 3]);
// if new 10002 event found with more write relays
if (
fetchCachedProfileEvent(10002)?.tags
fetchCachedMyProfileEvent(10002)?.tags
.some((t) => starterrelays.indexOf(t[1]) === -1 && (!t[2] || t[2] === 'write'))
) {
// fetch events again to ensure we got all my profile events
@@ -129,12 +129,169 @@ export const fetchMyProfileEvents = async (
} else {
// for kinds 0, 2, 10002 and 3
[0, 2, 10002, 3].forEach((k) => {
const e = fetchCachedProfileEvent(k as 0 | 2 | 10002 | 3);
const e = fetchCachedMyProfileEvent(k as 0 | 2 | 10002 | 3);
if (e !== null) profileEventProcesser(e);
});
}
};
const UserProfileEvents:{
[pubkey: string]: {
[kind: number]: Event;
};
} = {};
const storeProfileEvent = (event:Event) => {
if (!UserProfileEvents[event.pubkey]) UserProfileEvents[event.pubkey] = {};
if (
// no event of kind for pubkey
!UserProfileEvents[event.pubkey][event.kind]
// newer event of kind recieved
|| UserProfileEvents[event.pubkey][event.kind].created_at < event.created_at
) {
// store it
UserProfileEvents[event.pubkey][event.kind] = event;
}
};
export const fetchMyContactsProfileEvents = async () => {
const c = fetchCachedMyProfileEvent(3);
if (!c || c.tags.length === 0) return;
const required = c.tags.filter((p) => !UserProfileEvents[p[1]]);
if (required.length > 0) {
await requestEventsFromRelays(
required.map((t) => t[1]),
storeProfileEvent,
getRelays(),
[0, 10002, 3],
);
// TODO: check 10002 events and ensure we have read for one of their write relays
}
};
export const fetchCachedProfileEvent = (pubkey:string, kind:0 | 10002 | 3):Event | null => {
if (localStorageGetItem('pubkey') === pubkey) return fetchCachedMyProfileEvent(kind);
if (!UserProfileEvents[pubkey]) return null;
if (!UserProfileEvents[pubkey][kind]) return null;
return UserProfileEvents[pubkey][kind];
};
export const fetchAllCachedProfileEvents = (
kind: 0 | 10002 | 3,
):Event[] => Object.keys(UserProfileEvents)
.filter((p) => !!UserProfileEvents[p][kind])
.map((p) => UserProfileEvents[p][kind]);
export const fetchProfileEvents = async (
pubkeys:[string, ...string[]],
kind:0 | 10002 | 3,
relays?: string[] | null,
):Promise<[(Event | null), ...(Event | null)[]]> => {
const notcached = pubkeys.filter((p) => !fetchCachedProfileEvent(p, kind));
if (notcached.length > 0) {
await requestEventsFromRelays(
notcached,
storeProfileEvent,
relays || getRelays(),
[0, 10002, 3],
);
}
return pubkeys.map(
(p) => fetchCachedProfileEvent(p, kind),
) as [(Event | null), ...(Event | null)[]];
};
export const fetchProfileEvent = async (
pubkey:string,
kind:0 | 10002 | 3,
relays?: string[] | null,
):Promise<Event | null> => {
const r = await fetchProfileEvents([pubkey], kind, relays);
return r[0];
};
export const getContactMostPopularPetname = (pubkey: string):string | null => {
// considered implementing frank.david.erin model in nip-02 but I think the UX is to confusing
// get count of petnames for users by other contacts
const petnamecounts: { [petname: string]: number } = Object.keys(UserProfileEvents)
// returns petname or null
.map((pk) => {
if (!UserProfileEvents[pk][3]) return null;
const petnametag = UserProfileEvents[pk][3].tags.find((t) => t[1] === pubkey && t[3]);
if (petnametag) return petnametag[3];
return null;
})
// returns petname counts
.reduce((pv, c) => {
if (!c) return pv;
if (!pv[c]) return { ...pv, [c]: 1 };
return { ...pv, [c]: pv[c] + 1 };
}, {} as { [petname: string]: number });
if (petnamecounts.length === 0) return null;
// returns most frequent petname for user amoung contacts (appended with ' (?)')
return Object.keys(petnamecounts).sort((a, b) => petnamecounts[b] - petnamecounts[a])[0];
};
export const getMyPetnameForUser = (pubkey: string): string | null => {
const e = fetchCachedMyProfileEvent(3);
if (e) {
const mypetname = e.tags.find((t) => t[1] === pubkey && t[3]);
if (mypetname) return mypetname[3];
}
return null;
};
export const getMyRelayForUser = (pubkey: string): string | null => {
const e = fetchCachedMyProfileEvent(3);
if (e) {
const relay = e.tags.find((t) => t[1] === pubkey && t[2] && t[2] !== '');
if (relay) return relay[2];
}
return null;
};
export const isUserMyContact = (pubkey: string): boolean | null => {
const e = fetchCachedMyProfileEvent(3);
if (e) {
if (e.tags.some((t) => t[1] === pubkey)) return true;
return false;
}
return null;
};
export const getContactName = (pubkey: string):string => {
// my own name
if (localStorageGetItem('pubkey') === pubkey) {
const m = fetchCachedMyProfileEvent(0);
if (m) {
const { name } = JSON.parse(m.content);
if (name) return name;
}
} else {
// my petname for contact
const mypetname = getMyPetnameForUser(pubkey);
if (mypetname) return mypetname;
// TODO: what about displaying a common petname in brackets if vastly different from their name?
// their kind 0 name
if (UserProfileEvents[pubkey]) {
if (UserProfileEvents[pubkey][0]) {
const { name } = JSON.parse(UserProfileEvents[pubkey][0].content);
if (name) return name;
}
}
}
// most popular petname for user amoung contacts
const popularpetname = getContactMostPopularPetname(pubkey);
if (popularpetname) return `${popularpetname} (?)`;
// return shortened pubkey
/**
* 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 `${pubkey.substring(0, 10)}...`;
};
export const publishEvent = async (event:Event):Promise<boolean> => {
const r = await publishEventToRelay(
event,

View File

@@ -1,6 +1,6 @@
import { Event, UnsignedEvent } from 'nostr-tools';
import { generateLogoHero, LoadProfileHome } from './LoadProfileHome';
import { fetchMyProfileEvents } from './fetchEvents';
import { fetchMyContactsProfileEvents, fetchMyProfileEvents } from './fetchEvents';
import { localStorageGetItem, localStorageSetItem } from './LocalStorage';
import { LoadMetadataPage } from './LoadMetadataPage';
import LoadContactsPage from './LoadContactsPage';
@@ -31,6 +31,8 @@ const loadProfile = async () => {
(document.getElementById('navmetadata') as HTMLElement).onclick = LoadMetadataPage;
(document.getElementById('navcontacts') as HTMLElement).onclick = LoadContactsPage;
(document.getElementById('navrelays') as HTMLElement).onclick = LoadRelaysPage;
// get events from my contacts
await fetchMyContactsProfileEvents();
};
const LoadLandingPage = () => {

View File

@@ -71,4 +71,41 @@ img[src=""] {
input[type="text"] {
margin-top: 17px;
}
}
.hide { display:none;}
#contactdetails {
img {
display:inline-block;
max-width: 20%;
padding-right: 16px;
vertical-align:top;
}
.contactdetailsmain {
display:inline-block;
width:75%;
}
.contact-form-buttons {
margin-top: 34px;
}
}
#searchinput { margin-top: 16px; }
#searchresults ul {
padding: 0;
margin: 0;
li {
list-style: none;
margin:0;
width:30%;
display: inline-block;
}
img {
max-width: 75px;
border-radius: 38px;
float: left;
margin: 10px 10px 10px 0;
}
}