diff --git a/readme.md b/readme.md index 20ca115..c2e0f60 100644 --- a/readme.md +++ b/readme.md @@ -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) diff --git a/src/LoadContactsPage.ts b/src/LoadContactsPage.ts index 5978624..132bf9a 100644 --- a/src/LoadContactsPage.ts +++ b/src/LoadContactsPage.ts @@ -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 ` +
  • + + ${getContactName(pubkey)} + +
  • + `; +}; + +const generateMicroCardList = (eventorpubkeys:(Event | string)[]):string => ` + +`; + +const generateContactDetails = (pubkey:string):string => { + const e = fetchCachedProfileEvent(pubkey, 0); + if (!e) { + return ` +
    + ${getContactName(pubkey)} +

    loading users metadata...

    +
    + `; + } + const m = JSON.parse(e.content); + const otherspetname = getContactMostPopularPetname(pubkey); + const ismycontact = isUserMyContact(pubkey); + return ` +
    +
    + ${m && !!m.picture ? ` ` : ''} +
    + ${m.name ? m.name : '[unknown name]'} + ${m.nip05 ? `${m.nip05} ` : ''} + ${otherspetname && otherspetname !== m.name ? `
    popular petname: ${otherspetname}
    ` : ''} +
    ${m.about ? m.about : ''}
    +
    +
    + +
    + `; +}; + +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 = '✔ verified'; + else verifiedel.innerHTML = '✔ verified'; + } + }; + 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 => { + const s = input.value; + const searchstatus = document.getElementById('searchstatus') as HTMLDivElement; + const setLoading = () => { searchstatus.innerHTML = '

    Searching nip05...

    '; }; + // 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 ` +

    +
    +
    My Contacts
    + ${generateMicroCardList(mycontacts)} +
    +
    +
    + `; +}; + +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 = '

    not a verified nip05 address

    '; + else { + searchstatus.innerHTML = '

    Verified nip05 address. loading profile...

    '; + 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 = ` +
    + +
    +
    +
    + `; + setSearchInputOnChangeEvent(); + refreshResults(); +}; const LoadContactsPage = () => { const o:HTMLElement = document.getElementById('PM-container') as HTMLElement; o.innerHTML = `
    +
    `; loadBackupHistory('contactsbackuphistory', 3); + loadViewContacts('viewcontacts'); }; export default LoadContactsPage; diff --git a/src/LoadHistory.ts b/src/LoadHistory.ts index df996fe..1d45ea9 100644 --- a/src/LoadHistory.ts +++ b/src/LoadHistory.ts @@ -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 `${a[3]}`; - return `${(a[1]).substring(0, 10)}...`; - /** - * 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 `${npubEncode(a[1]).substring(0, 10)}...`; + return `${(a[1]).substring(0, 10)}...`; }; 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 = `

    Backup History

    ${table}`; diff --git a/src/LoadMetadataPage.ts b/src/LoadMetadataPage.ts index aafd0f1..c5d35a0 100644 --- a/src/LoadMetadataPage.ts +++ b/src/LoadMetadataPage.ts @@ -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 = `
    diff --git a/src/LoadProfileHome.ts b/src/LoadProfileHome.ts index 525dae6..733e989 100644 --- a/src/LoadProfileHome.ts +++ b/src/LoadProfileHome.ts @@ -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 = () => {
    ${noprofileinfo ? generateLogoHero() : `
    - ${generateMetadataHeader(fetchCachedProfileEvent(0) as Event)} + ${generateMetadataHeader(fetchCachedMyProfileEvent(0) as Event)}
    - ${generateMetadataSummary(fetchCachedProfileEvent(0), !uptodate)} - ${generateContactsSummary(fetchCachedProfileEvent(3), !uptodate)} - ${generateRelaysSummary(fetchCachedProfileEvent(10002), !uptodate)} + ${generateMetadataSummary(fetchCachedMyProfileEvent(0), !uptodate)} + ${generateContactsSummary(fetchCachedMyProfileEvent(3), !uptodate)} + ${generateRelaysSummary(fetchCachedMyProfileEvent(10002), !uptodate)}
    `}
    ${generateBackupHeroHeading(uptodate, noprofileinfo, hadlatest)}
    @@ -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)}`); diff --git a/src/LoadRelaysPage.ts b/src/LoadRelaysPage.ts index b3c400a..8feebf3 100644 --- a/src/LoadRelaysPage.ts +++ b/src/LoadRelaysPage.ts @@ -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 = `

    Relays

    - ${generateRelayForm(fetchCachedProfileEvent(10002) as Kind10002Event)} + ${generateRelayForm(fetchCachedMyProfileEvent(10002) as Kind10002Event)}
    `; // form submit event (document.getElementById('relayssubmitbutton') as HTMLButtonElement).onclick = (event) => { diff --git a/src/fetchEvents.test.ts b/src/fetchEvents.test.ts index 4906d9b..25fa3e2 100644 --- a/src/fetchEvents.test.ts +++ b/src/fetchEvents.test.ts @@ -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'); diff --git a/src/fetchEvents.ts b/src/fetchEvents.ts index e26e1a1..9847f7c 100644 --- a/src/fetchEvents.ts +++ b/src/fetchEvents.ts @@ -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 => { + 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 => { const r = await publishEventToRelay( event, diff --git a/src/index.ts b/src/index.ts index 820759f..51eef8b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 = () => { diff --git a/src/style.scss b/src/style.scss index 567d4ee..941096a 100644 --- a/src/style.scss +++ b/src/style.scss @@ -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; + } } \ No newline at end of file