contact: add follow and unfollow support

this creates a kind 3 event that includes a list of profiles that
the user is following.

the feed is still the public global feed and individual feed with
only events from followed pubkeys will be added in the next commit.
OFF0 1 year ago
parent 208ea6363a
commit fb1093624d
Signed by: offbyn
GPG Key ID: 94A2F643C51F37FA

@ -1,29 +1,69 @@
import {Event, nip19} from 'nostr-tools'; import {Event, nip19, signEvent} from 'nostr-tools';
import {elem} from './utils/dom'; import {elem} from './utils/dom';
import {dateTime} from './utils/time'; import {dateTime} from './utils/time';
import {isPTag, sortByCreatedAt} from './events'; import {isNotNonceTag, isPTag} from './events';
import {getViewContent} from './view'; import {getViewContent, getViewElem, getViewOptions, setViewElem} from './view';
import {powEvent} from './system';
import {config} from './settings';
import {getMetadata} from './profiles'; import {getMetadata} from './profiles';
import {publish} from './relays';
import {parseJSON} from './media';
const contactHistoryMap: { const contactHistoryMap: {
[pubkey: string]: Event[] [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 updateFollowing = (evt: Event) => {
const following = getViewContent().querySelector(`[data-following="${evt.pubkey}"]`); const view = getViewOptions();
if (following) { if (evt.pubkey === config.pubkey) {
const count = evt.tags.filter(isPTag).length; localStorage.setItem('follwing', JSON.stringify(evt));
const anchor = elem('a', { }
data: {following: evt.pubkey}, if (view.type === 'profile') {
href: `/${evt.id}`, updateFollowBtn(view.id);
title: dateTime.format(new Date(evt.created_at * 1000)), if (view.id === evt.pubkey) {
}, `following ${count}`); // update following link
following.replaceWith(anchor); 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) => { export const setContactList = (evt: Event) => {
let contactHistory = contactHistoryMap[evt.pubkey]; const contactHistory = contactHistoryMap[evt.pubkey];
if (!contactHistory) { if (!contactHistory) {
contactHistoryMap[evt.pubkey] = [evt]; contactHistoryMap[evt.pubkey] = [evt];
updateFollowing(evt); updateFollowing(evt);
@ -32,9 +72,8 @@ export const setContactList = (evt: Event) => {
if (contactHistory.find(({id}) => id === evt.id)) { if (contactHistory.find(({id}) => id === evt.id)) {
return; return;
} }
contactHistory.push(evt); contactHistory.unshift(evt);
contactHistory.sort(sortByCreatedAt); updateFollowing(contactHistory[0]); // TODO: ensure that this is newest contactlist?
updateFollowing(contactHistory[0]);
}; };
/** /**
@ -49,26 +88,8 @@ const findChanges = (current: Event, previous: Event) => {
return [addedContacts, removedContacts]; return [addedContacts, removedContacts];
}; };
export const updateContactList = (evt: Event) => { export const resetContactList = (pubkey: string) => {
const contactHistory = contactHistoryMap[evt.pubkey]; delete contactHistoryMap[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) { // oldest known contact-list update
// 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)];
}
return findChanges(evt, contactHistory[pos + 1]);
}; };
export const getContactUpdateMessage = ( export const getContactUpdateMessage = (
@ -76,7 +97,6 @@ export const getContactUpdateMessage = (
removedList: string[][], removedList: string[][],
) => { ) => {
const content = []; const content = [];
// console.log(addedContacts)
if (addedList.length && addedList[0]) { if (addedList.length && addedList[0]) {
const pubkey = addedList[0][1]; const pubkey = addedList[0][1];
const {userName} = getMetadata(pubkey); const {userName} = getMetadata(pubkey);
@ -90,7 +110,129 @@ export const getContactUpdateMessage = (
content.push(` (+ ${addedList.length - 1} others)`); content.push(` (+ ${addedList.length - 1} others)`);
} }
if (removedList?.length > 0) { if (removedList?.length > 0) {
content.push(elem('small', {}, ` and unfollowed ${removedList.length}`)); 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; 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}`);
});
}
};

@ -1,9 +1,11 @@
import {Event} from 'nostr-tools'; import {Event} from 'nostr-tools';
import {zeroLeadingBitsCount} from './utils/crypto'; import {zeroLeadingBitsCount} from './utils/crypto';
export const isEvent = <T>(evt?: T): evt is T => evt !== undefined;
export const isMention = ([tag, , , marker]: string[]) => tag === 'e' && marker === 'mention'; export const isMention = ([tag, , , marker]: string[]) => tag === 'e' && marker === 'mention';
export const isPTag = ([tag]: string[]) => tag === 'p'; export const isPTag = ([tag]: string[]) => tag === 'p';
export const hasEventTag = (tag: string[]) => tag[0] === 'e'; export const hasEventTag = (tag: string[]) => tag[0] === 'e';
export const isNotNonceTag = ([tag]: string[]) => tag !== 'nonce';
/** /**
* validate proof-of-work of a nostr event per nip-13. * validate proof-of-work of a nostr event per nip-13.

@ -10,7 +10,7 @@ import {clearView, getViewContent, getViewElem, getViewOptions, setViewElem, vie
import {handleReaction, handleUpvote} from './reactions'; import {handleReaction, handleUpvote} from './reactions';
import {closePublishView, openWriteInput, togglePublishView} from './write'; import {closePublishView, openWriteInput, togglePublishView} from './write';
import {handleMetadata, renderProfile} from './profiles'; import {handleMetadata, renderProfile} from './profiles';
import {getContactUpdateMessage, setContactList, updateContactList} from './contacts'; import {followContact, getContactUpdateMessage, setContactList, updateContactList} from './contacts';
import {EventWithNip19, EventWithNip19AndReplyTo, textNoteList, replyList} from './notes'; import {EventWithNip19, EventWithNip19AndReplyTo, textNoteList, replyList} from './notes';
import {createTextNote, renderEventDetails, renderRecommendServer, renderUpdateContact} from './ui'; import {createTextNote, renderEventDetails, renderRecommendServer, renderUpdateContact} from './ui';
@ -317,6 +317,9 @@ const handleButton = (button: HTMLButtonElement) => {
const note = replyList.find(r => r.id === id) || textNoteList.find(n => n.id === (id)); const note = replyList.find(r => r.id === id) || textNoteList.find(n => n.id === (id));
note && handleUpvote(note); note && handleUpvote(note);
break; break;
case 'follow':
followContact(id);
break;
} }
} }
// const container = e.target.closest('[data-append]'); // const container = e.target.closest('[data-append]');

@ -49,9 +49,6 @@ a.mbox-img:focus {
padding-bottom: var(--gap-half); padding-bottom: var(--gap-half);
word-break: break-word; word-break: break-word;
} }
.mbox-content a {
text-decoration: underline;
}
.mbox-img + .mbox-body { .mbox-img + .mbox-body {
--max-width: calc(100% - var(--profileimg-size) - var(--gap-half)); --max-width: calc(100% - var(--profileimg-size) - var(--gap-half));
flex-basis: var(--max-width); flex-basis: var(--max-width);
@ -66,28 +63,38 @@ a.mbox-img:focus {
display: flex; display: flex;
gap: var(--gap-quarter); gap: var(--gap-quarter);
justify-content: space-between; justify-content: space-between;
margin-bottom: .2rem; margin: .1rem 0;
margin-top: 0;
min-height: 1.8rem; min-height: 1.8rem;
} }
.mbox-header a { .mbox-header a {
font-size: var(--font-small); font-size: var(--font-small);
line-height: var(--lineheight-small);
text-decoration: none;
} }
.mbox-header small { .mbox-header small {
white-space: nowrap; white-space: nowrap;
} }
.mbox-username { .mbox-username {
font-weight: 600;
} }
.mbox-kind0-name { .mbox-kind0-name {
color: var(--color-accent); color: var(--color-accent);
} }
.mbox-updated-contact,
.mbox-recommend-server {
padding-bottom: var(--gap-quarter);
}
.mbox-updated-contact .mbox-body, .mbox-updated-contact .mbox-body,
.mbox-recommend-server .mbox-body { .mbox-recommend-server .mbox-body {
display: block; display: block;
font-size: var(--font-small); font-size: var(--font-small);
padding-bottom: var(--gap-quarter);
padding-top: 0;
}
.mbox-updated-contact + .mbox-updated-contact,
.mbox-recommend-server + .mbox-updated-contact {
padding-top: 0;
} }
.mbox-updated-contact .mbox-header, .mbox-updated-contact .mbox-header,

@ -130,6 +130,18 @@ button {
outline-offset: 1px; outline-offset: 1px;
word-break: normal; word-break: normal;
} }
button:active {
--bg-color: rgb(13, 74, 139);
--border-color: rgb(13, 74, 139);
}
.primary,
.secondary {
padding: .8rem 2.4rem;
}
.secondary {
background-color: transparent;
}
button:focus { button:focus {
} }

@ -16,6 +16,7 @@
--focus-outline-width: 2px; --focus-outline-width: 2px;
--focus-outline: var(--focus-outline-width) var(--focus-outline-style) var(--focus-outline-color); --focus-outline: var(--focus-outline-width) var(--focus-outline-style) var(--focus-outline-color);
--font-small: 1.2rem; --font-small: 1.2rem;
--lineheight-small: 1.5;
--gap: 2.4rem; --gap: 2.4rem;
--gap-half: 1.2rem; --gap-half: 1.2rem;
--gap-quarter: .6rem; --gap-quarter: .6rem;
@ -41,12 +42,16 @@
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
html { html {
--color: rgb(93, 93, 93); --color: rgb(43, 43, 43);
--color-accent: rgb(130, 130, 130); --color-accent: rgb(118, 118, 118);
--color-accent-line: rgb(163, 163, 163);
--color-danger: #0e0e0e; --color-danger: #0e0e0e;
--color-visited: #7467c4;
--color-visited-line: #9083e3;
--color-inverse: #fff;
--bgcolor: #fff; --bgcolor: #fff;
--bgcolor-nav: gainsboro; --bgcolor-nav: gainsboro;
--bgcolor-accent: #7badfc; --bgcolor-accent: #5194ff;
--bgcolor-danger: rgb(225, 40, 40); --bgcolor-danger: rgb(225, 40, 40);
--bgcolor-danger-input: rgba(255 255 255 / .85); --bgcolor-danger-input: rgba(255 255 255 / .85);
--bgcolor-inactive: #bababa; --bgcolor-inactive: #bababa;
@ -56,15 +61,19 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
html { html {
--color: #e3e3e3; --color: #d9d9d9;
--color-accent: #828282; --color-accent: #828282;
--color-accent-line: #737373;
--color-danger: #e3e3e3; --color-danger: #e3e3e3;
--color-visited: #796ae3;
--color-visited-line: #5d4fce;
--color-inverse: #101010;
--bgcolor: #101010; --bgcolor: #101010;
--bgcolor-nav: rgb(31, 22, 51); --bgcolor-nav: rgb(31, 22, 51);
--bgcolor-accent: rgb(16, 93, 176); --bgcolor-accent: rgb(16, 77, 176);
--bgcolor-danger: rgb(169, 0, 0); --bgcolor-danger: rgb(169, 0, 0);
--bgcolor-danger-input: rgba(0 0 0 / .5); --bgcolor-danger-input: rgba(0 0 0 / .5);
--bgcolor-inactive: #202122; --bgcolor-inactive: #353638;
--bgcolor-textinput: #0e0e0e; --bgcolor-textinput: #0e0e0e;
} }
@ -92,7 +101,7 @@ body {
@media (orientation: portrait) { @media (orientation: portrait) {
body { body {
font-size: 1.4rem; font-size: 1.4rem;
line-height: 1.5; line-height: 1.428571428571429;
} }
} }
@ -133,16 +142,25 @@ img {
a { a {
color: var(--color-accent); color: var(--color-accent);
text-decoration: none; text-decoration-color: var(--color-accent-line);
text-decoration-line: underline;
text-decoration-style: solid;
text-decoration-thickness: 2px;
} }
a:focus { a .highlight {
color: var(--color);
}
a:focus,
button:focus {
border-radius: var(--focus-border-radius); border-radius: var(--focus-border-radius);
outline: var(--focus-outline); outline: var(--focus-outline);
outline-offset: 0; outline-offset: 0;
} }
a:visited { a:visited {
color: #8377ce; color: var(--color-visited);
text-decoration-color: var(--color-visited-line);
} }
nav a:visited { nav a:visited {
color: inherit; color: inherit;
@ -166,6 +184,7 @@ dl {
} }
dt { dt {
color: var(--color-accent);
grid-column-start: 1; grid-column-start: 1;
} }

@ -135,16 +135,29 @@ nav a {
--extra-space: calc(var(--profileimg-size) + var(--gap-half)); --extra-space: calc(var(--profileimg-size) + var(--gap-half));
padding: var(--gap-half); padding: var(--gap-half);
} }
.hero-title {
.hero h1 { align-items: baseline;
display: flex;
flex-wrap: wrap;
gap: var(--gap-half);
justify-content: end;
max-width: var(--content-width);
}
.hero-title h1 {
flex-grow: 1;
margin-bottom: 0;
padding-left: var(--extra-space); padding-left: var(--extra-space);
} }
.hero-title button {
line-height: 1;
}
.hero p { .hero p {
max-width: calc(var(--content-width) - var(--extra-space));
padding-left: var(--extra-space); padding-left: var(--extra-space);
} }
.hero small { .hero .hero-npub {
color: var(--color-accent); color: var(--color-accent);
font-size: 1.1rem; font-size: 1.1rem;
display: block; display: block;
@ -155,7 +168,7 @@ nav a {
white-space: nowrap; white-space: nowrap;
} }
@media (min-width: 54ch) { @media (min-width: 54ch) {
.hero small { .hero .hero-npub {
padding-left: var(--extra-space); padding-left: var(--extra-space);
text-align: left; text-align: left;
} }
@ -164,3 +177,7 @@ nav a {
.hero footer { .hero footer {
padding-left: var(--extra-space); padding-left: var(--extra-space);
} }
.hero footer a {
text-decoration: none;
}

@ -194,9 +194,9 @@ export const subProfile = (
sub({ sub({
cb: onEvent, cb: onEvent,
filter: { filter: {
authors: [pubkey], authors: [pubkey, config.pubkey],
kinds: [3], kinds: [3],
limit: 3, limit: 6,
}, },
}); });
}, 100); }, 100);

@ -26,11 +26,19 @@ export const renderViewTemplate = (options: ViewTemplateOptions) => {
case 'profile': case 'profile':
const pubkey = options.id; const pubkey = options.id;
const detail = elem('p'); const detail = elem('p');
const followStatus = elem('small');
const followBtn = elem('button', {
className: 'primary',
name: 'follow',
data: {'id': options.id}
}, 'follow');
const following = elem('span'); const following = elem('span');
const profileHeader = elem('header', {className: 'hero'}, [ const profileHeader = elem('header', {className: 'hero'}, [
elem('small', {className: 'hero-npub'}, nip19.npubEncode(pubkey)), elem('small', {className: 'hero-npub'}, nip19.npubEncode(pubkey)),
elem('div', {className: 'hero-title'}, [ elem('div', {className: 'hero-title'}, [
elem('h1', {}, pubkey), elem('h1', {}, pubkey),
followStatus,
followBtn,
]), ]),
detail, detail,
elem('footer', {}, following), elem('footer', {}, following),
@ -38,6 +46,8 @@ export const renderViewTemplate = (options: ViewTemplateOptions) => {
dom.header = profileHeader; dom.header = profileHeader;
dom.detail = detail; dom.detail = detail;
dom.following = following; dom.following = following;
dom.followStatus = followStatus;
dom.followBtn = followBtn;
content.append(profileHeader); content.append(profileHeader);
document.title = pubkey; document.title = pubkey;
break; break;

@ -100,8 +100,6 @@ export const renderUpdateContact = (
' ', ' ',
elem('span', {data: {contacts: evt.id}, title: time.toISOString()}, evt.content), elem('span', {data: {contacts: evt.id}, title: time.toISOString()}, evt.content),
]), ]),
elem('div', {className: 'mbox-content'}, [
]),
]), ]),
], { ], {
className: 'mbox-updated-contact', className: 'mbox-updated-contact',
@ -133,13 +131,13 @@ export const renderEventDetails = (evt: Event, relay: string) => {
const {img, name, userName} = getMetadata(evt.pubkey); const {img, name, userName} = getMetadata(evt.pubkey);
const npub = nip19.npubEncode(evt.pubkey); const npub = nip19.npubEncode(evt.pubkey);
let content = parseJSON(evt.content) let content = (![1, 7].includes(evt.kind) && evt.content !== '') ? parseJSON(evt.content) : (evt.content || '<empty>');
switch (typeof content) { switch (typeof content) {
case 'object': case 'object':
content = JSON.stringify(content, null, 2); content = JSON.stringify(content, null, 2);
break; break;
default: default:
content = `${evt.content}`; content = `${content}`;
} }
const body = elem('div', {className: 'mbox-body'}, [ const body = elem('div', {className: 'mbox-body'}, [
elem('header', {className: 'mbox-header'}, [ elem('header', {className: 'mbox-header'}, [

Loading…
Cancel
Save