profile: show following, contact-list changes and contact events

- added following count in profile header
- added contact-list changes events
- added new raw event detail view to visulaize event metadata and
  raw content
pull/81/head
OFF0 9 months ago
parent 027c61e00f
commit 5039b3dece
Signed by: offbyn
GPG Key ID: 94A2F643C51F37FA

@ -0,0 +1,96 @@
import {Event, nip19} from 'nostr-tools';
import {elem} from './utils/dom';
import {dateTime} from './utils/time';
import {isPTag, sortByCreatedAt} from './events';
import {getViewContent} from './view';
import {getMetadata} from './profiles';
const contactHistoryMap: {
[pubkey: string]: Event[]
} = {};
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);
}
};
export const setContactList = (evt: Event) => {
let contactHistory = contactHistoryMap[evt.pubkey];
if (!contactHistory) {
contactHistoryMap[evt.pubkey] = [evt];
updateFollowing(evt);
return;
}
if (contactHistory.find(({id}) => id === evt.id)) {
return;
}
contactHistory.push(evt);
contactHistory.sort(sortByCreatedAt);
updateFollowing(contactHistory[0]);
};
/**
* 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 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 getContactUpdateMessage = (
addedList: string[][],
removedList: string[][],
) => {
const content = [];
// console.log(addedContacts)
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) {
content.push(elem('small', {}, ` and unfollowed ${removedList.length}`));
}
return content;
};

@ -2,6 +2,7 @@ import {Event} from 'nostr-tools';
import {zeroLeadingBitsCount} from './utils/crypto';
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';
/**

@ -4,14 +4,15 @@ import {elem} from './utils/dom';
import {bounce} from './utils/time';
import {isWssUrl} from './utils/url';
import {closeSettingsView, config, toggleSettingsView} from './settings';
import {sub24hFeed, subNote, subProfile} from './subscriptions'
import {sub24hFeed, subEventID, subNote, subProfile} from './subscriptions'
import {getReplyTo, hasEventTag, 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 {getContactUpdateMessage, setContactList, updateContactList} from './contacts';
import {EventWithNip19, EventWithNip19AndReplyTo, textNoteList, replyList} from './notes';
import {createTextNote, renderRecommendServer} from './ui';
import {createTextNote, renderEventDetails, renderRecommendServer, renderUpdateContact} from './ui';
// curl -H 'accept: application/nostr+json' https://relay.nostr.ch/
@ -149,6 +150,35 @@ config.rerenderFeed = () => {
renderFeed();
};
const handleContactList = (evt: Event, relay: string) => {
setContactList(evt);
const view = getViewOptions();
if (
getViewElem(evt.id)
|| view.type !== 'profile'
|| view.id !== evt.pubkey
) {
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;
}
const art = renderUpdateContact({...evt, content}, relay);
closestNote.after(art);
setViewElem(evt.id, art);
};
const handleRecommendServer = (evt: Event, relay: string) => {
if (getViewElem(evt.id) || !isWssUrl(evt.content)) {
return;
@ -160,12 +190,22 @@ const handleRecommendServer = (evt: Event, relay: string) => {
const closestTextNotes = textNoteList
// TODO: prob change to hasEnoughPOW
.filter(note => !config.filterDifficulty || note.tags.some(([tag, , commitment]) => tag === 'nonce' && Number(commitment) >= config.filterDifficulty))
// use find instead of sort?
.sort(sortEventCreatedAt(evt.created_at));
getViewElem(closestTextNotes[0].id)?.after(art); // TODO: note might not be in the dom yet, recommendedServers could be controlled by renderFeed
}
setViewElem(evt.id, art);
};
const onEventDetails = (evt: Event, relay: string) => {
if (getViewElem(`detail-${evt.id}`)) {
return;
}
const art = renderEventDetails(evt, relay);
getViewContent().append(art);
setViewElem(`detail-${evt.id}`, art);
};
const onEvent = (evt: Event, relay: string) => {
switch (evt.kind) {
case 0:
@ -178,7 +218,7 @@ const onEvent = (evt: Event, relay: string) => {
handleRecommendServer(evt, relay);
break;
case 3:
// handleContactList(evt, relay);
handleContactList(evt, relay);
break;
case 7:
handleReaction(evt, relay);
@ -211,6 +251,12 @@ const route = (path: string) => {
console.warn(`type ${type} not yet supported`);
}
renderFeed();
} 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)
}
};
@ -284,12 +330,15 @@ const handleButton = (button: HTMLButtonElement) => {
const handleContentClick = (content: HTMLElement) => {
const card = content.closest('article[data-id]') as HTMLElement;
if (
!card || !card.dataset.id
!card
|| !card.dataset.id
|| !card.dataset.kind
|| getSelection()?.toString() // do not navigate if user selects text
) {
return;
}
const href = `/${nip19.noteEncode(card.dataset.id)}`;
const {kind, id} = card.dataset;
const href = `/${kind === '1' ? nip19.noteEncode(id) : id}`;
route(href);
history.pushState({}, '', href);
};
@ -310,8 +359,8 @@ document.body.addEventListener('click', (event: MouseEvent) => {
handleButton(button);
return;
}
const content = target?.closest('.mbox-content');
if (content) {
handleContentClick(content as HTMLElement);
const card = target?.closest('.mbox-body');
if (card) {
handleContentClick(card as HTMLElement);
}
});

@ -1,7 +1,7 @@
/* https://developer.mozilla.org/en-US/docs/Web/CSS/Layout_cookbook/Media_objects */
.mbox {
align-items: center;
border-bottom: 1px solid var(--bgcolor-nav);
border-top: 1px solid var(--bgcolor-nav);
display: flex;
flex-direction: row;
flex-shrink: 0;
@ -49,11 +49,16 @@ a.mbox-img:focus {
padding-bottom: var(--gap-half);
word-break: break-word;
}
.mbox-body div a {
.mbox-content a {
text-decoration: underline;
}
.mbox-img + .mbox-body {
flex-basis: calc(100% - var(--profileimg-size) - var(--gap-half));
--max-width: calc(100% - var(--profileimg-size) - var(--gap-half));
flex-basis: var(--max-width);
max-width: var(--max-width);
}
.mbox-replies .mbox-replies .mbox-img + .mbox-body {
--max-width: calc(100% - var(--profileimg-size) + var(--gap-half));
}
.mbox-header {
@ -79,24 +84,28 @@ a.mbox-img:focus {
.mbox-recommend-server .mbox-body {
display: block;
font-size: var(--font-small);
overflow: scroll;
}
.mbox-updated-contact .mbox-header,
.mbox-recommend-server .mbox-header {
display: inline;
}
.mbox-content {
max-width: 100%;
}
.mbox-updated-contact {
padding: 0 0 1rem 0;
margin: 0;
}
.mbox-updated-contact + .mbox-updated-contact {
border-top: none;
}
.mbox {
overflow: clip;
}
.mbox .mbox {
border-bottom: none;
border-top: none;
max-width: 100%;
overflow: visible;
padding: 0;
@ -218,22 +227,20 @@ a.mbox-img:focus {
position: relative;
}
.mbox-replies .mbox .mbox .mbox-body {
/*
.mbox-replies .mbox-body.mbox-oneline {
display: flex;
flex-wrap: wrap;
font-size: var(--font-small);
padding-bottom: var(--gap-half);
padding-top: var(--gap-eight);
}
@media (orientation: portrait) {
.mbox-replies .mbox .mbox .mbox-body {
font-size: var(--font-small);
}
}
.mbox-replies .mbox .mbox .mbox-header a:last-of-type::after {
content: " ";
display: inline-block;
padding-right: var(--gap-half);
}
*/
.mbox-replies .mbox .mbox .buttons {
display: none;
}

@ -87,7 +87,7 @@ body {
color: var(--color);
font-size: 1.6rem;
line-height: 1.375;
word-break: break-all;
word-break: break-word;
}
@media (orientation: portrait) {
body {
@ -158,3 +158,17 @@ pre {
margin: 0;
padding: .5rem 0;
}
dl {
display: grid;
grid-row-gap: var(--gap-half);
grid-template-columns: max-content auto;
}
dt {
grid-column-start: 1;
}
dd {
grid-column-start: 2;
}

@ -113,6 +113,7 @@ nav button {
}
main .content {
height: 1px;
padding-bottom: 10rem;
}
nav .content {
display: flex;
@ -132,7 +133,6 @@ nav a {
.hero {
--extra-space: calc(var(--profileimg-size) + var(--gap-half));
border-bottom: 1px solid var(--bgcolor-nav);
padding: var(--gap-half);
}

@ -189,4 +189,28 @@ export const subProfile = (
limit: 50,
}
});
setTimeout(() => {
// get contacts
sub({
cb: onEvent,
filter: {
authors: [pubkey],
kinds: [3],
limit: 3,
},
});
}, 100);
};
export const subEventID = (
id: string,
onEvent: SubCallback,
) => {
sub({
cb: onEvent,
filter: {
ids: [id],
limit: 1,
},
});
};

@ -1,5 +1,5 @@
import {Event} from 'nostr-tools';
import {elem, elemArticle, parseTextContent} from './utils/dom';
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';
@ -79,6 +79,37 @@ export const createTextNote = (
});
};
type EventWithContent = Omit<Event, 'content'> & {
content: Children
}
export const renderUpdateContact = (
evt: EventWithContent,
relay: string,
) => {
const {img, name, userName} = getMetadata(evt.pubkey);
const time = new Date(evt.created_at * 1000);
return elemArticle([
elem('div', {className: 'mbox-img'}, img),
elem('div', {className: 'mbox-body'}, [
elem('header', {className: 'mbox-header'}, [
elem('span', {
className: `mbox-username${name ? ' mbox-kind0-name' : ''}`,
data: {profile: evt.pubkey},
}, name || userName),
' ',
elem('span', {data: {contacts: evt.id}, title: time.toISOString()}, evt.content),
]),
elem('div', {className: 'mbox-content'}, [
]),
]),
], {
className: 'mbox-updated-contact',
data: {kind: evt.kind, id: evt.id, pubkey: evt.pubkey, relay}
}
);
};
export const renderRecommendServer = (evt: Event, relay: string) => {
const {img, userName} = getMetadata(evt.pubkey);
const time = new Date(evt.created_at * 1000);
@ -97,3 +128,57 @@ export const renderRecommendServer = (evt: Event, relay: string) => {
data: {kind: evt.kind, id: evt.id, pubkey: evt.pubkey}
});
};
export const renderEventDetails = (evt: Event, relay: string) => {
const {img, name, userName} = getMetadata(evt.pubkey);
const time = new Date(evt.created_at * 1000);
const npub = nip19.npubEncode(evt.pubkey);
let content = parseJSON(evt.content)
switch (typeof content) {
case 'object':
content = JSON.stringify(content, null, 2);
break;
default:
content = `${evt.content}`;
}
const body = elem('div', {className: 'mbox-body', title: dateTime.format(time)}, [
elem('header', {className: 'mbox-header'}, [
elem('a', {
className: `mbox-username${name ? ' mbox-kind0-name' : ''}`,
data: {profile: evt.pubkey},
href: `/${npub}`,
}, name || userName),
]),
elem('dl', {}, [
elem('dt', {}, 'npub'),
elem('dd', {}, npub),
elem('dt', {}, 'created at'),
elem('dd', {}, dateTime.format(evt.created_at * 1000)),
elem('dt', {}, 'relay'),
elem('dd', {}, relay),
]),
elem('h2', {}, 'Event'),
elem('dl', {}, [
elem('dt', {}, 'id'),
elem('dd', {}, evt.id),
elem('dt', {}, 'kind'),
elem('dd', {}, evt.kind),
elem('dt', {}, 'pubkey'),
elem('dd', {}, evt.pubkey),
elem('dt', {}, 'tags count'),
elem('dd', {}, evt.tags.length),
elem('dt', {}, 'tags'),
elem('dd', {}, JSON.stringify(evt.tags)),
elem('dt', {}, 'content'),
elem('dd', {}, elem('pre', {}, content as string)),
]),
]);
return elemArticle([
elem('a', {className: 'mbox-img', href: `/${npub}`, tabIndex: -1}, img),
body,
], {
className: 'mbox-recommend-server',
data: {kind: evt.kind, id: evt.id, pubkey: evt.pubkey}
});
};

@ -12,7 +12,7 @@ type DataAttributes = {
type Attributes<Type> = Partial<Type & DataAttributes>;
type Children = Array<HTMLElement | string | null> | HTMLElement | string | number | null;
export type Children = Array<HTMLElement | string | null> | HTMLElement | string | number | null;
/**
* example usage:
@ -29,7 +29,7 @@ type Children = Array<HTMLElement | string | null> | HTMLElement | string | numb
export const elem = <Name extends keyof HTMLElementTagNameMap>(
name: Extract<Name, keyof HTMLElementTagNameMap>,
attrs?: Attributes<HTMLElementTagNameMap[Name]>,
children?: Children,
children?: Children | undefined,
): HTMLElementTagNameMap[Name] => {
const el = document.createElement(name);
if (attrs) {

@ -9,6 +9,9 @@ type ViewOptions = {
} | {
type: 'profile';
id: string;
} | {
type: 'event';
id: string;
};
type DOMMap = {
@ -74,6 +77,15 @@ const renderView = (options: ViewOptions) => {
break;
case 'feed':
break;
case 'event':
const id = options.id;
const eventHeader = elem('header', {className: 'hero'}, [
elem('h1', {}, id),
]);
dom[id] = eventHeader;
content.append(eventHeader);
document.title = id;
break;
}
const view = elem('section', {className: 'view'}, [content]);
return {content, dom, view};

Loading…
Cancel
Save