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.

also:
- updated following and unfollwing wording
- added proper primary and secondary button styles.
pull/84/head
OFF0 10 months ago
parent 208ea6363a
commit f92cfbbb31
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 {dateTime} from './utils/time';
import {isPTag, sortByCreatedAt} from './events';
import {getViewContent} from './view';
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[]
[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 following = getViewContent().querySelector(`[data-following="${evt.pubkey}"]`);
if (following) {
const count = evt.tags.filter(isPTag).length;
const anchor = elem('a', {
data: {following: evt.pubkey},
href: `/${evt.id}`,
title: dateTime.format(new Date(evt.created_at * 1000)),
}, `following ${count}`);
following.replaceWith(anchor);
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) => {
let contactHistory = contactHistoryMap[evt.pubkey];
const contactHistory = contactHistoryMap[evt.pubkey];
if (!contactHistory) {
contactHistoryMap[evt.pubkey] = [evt];
updateFollowing(evt);
@ -32,9 +72,8 @@ export const setContactList = (evt: Event) => {
if (contactHistory.find(({id}) => id === evt.id)) {
return;
}
contactHistory.push(evt);
contactHistory.sort(sortByCreatedAt);
updateFollowing(contactHistory[0]);
contactHistory.unshift(evt);
updateFollowing(contactHistory[0]); // TODO: ensure that this is newest contactlist?
};
/**
@ -49,26 +88,8 @@ const findChanges = (current: Event, previous: Event) => {
return [addedContacts, removedContacts];
};
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) { // 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 resetContactList = (pubkey: string) => {
delete contactHistoryMap[pubkey];
};
export const getContactUpdateMessage = (
@ -76,7 +97,6 @@ export const getContactUpdateMessage = (
removedList: string[][],
) => {
const content = [];
// console.log(addedContacts)
if (addedList.length && addedList[0]) {
const pubkey = addedList[0][1];
const {userName} = getMetadata(pubkey);
@ -90,7 +110,129 @@ export const getContactUpdateMessage = (
content.push(` (+ ${addedList.length - 1} others)`);
}
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;
};
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 {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 isPTag = ([tag]: string[]) => tag === 'p';
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.

@ -24,8 +24,8 @@
<legend>write a new note</legend>
<textarea name="message" rows="1"></textarea>
<div class="buttons">
<button type="submit" id="publish" disabled>send</button>
<button type="button" name="back">back</button>
<button type="submit" id="publish" class="primary" disabled>send</button>
<button type="button" name="back" class="primary">back</button>
</div>
<small id="sendstatus" class="form-status"></small>
</fieldset>
@ -42,7 +42,7 @@
<input type="url" name="picture" id="profile_picture" placeholder="https://your.domain/image.png">
<div class="buttons">
<small id="profilestatus" class="form-status" hidden></small>
<button type="submit" name="publish" tabindex="0" disabled>publish</button>
<button type="submit" name="publish" class="primary" tabindex="0" disabled>publish</button>
</div>
</form>
<form action="#" name="options">
@ -86,8 +86,8 @@
<input type="password" id="privatekey" autocomplete="off">
<div class="buttons">
<small id="keystatus" class="form-status" hidden></small>
<button type="button" name="generate" tabindex="0">new</button>
<button type="button" name="import" tabindex="0" disabled>save</button>
<button type="button" name="generate" class="primary" tabindex="0">new</button>
<button type="button" name="import" class="primary" tabindex="0" disabled>save</button>
</div>
</form>
<footer class="text">

@ -10,7 +10,7 @@ import {clearView, getViewContent, getViewElem, getViewOptions, setViewElem, vie
import {handleReaction, handleUpvote} from './reactions';
import {closePublishView, openWriteInput, togglePublishView} from './write';
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 {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));
note && handleUpvote(note);
break;
case 'follow':
followContact(id);
break;
}
}
// const container = e.target.closest('[data-append]');

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

@ -117,19 +117,35 @@ form .buttons,
.buttons img,
.buttons small,
.buttons span {
font-weight: normal;
vertical-align: middle;
}
button {
--bg-color: var(--bgcolor-accent);
--border-color: var(--bgcolor-accent);
background-color: var(--bg-color);
border: 0.2rem solid var(--border-color);
border-radius: .2rem;
background-color: transparent;
border: none;
cursor: pointer;
font-weight: bold;
outline-offset: 1px;
word-break: normal;
}
button:active {
--bg-color: rgb(13, 74, 139);
--border-color: rgb(13, 74, 139);
}
.primary,
.secondary {
border: 0.2rem solid var(--bgcolor-accent);
border-radius: .2rem;
padding: .9rem 2rem .7rem 2rem;
}
.primary {
background-color: var(--bgcolor-accent);
}
.secondary {
background-color: transparent;
}
button:focus {
}

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

@ -135,16 +135,29 @@ nav a {
--extra-space: calc(var(--profileimg-size) + var(--gap-half));
padding: var(--gap-half);
}
.hero h1 {
.hero-title {
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);
}
.hero-title button {
line-height: 1;
}
.hero p {
max-width: calc(var(--content-width) - var(--extra-space));
padding-left: var(--extra-space);
}
.hero small {
.hero .hero-npub {
color: var(--color-accent);
font-size: 1.1rem;
display: block;
@ -155,7 +168,7 @@ nav a {
white-space: nowrap;
}
@media (min-width: 54ch) {
.hero small {
.hero .hero-npub {
padding-left: var(--extra-space);
text-align: left;
}
@ -164,3 +177,7 @@ nav a {
.hero footer {
padding-left: var(--extra-space);
}
.hero footer a {
text-decoration: none;
}

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

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

@ -100,8 +100,6 @@ export const renderUpdateContact = (
' ',
elem('span', {data: {contacts: evt.id}, title: time.toISOString()}, evt.content),
]),
elem('div', {className: 'mbox-content'}, [
]),
]),
], {
className: 'mbox-updated-contact',
@ -133,13 +131,13 @@ export const renderEventDetails = (evt: Event, relay: string) => {
const {img, name, userName} = getMetadata(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) {
case 'object':
content = JSON.stringify(content, null, 2);
break;
default:
content = `${evt.content}`;
content = `${content}`;
}
const body = elem('div', {className: 'mbox-body'}, [
elem('header', {className: 'mbox-header'}, [

Loading…
Cancel
Save