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 9 months ago
parent 25d3283a80
commit fea8c0bd21
Signed by: offbyn
GPG Key ID: 94A2F643C51F37FA

@ -13,22 +13,28 @@ const contactHistoryMap: {
[pubkey: string]: Event[]; [pubkey: string]: Event[];
} = {}; } = {};
const hasOwnContactList = () => {
return !!contactHistoryMap[config.pubkey];
};
/** /**
* returns true if user is following 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); const following = contactHistoryMap[config.pubkey]?.at(0);
if (!following) { if (!following) {
return false; 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) => { export const updateFollowBtn = (pubkey: string) => {
const followBtn = getViewElem('followBtn'); const followBtn = getViewElem(`followBtn-${pubkey}`);
if (followBtn) { const view = getViewOptions();
if (followBtn && (view.type === 'contacts' || view.type === 'profile')) {
const hasContact = isFollowing(pubkey); 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.remove('primary', 'secondary');
followBtn.classList.add(hasContact ? 'secondary' : 'primary'); followBtn.classList.add(hasContact ? 'secondary' : 'primary');
followBtn.hidden = false; followBtn.hidden = false;
@ -40,25 +46,49 @@ const updateFollowing = (evt: Event) => {
if (evt.pubkey === config.pubkey) { if (evt.pubkey === config.pubkey) {
localStorage.setItem('follwing', JSON.stringify(evt)); localStorage.setItem('follwing', JSON.stringify(evt));
} }
if (view.type === 'profile') { switch(view.type) {
updateFollowBtn(view.id); case 'contacts':
if (view.id === evt.pubkey) { if (hasOwnContactList()) {
// update following link const lastContactList = contactHistoryMap[config.pubkey]?.at(1);
const following = getViewElem('following') as HTMLElement; if (lastContactList) {
if (following) { const [added, removed] = findChanges(evt, lastContactList);
const count = evt.tags.filter(isPTag).length; [
const anchor = elem('a', { ...added.map(([, pubkey]) => pubkey),
data: {following: evt.pubkey}, ...removed.map(([, pubkey]) => pubkey),
href: `/${evt.id}`, ].forEach(updateFollowBtn);
title: dateTime.format(evt.created_at * 1000), } else {
}, [ evt.tags
'following ', .filter(isPTag)
elem('span', {className: 'highlight'}, count), .forEach(([, pubkey]) => updateFollowBtn(pubkey));
]); }
following.replaceWith(anchor);
setViewElem('following', anchor);
} }
} 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)]; return [evt.tags.filter(isPTag)];
}; };
export const getContacts = () => { /**
const following = contactHistoryMap[config.pubkey]?.at(0); // TODO: ensure newest contactlist * returns list of pubkeys the given pubkey is following
if (following) { * @param pubkey
return following.tags * @returns {String[]} pubkeys
.filter(isPTag) */
.map(([, pubkey]) => pubkey); 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'); const followingFromStorage = localStorage.getItem('follwing');
if (followingFromStorage) { if (followingFromStorage) {
@ -186,9 +233,9 @@ const updateContactTags = (
]; ];
}; };
export const followContact = async (id: string) => { export const followContact = async (pubkey: string) => {
const followBtn = getViewElem('followBtn') as HTMLButtonElement; const followBtn = getViewElem(`followBtn-${pubkey}`) as HTMLButtonElement;
const statusElem = getViewElem('followStatus') as HTMLElement; const statusElem = getViewElem(`followStatus-${pubkey}`) as HTMLElement;
if (!followBtn || !statusElem) { if (!followBtn || !statusElem) {
return; return;
} }
@ -197,7 +244,7 @@ export const followContact = async (id: string) => {
kind: 3, kind: 3,
pubkey: config.pubkey, pubkey: config.pubkey,
content: '', content: '',
tags: updateContactTags(id, following), tags: updateContactTags(pubkey, following),
created_at: Math.floor(Date.now() * 0.001), created_at: Math.floor(Date.now() * 0.001),
}; };
@ -234,5 +281,4 @@ export const followContact = async (id: string) => {
console.info(`event published by ${relay}`); 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-status-bar-style" content="default">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<title>nostr</title> <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"> <link rel="manifest" href="/manifest.json">
</head> </head>
<body> <body>
@ -110,5 +110,5 @@
</div> </div>
</body> </body>
<script src="main.js"></script> <script src="/main.js"></script>
</html> </html>

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

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

@ -19,6 +19,7 @@
background-color: var(--bgcolor-textinput); background-color: var(--bgcolor-textinput);
border-radius: var(--profileimg-size); border-radius: var(--profileimg-size);
flex-basis: var(--profileimg-size); flex-basis: var(--profileimg-size);
flex-shrink: 0;
height: var(--profileimg-size); height: var(--profileimg-size);
margin-right: var(--gap-half); margin-right: var(--gap-half);
max-height: var(--profileimg-size); max-height: var(--profileimg-size);
@ -57,9 +58,12 @@ a.mbox-img:focus {
.mbox-replies .mbox-replies .mbox-img + .mbox-body { .mbox-replies .mbox-replies .mbox-img + .mbox-body {
--max-width: calc(100% - var(--profileimg-size) + var(--gap-half)); --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 { .mbox-header {
align-items: start; align-items: baseline;
display: flex; display: flex;
gap: var(--gap-quarter); gap: var(--gap-quarter);
justify-content: space-between; justify-content: space-between;
@ -72,6 +76,7 @@ a.mbox-img:focus {
text-decoration: none; text-decoration: none;
} }
.mbox-header small { .mbox-header small {
color: var(--color-accent);
white-space: nowrap; white-space: nowrap;
} }
@ -81,6 +86,35 @@ a.mbox-img:focus {
color: var(--color-accent); 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-updated-contact,
.mbox-recommend-server { .mbox-recommend-server {
padding-bottom: var(--gap-quarter); padding-bottom: var(--gap-quarter);

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

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

@ -1,5 +1,5 @@
import {Event} from 'nostr-tools'; import {Event} from 'nostr-tools';
import {getReplyTo, hasEventTag, isMention} from './events'; import {getReplyTo, hasEventTag, isMention, isPTag} from './events';
import {config} from './settings'; import {config} from './settings';
import {sub, subOnce, unsubAll} from './relays'; import {sub, subOnce, unsubAll} from './relays';
@ -288,3 +288,41 @@ export const subOwnContacts = (onEvent: SubCallback) => {
unsub: true, 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.replaceChildren('working…', cancelBtn);
statusElem.hidden = false; statusElem.hidden = false;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const worker = new Worker('./worker.js'); const worker = new Worker('/worker.js');
const onCancel = () => { const onCancel = () => {
worker.terminate(); worker.terminate();

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

@ -2,14 +2,15 @@ import {Event, nip19} from 'nostr-tools';
import {Children, elem, elemArticle, parseTextContent} from './utils/dom'; import {Children, elem, elemArticle, parseTextContent} from './utils/dom';
import {dateTime, formatTime} from './utils/time'; import {dateTime, formatTime} from './utils/time';
import {/*validatePow,*/ sortByCreatedAt} from './events'; import {/*validatePow,*/ sortByCreatedAt} from './events';
import {setViewElem} from './view'; import {getViewElem, getViewOptions, setViewElem} from './view';
import {config} from './settings'; import {config} from './settings';
import {getReactions, getReactionContents} from './reactions'; import {getReactions, getReactionContents} from './reactions';
import {openWriteInput} from './write'; import {openWriteInput} from './write';
// import {linkPreview} from './media'; // import {linkPreview} from './media';
import {parseJSON} from './media';
import {getMetadata} from './profiles'; import {getMetadata} from './profiles';
import {EventWithNip19, replyList} from './notes'; import {EventWithNip19, replyList} from './notes';
import {parseJSON} from './media'; import {isFollowing} from './contacts';
setInterval(() => { setInterval(() => {
document.querySelectorAll('time[datetime]').forEach((timeElem: HTMLTimeElement) => { 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} 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 * 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'}; export const getViewOptions: GetViewOptions = () => containers[activeContainerIndex]?.options || {type: 'feed'};

Loading…
Cancel
Save