Compare commits

...

15 Commits

Author SHA1 Message Date
OFF0 fea8c0bd21
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.
9 months ago
OFF0 25d3283a80
routes: fix internal link detection
ci/woodpecker/push/woodpecker Pipeline was successful Details
ci/woodpecker/pr/woodpecker Pipeline was successful Details
it just checked link length, but should at least ensure that the
condition which checks for internal links starts with a slash.
9 months ago
OFF0 80d3a3f6a7
view: prevent scrolling navigation elements
scrolling on navigation moved the whole view up, this change
prevents nav from being scrollable.
9 months ago
OFF0 edf5ac21a6
relays: remove noisy eose console log
ci/woodpecker/push/woodpecker Pipeline was successful Details
ci/woodpecker/pr/woodpecker Pipeline was successful Details
9 months ago
OFF0 889a49e0ef
links: remove trailing slash in link text
due to URL formatting, the link text of https://example.com was
showing as example.com/

removed trailing slash if possible.
9 months ago
OFF0 7539d11a56
contact: show timeline of only followed contacts
added home and global feed, home will try to show timeline with
all followed contacts and fallback to global if there are no
followees.

in a future commit global tab could become search and have a
search field at the top.
9 months ago
OFF0 f92cfbbb31
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.
9 months ago
OFF0 208ea6363a
profile: refactor
each view has it's own DOMMap to reference its own elements this
can us non-unique keys, i.e. each view can have a header key.

changed:
- use getViewElem instead of querying the dom
- access button.dataset.id directly before traversing the dom
9 months ago
OFF0 636c4610de
event: align content with definition term 9 months ago
OFF0 b2dc778eeb
error: overlay should spawn over the whole screen 9 months ago
OFF0 052b35155e
feed: subtile vertical rythm improvement 9 months ago
OFF0 a2cf5c90b9
profile: fix displaying website metadata
was using the wrong key and did not update
9 months ago
OFF0 f9fc0162ff
event: remove time in title attribute
time is already shown as an entry in the content area
9 months ago
OFF0 5a31d78a07
content: fix parse links with uppercase characters
ignore case sensitivity when checking for possible links
9 months ago
OFF0 a73af8bb78
event: fix classname on event detail view
still had recommend-server class (copy/paste error)
9 months ago

