route: add contact list view
ci/woodpecker/pr/woodpecker Pipeline was successful Details
ci/woodpecker/push/woodpecker Pipeline was successful Details

added new route /contacts/npub... to show contact lists of users.
each user has about text, follow/unfollow buttons.

fixed CSS and JavaScript links in index.html to support deeper
path i.e. /contacts/npub... uri's.
pull/84/head
OFF0 8 months ago
parent 25d3283a80
commit fea8c0bd21
Signed by: offbyn
GPG Key ID: 94A2F643C51F37FA

@ -13,22 +13,28 @@ const contactHistoryMap: {
[pubkey: string]: Event[];
} = {};
const hasOwnContactList = () => {
return !!contactHistoryMap[config.pubkey];
};
/**
* returns true if user is following pubkey
*/
export const isFollowing = (id: string) => {
export const isFollowing = (pubkey: string) => {
const following = contactHistoryMap[config.pubkey]?.at(0);
if (!following) {
return false;
}
return following.tags.some(([tag, value]) => tag === 'p' && value === id);
return following.tags.some(([tag, value]) => tag === 'p' && value === pubkey);
};
export const updateFollowBtn = (pubkey: string) => {
const followBtn = getViewElem('followBtn');
if (followBtn) {
const followBtn = getViewElem(`followBtn-${pubkey}`);
const view = getViewOptions();
if (followBtn && (view.type === 'contacts' || view.type === 'profile')) {
const hasContact = isFollowing(pubkey);
followBtn.textContent = hasContact ? 'unfollow' : 'follow';
const isMe = config.pubkey === pubkey;
followBtn.textContent = isMe ? 'following' : hasContact ? 'unfollow' : 'follow';
followBtn.classList.remove('primary', 'secondary');
followBtn.classList.add(hasContact ? 'secondary' : 'primary');
followBtn.hidden = false;
@ -40,25 +46,49 @@ const updateFollowing = (evt: Event) => {
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);
switch(view.type) {
case 'contacts':
if (hasOwnContactList()) {
const lastContactList = contactHistoryMap[config.pubkey]?.at(1);
if (lastContactList) {
const [added, removed] = findChanges(evt, lastContactList);
[
...added.map(([, pubkey]) => pubkey),
...removed.map(([, pubkey]) => pubkey),
].forEach(updateFollowBtn);
} else {
evt.tags
.filter(isPTag)
.forEach(([, pubkey]) => updateFollowBtn(pubkey));
}
}
}
break;
case '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: `/contacts/${nip19.npubEncode(evt.pubkey)}`,
title: dateTime.format(evt.created_at * 1000),
}, [
'following ',
elem('span', {className: 'highlight'}, count),
]);
following.replaceWith(anchor);
setViewElem('following', anchor);
}
}
break;
}
};
export const refreshFollowing = (id: string) => {
if (contactHistoryMap[id]?.at(0)) {
updateFollowing(contactHistoryMap[id][0]);
}
};
@ -148,12 +178,29 @@ export const updateContactList = (evt: Event) => {
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);
/**
* returns list of pubkeys the given pubkey is following
* @param pubkey
* @returns {String[]} pubkeys
*/
export const getContacts = (pubkey: string) => {
const following = contactHistoryMap[pubkey]?.at(0);
if (!following) {
return [];
}
return following.tags
.filter(isPTag)
.map(([, pubkey]) => pubkey);
};
/**
* returns list of pubkeys the user is following, if none found it will try from localstorage
* @returns {String[]} pubkeys
*/
export const getOwnContacts = () => {
const following = getContacts(config.pubkey);
if (following.length) {
return following;
}
const followingFromStorage = localStorage.getItem('follwing');
if (followingFromStorage) {
@ -186,9 +233,9 @@ const updateContactTags = (
];
};
export const followContact = async (id: string) => {
const followBtn = getViewElem('followBtn') as HTMLButtonElement;
const statusElem = getViewElem('followStatus') as HTMLElement;
export const followContact = async (pubkey: string) => {
const followBtn = getViewElem(`followBtn-${pubkey}`) as HTMLButtonElement;
const statusElem = getViewElem(`followStatus-${pubkey}`) as HTMLElement;
if (!followBtn || !statusElem) {
return;
}
@ -197,7 +244,7 @@ export const followContact = async (id: string) => {
kind: 3,
pubkey: config.pubkey,
content: '',
tags: updateContactTags(id, following),
tags: updateContactTags(pubkey, following),
created_at: Math.floor(Date.now() * 0.001),
};
@ -234,5 +281,4 @@ export const followContact = async (id: string) => {
console.info(`event published by ${relay}`);
});
}
};

