You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
239 lines
7.3 KiB
TypeScript
239 lines
7.3 KiB
TypeScript
import {Event, nip19, signEvent} from 'nostr-tools';
|
|
import {elem} from './utils/dom';
|
|
import {dateTime} from './utils/time';
|
|
import {isNotNonceTag, isPTag} from './events';
|
|
import {getViewContent, getViewElem, getViewOptions, setViewElem} from './view';
|
|
import {powEvent} from './system';
|
|
import {config} from './settings';
|
|
import {getMetadata} from './profiles';
|
|
import {publish} from './relays';
|
|
import {parseJSON} from './media';
|
|
|
|
const contactHistoryMap: {
|
|
[pubkey: string]: Event[];
|
|
} = {};
|
|
|
|
/**
|
|
* returns true if user is following pubkey
|
|
*/
|
|
export const isFollowing = (id: string) => {
|
|
const following = contactHistoryMap[config.pubkey]?.at(0);
|
|
if (!following) {
|
|
return false;
|
|
}
|
|
return following.tags.some(([tag, value]) => tag === 'p' && value === id);
|
|
};
|
|
|
|
export const updateFollowBtn = (pubkey: string) => {
|
|
const followBtn = getViewElem('followBtn');
|
|
if (followBtn) {
|
|
const hasContact = isFollowing(pubkey);
|
|
followBtn.textContent = hasContact ? 'unfollow' : 'follow';
|
|
followBtn.classList.remove('primary', 'secondary');
|
|
followBtn.classList.add(hasContact ? 'secondary' : 'primary');
|
|
followBtn.hidden = false;
|
|
}
|
|
};
|
|
|
|
const updateFollowing = (evt: Event) => {
|
|
const view = getViewOptions();
|
|
if (evt.pubkey === config.pubkey) {
|
|
localStorage.setItem('follwing', JSON.stringify(evt));
|
|
}
|
|
if (view.type === 'profile') {
|
|
updateFollowBtn(view.id);
|
|
if (view.id === evt.pubkey) {
|
|
// update following link
|
|
const following = getViewElem('following') as HTMLElement;
|
|
if (following) {
|
|
const count = evt.tags.filter(isPTag).length;
|
|
const anchor = elem('a', {
|
|
data: {following: evt.pubkey},
|
|
href: `/${evt.id}`,
|
|
title: dateTime.format(evt.created_at * 1000),
|
|
}, [
|
|
'following ',
|
|
elem('span', {className: 'highlight'}, count),
|
|
]);
|
|
following.replaceWith(anchor);
|
|
setViewElem('following', anchor);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
export const setContactList = (evt: Event) => {
|
|
const contactHistory = contactHistoryMap[evt.pubkey];
|
|
if (!contactHistory) {
|
|
contactHistoryMap[evt.pubkey] = [evt];
|
|
updateFollowing(evt);
|
|
return;
|
|
}
|
|
if (contactHistory.find(({id}) => id === evt.id)) {
|
|
return;
|
|
}
|
|
contactHistory.unshift(evt);
|
|
updateFollowing(contactHistory[0]); // TODO: ensure that this is newest contactlist?
|
|
};
|
|
|
|
/**
|
|
* findChanges
|
|
* returns added and removed contacts list of P tags, ignores any tag other than 'p'
|
|
*/
|
|
const findChanges = (current: Event, previous: Event) => {
|
|
const previousContacts = previous.tags.join('\n'); // filter for p tags first?
|
|
const currentContacts = current.tags.join('\n');
|
|
const addedContacts = current.tags.filter(([tag, pubkey]) => tag === 'p' && !previousContacts.includes(pubkey));
|
|
const removedContacts = previous.tags.filter(([tag, pubkey]) => tag === 'p' && !currentContacts.includes(pubkey));
|
|
return [addedContacts, removedContacts];
|
|
};
|
|
|
|
export const resetContactList = (pubkey: string) => {
|
|
delete contactHistoryMap[pubkey];
|
|
};
|
|
|
|
export const getContactUpdateMessage = (
|
|
addedList: string[][],
|
|
removedList: string[][],
|
|
) => {
|
|
const content = [];
|
|
if (addedList.length && addedList[0]) {
|
|
const pubkey = addedList[0][1];
|
|
const {userName} = getMetadata(pubkey);
|
|
const npub = nip19.npubEncode(pubkey);
|
|
content.push(
|
|
'follows ',
|
|
elem('a', {href: `/${npub}`, data: {profile: pubkey}}, userName),
|
|
);
|
|
}
|
|
if (addedList.length > 1) {
|
|
content.push(` (+ ${addedList.length - 1} others)`);
|
|
}
|
|
if (removedList?.length > 0) {
|
|
if (content.length) {
|
|
content.push(' and');
|
|
}
|
|
content.push(' unfollowed ');
|
|
if (removedList.length > 1) {
|
|
content.push(`${removedList.length}`);
|
|
} else {
|
|
const removedPubkey = removedList[0][1];
|
|
const {userName: removeduserName} = getMetadata(removedPubkey);
|
|
const removedNpub = nip19.npubEncode(removedPubkey);
|
|
content.push(elem('a', {href: `/${removedNpub}`, data: {profile: removedPubkey}}, removeduserName));
|
|
}
|
|
}
|
|
return content;
|
|
};
|
|
|
|
export const updateContactList = (evt: Event) => {
|
|
const contactHistory = contactHistoryMap[evt.pubkey];
|
|
if (contactHistory.length === 1) {
|
|
return [contactHistory[0].tags.filter(isPTag)];
|
|
}
|
|
const pos = contactHistory.findIndex(({id}) => id === evt.id);
|
|
if (evt.id !== contactHistory.at(-1)?.id) { // not oldest known contact-list update
|
|
return findChanges(evt, contactHistory[pos + 1]);
|
|
}
|
|
// update existing contact entries
|
|
contactHistory
|
|
.slice(0, -1)
|
|
.forEach((entry, i) => {
|
|
const previous = contactHistory[i + 1];
|
|
const [added, removed] = findChanges(entry, previous);
|
|
const contactNote = getViewContent().querySelector(`[data-contacts="${entry.id}"]`);
|
|
const updated = getContactUpdateMessage(added, removed);
|
|
contactNote?.replaceChildren(...updated);
|
|
});
|
|
return [evt.tags.filter(isPTag)];
|
|
};
|
|
|
|
export const getContacts = () => {
|
|
const following = contactHistoryMap[config.pubkey]?.at(0); // TODO: ensure newest contactlist
|
|
if (following) {
|
|
return following.tags
|
|
.filter(isPTag)
|
|
.map(([, pubkey]) => pubkey);
|
|
}
|
|
const followingFromStorage = localStorage.getItem('follwing');
|
|
if (followingFromStorage) {
|
|
const follwingData = parseJSON(followingFromStorage) as Event;
|
|
// TODO: ensure signature matches
|
|
if (follwingData && follwingData.pubkey === config.pubkey) {
|
|
return follwingData.tags
|
|
.filter(isPTag)
|
|
.map(([, pubkey]) => pubkey);
|
|
}
|
|
}
|
|
return [];
|
|
};
|
|
|
|
const updateContactTags = (
|
|
followeeID: string,
|
|
currentContactList: Event | undefined,
|
|
) => {
|
|
if (!currentContactList?.tags) {
|
|
return [['p', followeeID], ['p', config.pubkey]];
|
|
}
|
|
if (currentContactList.tags.some(([tag, id]) => tag === 'p' && id === followeeID)) {
|
|
return currentContactList.tags
|
|
.filter(([tag, id]) => tag === 'p' && id !== followeeID);
|
|
}
|
|
return [
|
|
['p', followeeID],
|
|
...currentContactList.tags
|
|
.filter(isNotNonceTag),
|
|
];
|
|
};
|
|
|
|
export const followContact = async (id: string) => {
|
|
const followBtn = getViewElem('followBtn') as HTMLButtonElement;
|
|
const statusElem = getViewElem('followStatus') as HTMLElement;
|
|
if (!followBtn || !statusElem) {
|
|
return;
|
|
}
|
|
const following = contactHistoryMap[config.pubkey]?.at(0);
|
|
const unsignedEvent = {
|
|
kind: 3,
|
|
pubkey: config.pubkey,
|
|
content: '',
|
|
tags: updateContactTags(id, following),
|
|
created_at: Math.floor(Date.now() * 0.001),
|
|
};
|
|
|
|
followBtn.disabled = true;
|
|
const newContactListEvent = await powEvent(unsignedEvent, {
|
|
difficulty: config.difficulty,
|
|
statusElem,
|
|
timeout: config.timeout,
|
|
}).catch(console.warn);
|
|
|
|
if (!newContactListEvent) {
|
|
statusElem.textContent = '';
|
|
statusElem.hidden = false;
|
|
followBtn.disabled = false;
|
|
return;
|
|
}
|
|
const privatekey = localStorage.getItem('private_key');
|
|
if (!privatekey) {
|
|
statusElem.textContent = 'no private key to sign';
|
|
statusElem.hidden = false;
|
|
followBtn.disabled = false;
|
|
return;
|
|
}
|
|
const sig = signEvent(newContactListEvent, privatekey);
|
|
// TODO: validateEvent?
|
|
if (sig) {
|
|
statusElem.textContent = 'publishing…';
|
|
publish({...newContactListEvent, sig}, (relay, error) => {
|
|
if (error) {
|
|
return console.error(error, relay);
|
|
}
|
|
statusElem.hidden = true;
|
|
followBtn.disabled = false;
|
|
console.info(`event published by ${relay}`);
|
|
});
|
|
}
|
|
|
|
};
|