@ -1,29 +1,99 @@
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[];
} = {};
const hasOwnContactList = () => {
return !!contactHistoryMap[config.pubkey];
};
/**
* returns true if user is following pubkey
*/
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 === pubkey);
};
export const updateFollowBtn = (pubkey: string) => {
const followBtn = getViewElem(`followBtn-${pubkey}`);
const view = getViewOptions();
if (followBtn && (view.type === 'contacts' || view.type === 'profile')) {
const hasContact = isFollowing(pubkey);
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;
}
};
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));
}
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]);
}
};
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 +102,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 +118,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 +127,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 +140,145 @@ 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)];
};
/**
* 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) {
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 (pubkey: string) => {
const followBtn = getViewElem(`followBtn-${pubkey}`) as HTMLButtonElement;
const statusElem = getViewElem(`followStatus-${pubkey}`) as HTMLElement;
if (!followBtn || !statusElem) {
return;
}
const following = contactHistoryMap[config.pubkey]?.at(0);
const unsignedEvent = {
kind: 3,
pubkey: config.pubkey,
content: '',
tags: updateContactTags(pubkey, 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.

@ -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>
@ -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">
@ -102,11 +102,13 @@
<!-- views are inserted here -->
</main>
<nav>
<a href="/"><!--<span>X</span>-->feed</a>
<a href="/">home</a>
<a href="/feed">global</a>
<span class="spacer"></span>
<button tpye="button" name="settings">settings</button>
</nav>
</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 {sub24hFeed, subEventID, subNote, subProfile} from './subscriptions'
import {getReplyTo, hasEventTag, isMention, sortByCreatedAt, sortEventCreatedAt} from './events';
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 {getContactUpdateMessage, setContactList, updateContactList} 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
@ -55,7 +67,6 @@ const renderFeed = bounce(() => {
.forEach(renderNote);
break;
case 'profile':
const isEvent = <T>(evt?: T): evt is T => evt !== undefined;
[
...textNoteList // get notes
.filter(note => note.pubkey === view.id),
@ -65,9 +76,24 @@ 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 = getOwnContacts();
[
...textNoteList
.filter(note => ids.includes(note.pubkey)),
...replyList // search id in notes and replies
.filter(reply => ids.includes(reply.pubkey))
.map(reply => textNoteList.find(note => note.id === reply.replyTo))
.filter(isEvent),
]
.sort(sortByCreatedAt)
.reverse()
.forEach(renderNote);
break;
case 'feed':
const now = Math.floor(Date.now() * 0.001);
@ -82,12 +108,16 @@ 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)
const renderReply = (evt: EventWithNip19AndReplyTo) => {
const parent = getViewElem(evt.replyTo);
if (!parent) { // root article has not been rendered
if (!parent || getViewElem(evt.id)) {
return;
}
let replyContainer = parent.querySelector('.mbox-replies');
@ -110,7 +140,6 @@ const handleReply = (evt: EventWithNip19, relay: string) => {
}
const replyTo = getReplyTo(evt);
if (!replyTo) {
console.warn('expected to find reply-to-event-id', evt);
return;
}
const evtWithReplyTo = {replyTo, ...evt};
@ -124,7 +153,7 @@ const handleTextNote = (evt: Event, relay: string) => {
return;
}
if (eventRelayMap[evt.id]) {
eventRelayMap[evt.id] = [...(eventRelayMap[evt.id]), relay]; // TODO: just push?
eventRelayMap[evt.id] = [...(eventRelayMap[evt.id]), relay]; // TODO: remove eventRelayMap and just check for getViewElem?
} else {
eventRelayMap[evt.id] = [relay];
const evtWithNip19 = {
@ -145,38 +174,45 @@ const handleTextNote = (evt: Event, relay: string) => {
}
};
config.rerenderFeed = () => {
const rerenderFeed = () => {
clearView();
renderFeed();
};
config.rerenderFeed = rerenderFeed;
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) => {
@ -198,12 +234,12 @@ const handleRecommendServer = (evt: Event, relay: string) => {
};
const onEventDetails = (evt: Event, relay: string) => {
if (getViewElem(`detail-${evt.id}`)) {
if (getViewElem(evt.id)) {
return;
}
const art = renderEventDetails(evt, relay);
getViewContent().append(art);
setViewElem(`detail-${evt.id}`, art);
const article = renderEventDetails(evt, relay);
getViewContent().append(article);
setViewElem(evt.id, article);
};
const onEvent = (evt: Event, relay: string) => {
@ -230,8 +266,19 @@ const onEvent = (evt: Event, relay: string) => {
// subscribe and change view
const route = (path: string) => {
if (path === '/') {
sub24hFeed(onEvent);
view('/', {type: 'feed'});
const contactList = getOwnContacts();
if (contactList.length) {
subPubkeys(contactList, onEvent);
view(`/`, {type: 'home'});
} else {
subGlobalFeed(onEvent);
view('/feed', {type: 'feed'});
}
return;
}
if (path === '/feed') {
subGlobalFeed(onEvent);
view('/feed', {type: 'feed'});
} else if (path.length === 64 && path.match(/^\/[0-9a-z]+$/)) {
const {type, data} = nip19.decode(path.slice(1));
if (typeof data !== 'string') {
@ -246,22 +293,31 @@ const route = (path: string) => {
case 'npub':
subProfile(data, onEvent);
view(path, {type: 'profile', id: data});
updateFollowBtn(data);
break;
default:
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
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) {
@ -286,8 +342,11 @@ const handleLink = (a: HTMLAnchorElement, e: MouseEvent) => {
}
if (
href === '/'
|| href.startsWith('/feed')
|| href.startsWith('/note')
|| href.startsWith('/npub')
|| href.startsWith('/contacts/npub')
|| (href.startsWith('/') && href.length === 65)
) {
route(href);
history.pushState({}, '', href);
@ -306,17 +365,26 @@ const handleButton = (button: HTMLButtonElement) => {
case 'back':
closePublishView();
return;
case 'import':
resetContactList(config.pubkey);
rerenderFeed();
subOwnContacts(onEvent);
subGlobalFeed(onEvent);
return;
}
const id = (button.closest('[data-id]') as HTMLElement)?.dataset.id;
const id = button.dataset.id || (button.closest('[data-id]') as HTMLElement)?.dataset.id;
if (id) {
switch(button.name) {
case 'reply':
openWriteInput(button, id);
break;
return;
case 'star':
const note = replyList.find(r => r.id === id) || textNoteList.find(n => n.id === (id));
note && handleUpvote(note);
break;
const note = replyList.find(r => r.id === id) || textNoteList.find(n => n.id === (id));
note && handleUpvote(note);
return;
case 'follow':
followContact(id);
return;
}
}
// const container = e.target.closest('[data-append]');

@ -1,7 +1,7 @@
import {Event} from 'nostr-tools';
import {elem, elemCanvas, parseTextContent} from './utils/dom';
import {getNoxyUrl} from './utils/url';
import {getViewContent, getViewElem} from './view';
import {getViewElem, getViewOptions} from './view';
import {parseJSON} from './media';
import {sortByCreatedAt} from './events';
@ -37,7 +37,7 @@ const transformMetadata = (data: unknown): Profile | undefined => {
name,
...(hasAboutString && {about: data.about as string}),
...(hasPictureString && {picture: data.picture as string}),
...(hasWebsite && {hasWebsite: data.website as string})
...(hasWebsite && {website: data.website as string})
};
};
@ -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,14 +112,13 @@ 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) => {
const content = getViewContent();
const header = getViewElem(pubkey);
const header = getViewElem('header');
const metadata = profileMap[pubkey];
if (!content || !header || !metadata) {
if (!header || !metadata) {
return;
}
if (metadata.name) {
@ -119,11 +130,21 @@ export const renderProfile = (pubkey: string) => {
header.append(elem('h1', {}, metadata.name));
}
}
if (metadata.about) {
const detail = getViewElem(`detail-${pubkey}`);
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]');
if (website) {
const url = metadata.website.toLowerCase().startsWith('http') ? metadata.website : `https://${metadata.website}`;
const [content] = parseTextContent(url);
website.replaceChildren(...content);
(website as HTMLDivElement).hidden = false;
} else {
detail.append(elem('div', {data: {website: ''}}, metadata.name));
}
}
};

@ -50,7 +50,7 @@ const subscribe = (
});
if (unsub) {
sub.on('eose', () => {
console.log('eose', relay.url);
// console.log('eose', relay.url);
unsubscribe(sub);
});
}
@ -69,7 +69,7 @@ export const subOnce = (
if (relay) {
const sub = subscribe(obj.cb, obj.filter, relay);
sub.on('eose', () => {
console.log('eose', obj.relay);
// console.log('eose', obj.relay);
unsubscribe(sub);
});
}

@ -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);
@ -49,9 +50,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);
@ -60,33 +58,77 @@ 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;
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 {
color: var(--color-accent);
white-space: nowrap;
}
.mbox-username {
font-weight: 600;
}
.mbox-kind0-name {
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);
}
.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,

@ -5,6 +5,7 @@
display: flex;
flex-direction: column;
left: 0;
max-width: 100vw;
overflow: auto;
padding: var(--gap);
position: fixed;

@ -117,19 +117,39 @@ 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;
}
.secondary:disabled {
border-color: var(--color-accent);
color: var(--color-accent);
}
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,14 +101,15 @@ body {
@media (orientation: portrait) {
body {
font-size: 1.4rem;
line-height: 1.5;
line-height: 1.428571428571429;
}
}
html, body {
min-height: 100%;
height: 100%;
margin: 0;
min-height: 100%;
overflow: clip;
}
h1, h2, h3, h4, h5 { font-weight: normal; }
@ -133,16 +143,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 .highlight {
color: var(--color);
}
a:focus {
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;
@ -156,7 +175,7 @@ img[alt] {
pre {
margin: 0;
padding: .5rem 0;
padding: 0;
}
dl {
@ -166,6 +185,7 @@ dl {
}
dt {
color: var(--color-accent);
grid-column-start: 1;
}

@ -24,14 +24,16 @@ aside {
}
nav {
align-items: center;
background-color: var(--bgcolor-nav);
display: flex;
flex-direction: row;
flex-grow: 1;
flex-shrink: 0;
justify-content: space-between;
min-height: 4.6rem;
overflow-y: auto;
padding: 0 1.5rem;
padding: .2rem 1.5rem;
user-select: none;
-webkit-user-select: none;
}
@ -42,6 +44,7 @@ nav {
}
@media (orientation: landscape) {
nav {
align-items: stretch;
flex-direction: column;
justify-content: space-between;
}
@ -51,6 +54,8 @@ nav button {
--bgcolor-accent: transparent;
--border-color: transparent;
border-radius: 0;
color: inherit;
font-weight: bold;
padding: 1rem;
}
@media (orientation: landscape) {
@ -58,6 +63,17 @@ nav button {
nav button {
padding: 2rem 0;
}
nav .spacer {
flex-grow: 1;
}
nav button:last-child {
margin-bottom: .4rem;
}
}
@media (orientation: portrait) {
nav .spacer {
display: none;
}
}
.view {
@ -121,8 +137,6 @@ nav .content {
justify-content: space-between;
}
nav a {
display: flex;
flex-direction: column;
text-align: center;
text-decoration: none;
}
@ -135,19 +149,36 @@ 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;
font-size: 2.1rem;
line-height: 1.285714285714286;
margin-bottom: 0;
margin-top: 2rem;
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;
font-size: 1.1rem;
line-height: 1.36363636;
max-width: 100%;
overflow: clip;
text-align: center;
@ -155,7 +186,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 +195,7 @@ nav a {
.hero footer {
padding-left: var(--extra-space);
}
.hero footer a {
text-decoration: none;
}

@ -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';
@ -8,8 +8,58 @@ type SubCallback = (
relay: string,
) => void;
export const subPubkeys = (
pubkeys: string[],
onEvent: SubCallback,
) => {
const authorsPrefixes = pubkeys.map(pubkey => pubkey.slice(0, 32));
console.info(`subscribe to homefeed ${authorsPrefixes}`);
unsubAll();
const repliesTo = new Set<string>();
sub({
cb: (evt, relay) => {
if (
evt.tags.some(hasEventTag)
&& !evt.tags.some(isMention)
) {
const note = getReplyTo(evt); // get all reply to events instead?
if (note && !repliesTo.has(note)) {
repliesTo.add(note);
subOnce({
cb: onEvent,
filter: {
ids: [note],
kinds: [1],
limit: 1,
},
relay,
});
}
}
onEvent(evt, relay);
},
filter: {
authors: authorsPrefixes,
kinds: [1],
limit: 20,
},
});
// get metadata
sub({
cb: onEvent,
filter: {
authors: pubkeys,
kinds: [0],
limit: pubkeys.length,
},
unsub: true,
});
};
/** subscribe to global feed */
export const sub24hFeed = (onEvent: SubCallback) => {
export const subGlobalFeed = (onEvent: SubCallback) => {
console.info('subscribe to global feed');
unsubAll();
const now = Math.floor(Date.now() * 0.001);
const pubkeys = new Set<string>();
@ -132,15 +182,17 @@ export const subNote = (
});
};
replies.add(eventId)
sub({
cb: onReply,
filter: {
'#e': [eventId],
kinds: [1, 7],
},
unsub: true,
});
replies.add(eventId);
setTimeout(() => {
sub({
cb: onReply,
filter: {
'#e': [eventId],
kinds: [1, 7],
},
unsub: true, // TODO: probably keep this subscription also after onReply/unsubAll
});
}, 200);
};
/** subscribe to npub key (nip-19) */
@ -194,9 +246,9 @@ export const subProfile = (
sub({
cb: onEvent,
filter: {
authors: [pubkey],
authors: [pubkey, config.pubkey],
kinds: [3],
limit: 3,
limit: 6,
},
});
}, 100);
@ -206,11 +258,71 @@ export const subEventID = (
id: string,
onEvent: SubCallback,
) => {
unsubAll();
sub({
cb: onEvent,
filter: {
ids: [id],
limit: 1,
},
unsub: true,
});
sub({
cb: onEvent,
filter: {
authors: [id],
limit: 200,
},
unsub: true,
});
};
export const subOwnContacts = (onEvent: SubCallback) => {
sub({
cb: onEvent,
filter: {
authors: [config.pubkey],
kinds: [3],
limit: 1,
},
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();

@ -7,13 +7,18 @@ export type DOMMap = {
};
export type ViewTemplateOptions = {
type: 'feed'
type: 'home';
} | {
type: 'feed';
} | {
type: 'note';
id: string;
} | {
type: 'profile';
id: string;
} | {
type: 'contacts';
id: string;
} | {
type: 'event';
id: string;
@ -23,36 +28,55 @@ export const renderViewTemplate = (options: ViewTemplateOptions) => {
const content = elem('div', {className: 'content'});
const dom: DOMMap = {};
switch (options.type) {
case 'home':
break;
case 'feed':
break;
case 'profile':
const pubkey = options.id;
const detail = elem('p', {data: {'profileDetails': pubkey}});
dom[`detail-${pubkey}`] = detail;
const header = elem('header', {className: 'hero'}, [
elem('small', {}, nip19.npubEncode(pubkey)),
elem('h1', {}, pubkey),
const npub = nip19.npubEncode(pubkey);
const about = elem('span');
const detail = elem('p', {}, about);
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'}, npub),
elem('div', {className: 'hero-title'}, [
elem('h1', {}, pubkey),
followStatus,
followBtn,
]),
detail,
elem('footer', {}, [
elem('span', {data:{following: pubkey}})
])
elem('footer', {}, following),
]);
dom[pubkey] = header;
content.append(header);
dom.header = profileHeader;
dom[`about-${pubkey}`] = about;
dom[`detail-${pubkey}`] = detail;
dom.following = following;
dom[`followStatus-${pubkey}`] = followStatus;
dom[`followBtn-${pubkey}`] = followBtn;
content.append(profileHeader);
document.title = pubkey;
break;
case 'note':
break;
case 'feed':
case 'contacts':
break;
case 'event':
const id = options.id;
const eventHeader = elem('header', {className: 'hero'}, [
elem('h1', {}, id),
]);
dom[id] = eventHeader;
content.append(eventHeader);
content.append(
elem('header', {className: 'hero'}, [
elem('h1', {}, id),
])
);
document.title = id;
break;
}
const view = elem('section', {className: 'view'}, [content]);
return {content, dom, view};
};
};

@ -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) => {
@ -100,8 +101,6 @@ export const renderUpdateContact = (
' ',
elem('span', {data: {contacts: evt.id}, title: time.toISOString()}, evt.content),
]),
elem('div', {className: 'mbox-content'}, [
]),
]),
], {
className: 'mbox-updated-contact',
@ -131,18 +130,17 @@ export const renderRecommendServer = (evt: Event, relay: string) => {
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)
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', title: dateTime.format(time)}, [
const body = elem('div', {className: 'mbox-body'}, [
elem('header', {className: 'mbox-header'}, [
elem('a', {
className: `mbox-username${name ? ' mbox-kind0-name' : ''}`,
@ -178,7 +176,50 @@ export const renderEventDetails = (evt: Event, relay: string) => {
elem('a', {className: 'mbox-img', href: `/${npub}`, tabIndex: -1}, img),
body,
], {
className: 'mbox-recommend-server',
className: 'mbox-plain-event',
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},
});
};

@ -104,11 +104,12 @@ export const parseTextContent = (
return elem('a', {href: `/${npub}`, data: {profile: data}}, data.slice(6, 15))
}
}
if (!word.match(/^(https?:\/\/|www\.)\S*/)) {
const WORD = word.toUpperCase();
if (!WORD.match(/^(HTTPS?:\/\/|WWW\.)\S*/)) {
return word;
}
try {
if (!word.startsWith('http')) {
if (!WORD.startsWith('HTTP')) {
word = 'https://' + word;
}
const url = new URL(word);
@ -116,11 +117,12 @@ export const parseTextContent = (
return word;
}
firstLink = firstLink || url.href;
const prettierWithoutSlash = url.pathname === '/';
return elem('a', {
href: url.href,
target: '_blank',
rel: 'noopener noreferrer'
}, url.href.slice(url.protocol.length + 2));
}, url.href.slice(url.protocol.length + 2, prettierWithoutSlash ? -1 : undefined));
} catch (err) {
return word;
}

@ -60,8 +60,8 @@ type GetViewOptions = () => ViewTemplateOptions;
/**
* get options for current view
* @returns {id: '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