@ -6,7 +6,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>nostr</title>
<link rel="stylesheet" href="styles/main.css" type="text/css">
<link rel="stylesheet" href="/styles/main.css" type="text/css">
<link rel="manifest" href="/manifest.json">
</head>
<body>
@ -110,5 +110,5 @@
</div>
</body>
<script src="main.js"></script>
<script src="/main.js"></script>
</html>

@ -4,15 +4,15 @@ import {elem} from './utils/dom';
import {bounce} from './utils/time';
import {isWssUrl} from './utils/url';
import {closeSettingsView, config, toggleSettingsView} from './settings';
import {subGlobalFeed, subEventID, subNote, subProfile, subPubkeys, subOwnContacts} from './subscriptions'
import {subGlobalFeed, subEventID, subNote, subProfile, subPubkeys, subOwnContacts, subContactList} from './subscriptions'
import {getReplyTo, hasEventTag, isEvent, isMention, sortByCreatedAt, sortEventCreatedAt} from './events';
import {clearView, getViewContent, getViewElem, getViewOptions, setViewElem, view} from './view';
import {handleReaction, handleUpvote} from './reactions';
import {closePublishView, openWriteInput, togglePublishView} from './write';
import {handleMetadata, renderProfile} from './profiles';
import {followContact, getContactUpdateMessage, getContacts, resetContactList, setContactList, updateContactList, updateFollowBtn} from './contacts';
import {followContact, getContactUpdateMessage, getContacts, getOwnContacts, refreshFollowing, resetContactList, setContactList, updateContactList, updateFollowBtn} from './contacts';
import {EventWithNip19, EventWithNip19AndReplyTo, textNoteList, replyList} from './notes';
import {createTextNote, renderEventDetails, renderRecommendServer, renderUpdateContact} from './ui';
import {createContact, createTextNote, renderEventDetails, renderRecommendServer, renderUpdateContact} from './ui';
// curl -H 'accept: application/nostr+json' https://relay.nostr.ch/
@ -38,6 +38,18 @@ const renderNote = (
setViewElem(evt.id, article);
};
const renderContact = (pubkey: string) => {
if (getViewElem(`contact-${pubkey}`)) { // contact already in view
updateFollowBtn(pubkey);
return;
}
const contact = createContact(pubkey);
if (contact) {
getViewContent().append(contact);
setViewElem(`contact-${pubkey}`, contact);
}
};
const hasEnoughPOW = (
[tag, , commitment]: string[],
eventId: string
@ -64,12 +76,13 @@ const renderFeed = bounce(() => {
]
.sort(sortByCreatedAt)
.reverse()
.forEach(renderNote); // render in-reply-to
.forEach(renderNote);
renderProfile(view.id);
refreshFollowing(view.id);
break;
case 'home':
const ids = getContacts();
const ids = getOwnContacts();
[
...textNoteList
.filter(note => ids.includes(note.pubkey)),
@ -95,6 +108,10 @@ const renderFeed = bounce(() => {
.reverse()
.forEach(renderNote);
break;
case 'contacts':
getContacts(view.id)
.forEach(renderContact);
break;
}
}, 17); // (16.666 rounded, an arbitrary value to limit updates to max 60x per s)
@ -167,30 +184,35 @@ const handleContactList = (evt: Event, relay: string) => {
// TODO: if newer and view.type === 'home' rerenderFeed()
setContactList(evt);
const view = getViewOptions();
if (getViewElem(evt.id)) {
return;
}
if (
getViewElem(evt.id)
|| view.type !== 'profile'
|| view.id !== evt.pubkey
view.type === 'contacts'
&& [view.id, config.pubkey].includes(evt.pubkey) // render if contact-list is from current users or current view
) {
renderFeed();
return;
}
// use find instead of sort?
const closestTextNotes = textNoteList.sort(sortEventCreatedAt(evt.created_at));
const closestNote = getViewElem(closestTextNotes[0].id);
if (!closestNote) {
// no close note, try later
setTimeout(() => handleContactList(evt, relay), 1500);
return;
};
const [addedContacts, removedContacts] = updateContactList(evt);
const content = getContactUpdateMessage(addedContacts, removedContacts);
if (!content.length) {
// P same as before, maybe only evt.content or 'a' tags changed?
return;
if (view.type === 'profile' && view.id === evt.pubkey) {
// use find instead of sort?
const closestTextNotes = textNoteList.sort(sortEventCreatedAt(evt.created_at));
const closestNote = getViewElem(closestTextNotes[0].id);
if (!closestNote) {
// no close note, try later
setTimeout(() => handleContactList(evt, relay), 1500);
return;
};
const [addedContacts, removedContacts] = updateContactList(evt);
const content = getContactUpdateMessage(addedContacts, removedContacts);
if (!content.length) {
// P same as before, maybe only evt.content or 'a' tags changed?
return;
}
const art = renderUpdateContact({...evt, content}, relay);
closestNote.after(art);
setViewElem(evt.id, art);
}
const art = renderUpdateContact({...evt, content}, relay);
closestNote.after(art);
setViewElem(evt.id, art);
};
const handleRecommendServer = (evt: Event, relay: string) => {
@ -243,10 +265,9 @@ const onEvent = (evt: Event, relay: string) => {
// subscribe and change view
const route = (path: string) => {
const contactList = getContacts();
if (path === '/') {
const contactList = getOwnContacts();
if (contactList.length) {
const {pubkey} = config;
subPubkeys(contactList, onEvent);
view(`/`, {type: 'home'});
} else {
@ -278,18 +299,25 @@ const route = (path: string) => {
console.warn(`type ${type} not yet supported`);
}
renderFeed();
} else if (path.length === 73 && path.match(/^\/contacts\/npub[0-9a-z]+$/)) {
const contactNpub = path.slice(10);
const {type: contactType, data: contactPubkey} = nip19.decode(contactNpub);
if (contactType === 'npub') {
subContactList(contactPubkey, onEvent);
view(path, {type: 'contacts', id: contactPubkey});
}
} else if (path.length === 65) {
const eventID = path.slice(1);
subEventID(eventID, onEventDetails);
view(path, {type: 'event', id: eventID});
} else {
console.warn('no support for ', path)
console.warn('no support for ', path);
}
};
// onload
subOwnContacts(onEvent);
route(location.pathname);
subOwnContacts(onEvent); // subscribe after route as routing unsubscribes current subs
// only push a new entry if there is no history onload
if (!history.length) {
@ -317,6 +345,7 @@ const handleLink = (a: HTMLAnchorElement, e: MouseEvent) => {
|| href.startsWith('/feed')
|| href.startsWith('/note')
|| href.startsWith('/npub')
|| href.startsWith('/contacts/npub')
|| (href.startsWith('/') && href.length === 65)
) {
route(href);

@ -1,7 +1,7 @@
import {Event} from 'nostr-tools';
import {elem, elemCanvas, parseTextContent} from './utils/dom';
import {getNoxyUrl} from './utils/url';
import {getViewElem} from './view';
import {getViewElem, getViewOptions} from './view';
import {parseJSON} from './media';
import {sortByCreatedAt} from './events';
@ -87,10 +87,22 @@ export const handleMetadata = (evt: Event, relay: string) => {
username.classList.add('mbox-kind0-name');
});
}
if (metadata.about) {
const about = getViewElem(`about-${evt.pubkey}`);
if (about) {
const view = getViewOptions();
about.replaceChildren(...parseTextContent(
view.type === 'contacts'
? metadata.about.split('\n')[0]
: metadata.about
)[0]);
}
}
};
export const getMetadata = (pubkey: string) => {
const user = profileMap[pubkey];
const about = user?.about;
const name = user?.name;
const userName = name || pubkey.slice(0, 8);
// const userImg = user?.picture;
@ -100,7 +112,7 @@ export const getMetadata = (pubkey: string) => {
src: userImg,
title: `${userName} on ${host} ${userAbout}`,
}) : */ elemCanvas(pubkey);
return {img, name, userName};
return {about, img, name, userName};
};
export const renderProfile = (pubkey: string) => {
@ -118,12 +130,11 @@ export const renderProfile = (pubkey: string) => {
header.append(elem('h1', {}, metadata.name));
}
}
const detail = getViewElem('detail');
if (metadata.about) {
if (!detail.children.length) {
const [content] = parseTextContent(metadata.about);
detail?.append(...content);
}
console.log('render detail')
const detail = getViewElem(`detail-${pubkey}`);
if (metadata.about && !detail.children.length) {
const [content] = parseTextContent(metadata.about);
detail?.append(...content);
}
if (metadata.website) {
const website = detail.querySelector('[data-website]');

@ -19,6 +19,7 @@
background-color: var(--bgcolor-textinput);
border-radius: var(--profileimg-size);
flex-basis: var(--profileimg-size);
flex-shrink: 0;
height: var(--profileimg-size);
margin-right: var(--gap-half);
max-height: var(--profileimg-size);
@ -57,9 +58,12 @@ a.mbox-img:focus {
.mbox-replies .mbox-replies .mbox-img + .mbox-body {
--max-width: calc(100% - var(--profileimg-size) + var(--gap-half));
}
.mbox-contact .mbox-img + .mbox-body {
--max-width: calc(100% - var(--profileimg-size) - var(--gap-half) - 90px);
}
.mbox-header {
align-items: start;
align-items: baseline;
display: flex;
gap: var(--gap-quarter);
justify-content: space-between;
@ -72,6 +76,7 @@ a.mbox-img:focus {
text-decoration: none;
}
.mbox-header small {
color: var(--color-accent);
white-space: nowrap;
}
@ -81,6 +86,35 @@ a.mbox-img:focus {
color: var(--color-accent);
}
.mbox-contact {
align-items: start; /* TODO: maybe all .mbox element should have align-items start */
flex-wrap: nowrap;
padding: var(--gap-half);
}
.mbox-contact .mbox-header {
justify-content: start;
}
.mbox-contact .mbox-body {
flex-basis: 100%;
flex-grow: 1;
flex-shrink: 1;
min-width: 0; /* with this mbox-content displays text on one line cutting off text-overflo... */
padding-bottom: 0;
}
.mbox-contact .mbox-content {
overflow: clip;
padding-right: var(--gap-quarter);
text-overflow: ellipsis;
white-space: nowrap;
}
.mbox-cta {
align-items: center;
align-self: center;
display: flex;
white-space: nowrap;
}
.mbox-updated-contact,
.mbox-recommend-server {
padding-bottom: var(--gap-quarter);

@ -146,6 +146,10 @@ button:active {
.secondary {
background-color: transparent;
}
.secondary:disabled {
border-color: var(--color-accent);
color: var(--color-accent);
}
button:focus {
}

@ -159,7 +159,10 @@ nav a {
}
.hero-title h1 {
flex-grow: 1;
font-size: 2.1rem;
line-height: 1.285714285714286;
margin-bottom: 0;
margin-top: 2rem;
padding-left: var(--extra-space);
}
.hero-title button {
@ -173,8 +176,9 @@ nav a {
.hero .hero-npub {
color: var(--color-accent);
font-size: 1.1rem;
display: block;
font-size: 1.1rem;
line-height: 1.36363636;
max-width: 100%;
overflow: clip;
text-align: center;

@ -1,5 +1,5 @@
import {Event} from 'nostr-tools';
import {getReplyTo, hasEventTag, isMention} from './events';
import {getReplyTo, hasEventTag, isMention, isPTag} from './events';
import {config} from './settings';
import {sub, subOnce, unsubAll} from './relays';
@ -288,3 +288,41 @@ export const subOwnContacts = (onEvent: SubCallback) => {
unsub: true,
});
};
export const subContactList = (
pubkey: string,
onEvent: SubCallback,
) => {
unsubAll();
const pubkeys = new Set<string>();
let newestEvent = 0;
sub({
cb: (evt: Event, relay: string) => {
if (evt.created_at <= newestEvent) {
return;
}
newestEvent = evt.created_at;
const newPubkeys = evt.tags
.filter(isPTag)
.filter(([, p]) => !pubkeys.has(p))
.map(([, p]) => {
pubkeys.add(p);
return p
});
subOnce({
cb: onEvent,
filter: {
authors: newPubkeys,
kinds: [0],
},
relay,
});
onEvent(evt, relay);
},
filter: {
authors: [pubkey],
kinds: [3],
limit: 1,
},
});
};

@ -88,7 +88,7 @@ export const powEvent = (
statusElem.replaceChildren('working…', cancelBtn);
statusElem.hidden = false;
return new Promise((resolve, reject) => {
const worker = new Worker('./worker.js');
const worker = new Worker('/worker.js');
const onCancel = () => {
worker.terminate();

@ -16,6 +16,9 @@ export type ViewTemplateOptions = {
} | {
type: 'profile';
id: string;
} | {
type: 'contacts';
id: string;
} | {
type: 'event';
id: string;
@ -32,7 +35,8 @@ export const renderViewTemplate = (options: ViewTemplateOptions) => {
case 'profile':
const pubkey = options.id;
const npub = nip19.npubEncode(pubkey);
const detail = elem('p');
const about = elem('span');
const detail = elem('p', {}, about);
const followStatus = elem('small');
const followBtn = elem('button', {
className: 'primary',
@ -51,15 +55,18 @@ export const renderViewTemplate = (options: ViewTemplateOptions) => {
elem('footer', {}, following),
]);
dom.header = profileHeader;
dom.detail = detail;
dom[`about-${pubkey}`] = about;
dom[`detail-${pubkey}`] = detail;
dom.following = following;
dom.followStatus = followStatus;
dom.followBtn = followBtn;
dom[`followStatus-${pubkey}`] = followStatus;
dom[`followBtn-${pubkey}`] = followBtn;
content.append(profileHeader);
document.title = pubkey;
break;
case 'note':
break;
case 'contacts':
break;
case 'event':
const id = options.id;
content.append(

@ -2,14 +2,15 @@ import {Event, nip19} from 'nostr-tools';
import {Children, elem, elemArticle, parseTextContent} from './utils/dom';
import {dateTime, formatTime} from './utils/time';
import {/*validatePow,*/ sortByCreatedAt} from './events';
import {setViewElem} from './view';
import {getViewElem, getViewOptions, setViewElem} from './view';
import {config} from './settings';
import {getReactions, getReactionContents} from './reactions';
import {openWriteInput} from './write';
// import {linkPreview} from './media';
import {parseJSON} from './media';
import {getMetadata} from './profiles';
import {EventWithNip19, replyList} from './notes';
import {parseJSON} from './media';
import {isFollowing} from './contacts';
setInterval(() => {
document.querySelectorAll('time[datetime]').forEach((timeElem: HTMLTimeElement) => {
@ -179,3 +180,46 @@ export const renderEventDetails = (evt: Event, relay: string) => {
data: {kind: evt.kind, id: evt.id, pubkey: evt.pubkey}
});
};
export const createContact = (pubkey: string) => {
const {about: aboutContent, img, name, userName} = getMetadata(pubkey);
const npub = nip19.npubEncode(pubkey);
const view = getViewOptions();
if (view.type !== 'contacts') {
return null;
}
const isMe = config.pubkey === pubkey;
const isCurrentUser = view.id === pubkey;
const hasContact = isFollowing(pubkey);
const followStatus = elem('small');
const followBtn = elem('button', {
className: hasContact ? 'secondary' : 'primary',
...(isMe && {disabled: true}),
name: 'follow',
data: {id: pubkey}
}, hasContact ? (isMe ? 'following' : 'unfollow') : 'follow');
const about = elem('div', {className: 'mbox-content'}, aboutContent);
setViewElem(`about-${pubkey}`, about);
setViewElem(`followStatus-${pubkey}`, followStatus);
setViewElem(`followBtn-${pubkey}`, followBtn);
return elemArticle([
elem('a', {className: 'mbox-img', href: `/${npub}`, tabIndex: -1}, img),
elem('div', {className: 'mbox-body'}, [
elem('header', {className: 'mbox-header'}, [
elem('a', {
className: `mbox-username${name ? ' mbox-kind0-name' : ''}`,
data: {profile: pubkey},
href: `/${npub}`,
}, name || userName),
(isMe || isCurrentUser)
? elem('small', {}, isMe ? '(your user)' : '(current user)')
: null,
]),
about,
]),
elem('div', {className: 'mbox-cta'}, [followStatus, followBtn]),
], {
className: 'mbox-contact',
data: {pubkey},
});
};

@ -60,7 +60,7 @@ type GetViewOptions = () => ViewTemplateOptions;
/**
* get options for current view
* @returns {id: 'home' | 'feed' | 'profile' | 'note' | 'event', id?: string}
* @returns {id: 'home' | 'feed' | 'profile' | 'note' | 'contacts' | 'event', id?: string}
*/
export const getViewOptions: GetViewOptions = () => containers[activeContainerIndex]?.options || {type: 'feed'};

Loading…
Cancel
Save