mirror of
https://github.com/aljazceru/nostr-profile-manager.git
synced 2025-12-17 05:34:20 +01:00
345 lines
12 KiB
TypeScript
345 lines
12 KiB
TypeScript
import { Event, UnsignedEvent } from 'nostr-tools';
|
|
import { sanitize } from 'isomorphic-dompurify';
|
|
import { localStorageGetItem, localStorageSetItem } from './LocalStorage';
|
|
import { publishEventToRelay, requestEventsFromRelays } from './RelayManagement';
|
|
|
|
export const lastFetchDate = ():number | null => {
|
|
const d = localStorageGetItem('my-profile-last-fetch-date');
|
|
if (d === null) return null;
|
|
return Number(d);
|
|
};
|
|
let fetchedthissession: boolean = false;
|
|
export const updateLastFetchDate = ():void => {
|
|
fetchedthissession = true;
|
|
localStorageSetItem('my-profile-last-fetch-date', Date.now().toString());
|
|
};
|
|
|
|
export const lastUpdateDate = ():number | null => {
|
|
const d = localStorageGetItem('my-profile-last-update-date');
|
|
if (d === null) return null;
|
|
return Number(d);
|
|
};
|
|
|
|
export const updateLastUpdateDate = ():void => {
|
|
localStorageSetItem('my-profile-last-update-date', Date.now().toString());
|
|
};
|
|
|
|
export const isUptodate = ():boolean => fetchedthissession;
|
|
// const f = lastFetchDate();
|
|
// // uptodate - fetched within 10 seconds
|
|
// return !(f === null || f < (Date.now() - 10000));
|
|
|
|
export const hadLatest = ():boolean => {
|
|
if (!isUptodate()) return false;
|
|
const f = lastFetchDate();
|
|
const u = lastUpdateDate();
|
|
// hadlatest - last update was no more than 10 seconds before fetch complete
|
|
return !(u === null || f === null || u > (f - 10000));
|
|
};
|
|
|
|
/**
|
|
* storeMyProfileEvent
|
|
* @returns true if stored and false duplicate, wrong kind or wrong pubkey
|
|
*/
|
|
export const storeMyProfileEvent = (event:Event): boolean => {
|
|
// thrown on no pubkey in localStorage
|
|
if (localStorageGetItem('pubkey') === null) {
|
|
throw new Error('storeMyProfileEvent no pubkey in localStorage');
|
|
}
|
|
// return false if...
|
|
if (
|
|
// event is of an unsupported kind
|
|
!(event.kind === 0 || event.kind === 2 || event.kind === 10002 || event.kind === 3)
|
|
// or fron a different pubkey
|
|
|| event.pubkey !== localStorageGetItem('pubkey')
|
|
) return false;
|
|
|
|
const arrayname = `my-profile-event-${event.kind}`;
|
|
const ls = localStorageGetItem(arrayname);
|
|
// if localStorage my-profile-event-[kind] doesnt exist, create it with new event in.
|
|
if (ls === null) localStorageSetItem(arrayname, JSON.stringify([event]));
|
|
else {
|
|
const a = JSON.parse(ls) as Event[];
|
|
// if event is already stored return false
|
|
if (a.some((e) => e.id === event.id)) return false;
|
|
// add event, store array
|
|
a.push(event);
|
|
localStorageSetItem(arrayname, JSON.stringify(a));
|
|
}
|
|
// update last updated date
|
|
updateLastUpdateDate();
|
|
// return true as event saved
|
|
return true;
|
|
};
|
|
|
|
export const fetchCachedMyProfileEventHistory = (
|
|
kind: 0 | 2 | 10002 | 3,
|
|
): null | [Event, ...Event[]] => {
|
|
// get data from local storage
|
|
const arrayname = `my-profile-event-${kind}`;
|
|
const ls = localStorageGetItem(arrayname);
|
|
// if no events are cached return null
|
|
if (ls === null) return null;
|
|
|
|
const a = JSON.parse(ls) as [Event, ...Event[]];
|
|
// return as Events array
|
|
return a.sort((x, y) => y.created_at - x.created_at);
|
|
};
|
|
|
|
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 = fetchCachedMyProfileEvent(10002);
|
|
const myrelays = !e ? [] : e.tags.map((r) => r[1]);
|
|
// return minimum of 3 relays, filling in with default relays (removing duplicates)
|
|
return myrelays.length > 3 ? myrelays : [...new Set([
|
|
...myrelays,
|
|
'wss://relay.damus.io',
|
|
'wss://nostr-pub.wellorder.net',
|
|
'wss://nostr-relay.wlvs.space',
|
|
])].slice(0, 3);
|
|
};
|
|
|
|
/** get my latest profile events either from cache (if isUptodate) or from relays */
|
|
export const fetchMyProfileEvents = async (
|
|
pubkey:string,
|
|
profileEventProcesser: (event: Event) => void,
|
|
): Promise<void> => {
|
|
// get events from relays, store them and run profileEventProcesser
|
|
if (!isUptodate()) {
|
|
const starterrelays = getRelays();
|
|
await requestEventsFromRelays([pubkey], (event: Event) => {
|
|
storeMyProfileEvent(event);
|
|
profileEventProcesser(event);
|
|
}, starterrelays, [0, 2, 10002, 3]);
|
|
// if new 10002 event found with more write relays
|
|
if (
|
|
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
|
|
await fetchMyProfileEvents(pubkey, profileEventProcesser);
|
|
}
|
|
// update last-fetch-from-relays date
|
|
updateLastFetchDate();
|
|
} else {
|
|
// for kinds 0, 2, 10002 and 3
|
|
[0, 2, 10002, 3].forEach((k) => {
|
|
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];
|
|
};
|
|
|
|
/** returns sanatized most popular petname for contact */
|
|
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 sanitize(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 sanitize(
|
|
Object.keys(petnamecounts).sort((a, b) => petnamecounts[b] - petnamecounts[a])[0],
|
|
);
|
|
};
|
|
|
|
/** returns my petname for user but sanatized */
|
|
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 sanitize(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;
|
|
};
|
|
|
|
/** get sanatized contact name */
|
|
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 sanitize(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]) {
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
const { name, display_name } = JSON.parse(UserProfileEvents[pubkey][0].content);
|
|
if (name) return sanitize(name);
|
|
// name isn't present for Jack Dorsey and Vitor from Amethyst in Apr 2023.
|
|
if (display_name) return sanitize(display_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,
|
|
[
|
|
...getRelays(),
|
|
'wss://nostr.mutinywallet.com', // blastr
|
|
],
|
|
);
|
|
if (r) storeMyProfileEvent(event);
|
|
return r;
|
|
};
|
|
/**
|
|
* @param e event for signing and publishing
|
|
* @param ElementId id of button or anchor element that was used for submitting the event
|
|
* @param innerHTMLAfterSuccess what the button should read after event successfully submitted
|
|
* @returns true if event was published, false if it was not
|
|
*/
|
|
export const submitUnsignedEvent = async (
|
|
e:UnsignedEvent,
|
|
ElementId:string,
|
|
innerHTMLAfterSuccess:string = 'Update',
|
|
):Promise<boolean> => {
|
|
const b = document.getElementById(ElementId) as HTMLButtonElement | HTMLAnchorElement;
|
|
// set loading status
|
|
b.setAttribute('disabled', '');
|
|
b.setAttribute('aria-busy', 'true');
|
|
b.innerHTML = 'Signing...';
|
|
// sign event
|
|
if (!window.nostr) return new Promise((r) => { r(false); });
|
|
const ne = await window.nostr.signEvent(e);
|
|
// publish
|
|
b.innerHTML = 'Sending...';
|
|
const r = await publishEvent(ne);
|
|
b.removeAttribute('aria-busy');
|
|
b.innerHTML = 'Recieved by Relays!';
|
|
setTimeout(() => {
|
|
b.innerHTML = innerHTMLAfterSuccess;
|
|
b.removeAttribute('disabled');
|
|
}, 1000);
|
|
return r;
|
|
};
|