Compare commits

..

No commits in common. 'fea8c0bd21811ea17eb1b2ed2130e229a1e0f28d' and 'f3478c4148e7d79d198ef0276932e6efddf79a43' have entirely different histories.

@ -1,99 +1,29 @@
import {Event, nip19, signEvent} from 'nostr-tools';
import {Event, nip19} from 'nostr-tools';
import {elem} from './utils/dom';
import {dateTime} from './utils/time';
import {isNotNonceTag, isPTag} from './events';
import {getViewContent, getViewElem, getViewOptions, setViewElem} from './view';
import {powEvent} from './system';
import {config} from './settings';
import {isPTag, sortByCreatedAt} from './events';
import {getViewContent} from './view';
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 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]);
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) => {
const contactHistory = contactHistoryMap[evt.pubkey];
let contactHistory = contactHistoryMap[evt.pubkey];
if (!contactHistory) {
contactHistoryMap[evt.pubkey] = [evt];
updateFollowing(evt);
@ -102,8 +32,9 @@ export const setContactList = (evt: Event) => {
if (contactHistory.find(({id}) => id === evt.id)) {
return;
}
contactHistory.unshift(evt);
updateFollowing(contactHistory[0]); // TODO: ensure that this is newest contactlist?
contactHistory.push(evt);
contactHistory.sort(sortByCreatedAt);
updateFollowing(contactHistory[0]);
};
/**
@ -118,8 +49,26 @@ const findChanges = (current: Event, previous: Event) => {
return [addedContacts, removedContacts];
};
export const resetContactList = (pubkey: string) => {
delete contactHistoryMap[pubkey];
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 = (
@ -127,6 +76,7 @@ export const getContactUpdateMessage = (
removedList: string[][],
) => {
const content = [];
// console.log(addedContacts)
if (addedList.length && addedList[0]) {
const pubkey = addedList[0][1];
const {userName} = getMetadata(pubkey);
@ -140,145 +90,7 @@ export const getContactUpdateMessage = (
content.push(` (+ ${addedList.length - 1} others)`);
}
if (removedList?.length > 0) {
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));
}
content.push(elem('small', {}, ` and unfollowed ${removedList.length}`));
}
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,11 +1,9 @@
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" class="primary" disabled>send</button>
<button type="button" name="back" class="primary">back</button>
<button type="submit" id="publish" disabled>send</button>
<button type="button" name="back">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" class="primary" tabindex="0" disabled>publish</button>
<button type="submit" name="publish" 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" class="primary" tabindex="0">new</button>
<button type="button" name="import" class="primary" tabindex="0" disabled>save</button>
<button type="button" name="generate" tabindex="0">new</button>
<button type="button" name="import" tabindex="0" disabled>save</button>
</div>
</form>
<footer class="text">
@ -102,13 +102,11 @@
<!-- views are inserted here -->
</main>
<nav>
<a href="/">home</a>
<a href="/feed">global</a>
<span class="spacer"></span>
<a href="/"><!--<span>X</span>-->feed</a>
<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 {subGlobalFeed, subEventID, subNote, subProfile, subPubkeys, subOwnContacts, subContactList} from './subscriptions'
import {getReplyTo, hasEventTag, isEvent, isMention, sortByCreatedAt, sortEventCreatedAt} from './events';
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 {followContact, getContactUpdateMessage, getContacts, getOwnContacts, refreshFollowing, resetContactList, setContactList, updateContactList, updateFollowBtn} from './contacts';
import {getContactUpdateMessage, setContactList, updateContactList} from './contacts';
import {EventWithNip19, EventWithNip19AndReplyTo, textNoteList, replyList} from './notes';
import {createContact, createTextNote, renderEventDetails, renderRecommendServer, renderUpdateContact} from './ui';
import {createTextNote, renderEventDetails, renderRecommendServer, renderUpdateContact} from './ui';
// curl -H 'accept: application/nostr+json' https://relay.nostr.ch/
@ -38,18 +38,6 @@ 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
@ -67,6 +55,7 @@ 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),
@ -76,24 +65,9 @@ const renderFeed = bounce(() => {
]
.sort(sortByCreatedAt)
.reverse()
.forEach(renderNote);
.forEach(renderNote); // render in-reply-to
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);
@ -108,16 +82,12 @@ 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 || getViewElem(evt.id)) {
if (!parent) { // root article has not been rendered
return;
}
let replyContainer = parent.querySelector('.mbox-replies');
@ -140,6 +110,7 @@ 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};
@ -153,7 +124,7 @@ const handleTextNote = (evt: Event, relay: string) => {
return;
}
if (eventRelayMap[evt.id]) {
eventRelayMap[evt.id] = [...(eventRelayMap[evt.id]), relay]; // TODO: remove eventRelayMap and just check for getViewElem?
eventRelayMap[evt.id] = [...(eventRelayMap[evt.id]), relay]; // TODO: just push?
} else {
eventRelayMap[evt.id] = [relay];
const evtWithNip19 = {
@ -174,45 +145,38 @@ const handleTextNote = (evt: Event, relay: string) => {
}
};
const rerenderFeed = () => {
config.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 (
view.type === 'contacts'
&& [view.id, config.pubkey].includes(evt.pubkey) // render if contact-list is from current users or current view
getViewElem(evt.id)
|| view.type !== 'profile'
|| view.id !== evt.pubkey
) {
renderFeed();
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);
// 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) => {
@ -234,12 +198,12 @@ const handleRecommendServer = (evt: Event, relay: string) => {
};
const onEventDetails = (evt: Event, relay: string) => {
if (getViewElem(evt.id)) {
if (getViewElem(`detail-${evt.id}`)) {
return;
}
const article = renderEventDetails(evt, relay);
getViewContent().append(article);
setViewElem(evt.id, article);
const art = renderEventDetails(evt, relay);
getViewContent().append(art);
setViewElem(`detail-${evt.id}`, art);
};
const onEvent = (evt: Event, relay: string) => {
@ -266,19 +230,8 @@ const onEvent = (evt: Event, relay: string) => {
// subscribe and change view
const route = (path: string) => {
if (path === '/') {
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'});
sub24hFeed(onEvent);
view('/', {type: 'feed'});
} else if (path.length === 64 && path.match(/^\/[0-9a-z]+$/)) {
const {type, data} = nip19.decode(path.slice(1));
if (typeof data !== 'string') {
@ -293,31 +246,22 @@ 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) {
@ -342,11 +286,8 @@ 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);
@ -365,26 +306,17 @@ const handleButton = (button: HTMLButtonElement) => {
case 'back':
closePublishView();
return;
case 'import':
resetContactList(config.pubkey);
rerenderFeed();
subOwnContacts(onEvent);
subGlobalFeed(onEvent);
return;
}
const id = button.dataset.id || (button.closest('[data-id]') as HTMLElement)?.dataset.id;
const id = (button.closest('[data-id]') as HTMLElement)?.dataset.id;
if (id) {
switch(button.name) {
case 'reply':
openWriteInput(button, id);
return;
break;
case 'star':
const note = replyList.find(r => r.id === id) || textNoteList.find(n => n.id === (id));
note && handleUpvote(note);
return;
case 'follow':
followContact(id);
return;
const note = replyList.find(r => r.id === id) || textNoteList.find(n => n.id === (id));
note && handleUpvote(note);
break;
}
}
// 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 {getViewElem, getViewOptions} from './view';
import {getViewContent, getViewElem} 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 && {website: data.website as string})
...(hasWebsite && {hasWebsite: data.website as string})
};
};
@ -87,22 +87,10 @@ 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;
@ -112,13 +100,14 @@ export const getMetadata = (pubkey: string) => {
src: userImg,
title: `${userName} on ${host} ${userAbout}`,
}) : */ elemCanvas(pubkey);
return {about, img, name, userName};
return {img, name, userName};
};
export const renderProfile = (pubkey: string) => {
const header = getViewElem('header');
const content = getViewContent();
const header = getViewElem(pubkey);
const metadata = profileMap[pubkey];
if (!header || !metadata) {
if (!content || !header || !metadata) {
return;
}
if (metadata.name) {
@ -130,21 +119,11 @@ export const renderProfile = (pubkey: string) => {
header.append(elem('h1', {}, metadata.name));
}
}
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));
if (metadata.about) {
const detail = getViewElem(`detail-${pubkey}`);
if (!detail.children.length) {
const [content] = parseTextContent(metadata.about);
detail?.append(...content);
}
}
};

@ -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,7 +19,6 @@
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);
@ -50,6 +49,9 @@ 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);
@ -58,77 +60,33 @@ 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: baseline;
align-items: start;
display: flex;
gap: var(--gap-quarter);
justify-content: space-between;
margin: .1rem 0;
margin-top: 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,7 +5,6 @@
display: flex;
flex-direction: column;
left: 0;
max-width: 100vw;
overflow: auto;
padding: var(--gap);
position: fixed;

@ -117,39 +117,19 @@ form .buttons,
.buttons img,
.buttons small,
.buttons span {
font-weight: normal;
vertical-align: middle;
}
button {
background-color: transparent;
border: none;
--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;
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,7 +16,6 @@
--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;
@ -42,16 +41,12 @@
@media (prefers-color-scheme: light) {
html {
--color: rgb(43, 43, 43);
--color-accent: rgb(118, 118, 118);
--color-accent-line: rgb(163, 163, 163);
--color: rgb(93, 93, 93);
--color-accent: rgb(130, 130, 130);
--color-danger: #0e0e0e;
--color-visited: #7467c4;
--color-visited-line: #9083e3;
--color-inverse: #fff;
--bgcolor: #fff;
--bgcolor-nav: gainsboro;
--bgcolor-accent: #5194ff;
--bgcolor-accent: #7badfc;
--bgcolor-danger: rgb(225, 40, 40);
--bgcolor-danger-input: rgba(255 255 255 / .85);
--bgcolor-inactive: #bababa;
@ -61,19 +56,15 @@
@media (prefers-color-scheme: dark) {
html {
--color: #d9d9d9;
--color: #e3e3e3;
--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, 77, 176);
--bgcolor-accent: rgb(16, 93, 176);
--bgcolor-danger: rgb(169, 0, 0);
--bgcolor-danger-input: rgba(0 0 0 / .5);
--bgcolor-inactive: #353638;
--bgcolor-inactive: #202122;
--bgcolor-textinput: #0e0e0e;
}
@ -101,15 +92,14 @@ body {
@media (orientation: portrait) {
body {
font-size: 1.4rem;
line-height: 1.428571428571429;
line-height: 1.5;
}
}
html, body {
min-height: 100%;
height: 100%;
margin: 0;
min-height: 100%;
overflow: clip;
}
h1, h2, h3, h4, h5 { font-weight: normal; }
@ -143,25 +133,16 @@ img {
a {
color: var(--color-accent);
text-decoration-color: var(--color-accent-line);
text-decoration-line: underline;
text-decoration-style: solid;
text-decoration-thickness: 2px;
}
a .highlight {
color: var(--color);
text-decoration: none;
}
a:focus,
button:focus {
a:focus {
border-radius: var(--focus-border-radius);
outline: var(--focus-outline);
outline-offset: 0;
}
a:visited {
color: var(--color-visited);
text-decoration-color: var(--color-visited-line);
color: #8377ce;
}
nav a:visited {
color: inherit;
@ -175,7 +156,7 @@ img[alt] {
pre {
margin: 0;
padding: 0;
padding: .5rem 0;
}
dl {
@ -185,7 +166,6 @@ dl {
}
dt {
color: var(--color-accent);
grid-column-start: 1;
}

@ -24,16 +24,14 @@ 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: .2rem 1.5rem;
padding: 0 1.5rem;
user-select: none;
-webkit-user-select: none;
}
@ -44,7 +42,6 @@ nav {
}
@media (orientation: landscape) {
nav {
align-items: stretch;
flex-direction: column;
justify-content: space-between;
}
@ -54,8 +51,6 @@ nav button {
--bgcolor-accent: transparent;
--border-color: transparent;
border-radius: 0;
color: inherit;
font-weight: bold;
padding: 1rem;
}
@media (orientation: landscape) {
@ -63,17 +58,6 @@ 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 {
@ -137,6 +121,8 @@ nav .content {
justify-content: space-between;
}
nav a {
display: flex;
flex-direction: column;
text-align: center;
text-decoration: none;
}
@ -149,36 +135,19 @@ nav a {
--extra-space: calc(var(--profileimg-size) + var(--gap-half));
padding: var(--gap-half);
}
.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;
.hero h1 {
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 .hero-npub {
.hero small {
color: var(--color-accent);
display: block;
font-size: 1.1rem;
line-height: 1.36363636;
display: block;
max-width: 100%;
overflow: clip;
text-align: center;
@ -186,7 +155,7 @@ nav a {
white-space: nowrap;
}
@media (min-width: 54ch) {
.hero .hero-npub {
.hero small {
padding-left: var(--extra-space);
text-align: left;
}
@ -195,7 +164,3 @@ 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, isPTag} from './events';
import {getReplyTo, hasEventTag, isMention} from './events';
import {config} from './settings';
import {sub, subOnce, unsubAll} from './relays';
@ -8,58 +8,8 @@ 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 subGlobalFeed = (onEvent: SubCallback) => {
console.info('subscribe to global feed');
export const sub24hFeed = (onEvent: SubCallback) => {
unsubAll();
const now = Math.floor(Date.now() * 0.001);
const pubkeys = new Set<string>();
@ -182,17 +132,15 @@ export const subNote = (
});
};
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);
replies.add(eventId)
sub({
cb: onReply,
filter: {
'#e': [eventId],
kinds: [1, 7],
},
unsub: true,
});
};
/** subscribe to npub key (nip-19) */
@ -246,9 +194,9 @@ export const subProfile = (
sub({
cb: onEvent,
filter: {
authors: [pubkey, config.pubkey],
authors: [pubkey],
kinds: [3],
limit: 6,
limit: 3,
},
});
}, 100);
@ -258,71 +206,11 @@ 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,18 +7,13 @@ export type DOMMap = {
};
export type ViewTemplateOptions = {
type: 'home';
} | {
type: 'feed';
type: 'feed'
} | {
type: 'note';
id: string;
} | {
type: 'profile';
id: string;
} | {
type: 'contacts';
id: string;
} | {
type: 'event';
id: string;
@ -28,55 +23,36 @@ 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 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,
]),
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),
detail,
elem('footer', {}, following),
elem('footer', {}, [
elem('span', {data:{following: pubkey}})
])
]);
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);
dom[pubkey] = header;
content.append(header);
document.title = pubkey;
break;
case 'note':
break;
case 'contacts':
case 'feed':
break;
case 'event':
const id = options.id;
content.append(
elem('header', {className: 'hero'}, [
elem('h1', {}, 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};
};
};

@ -2,15 +2,14 @@ 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 {getViewElem, getViewOptions, setViewElem} from './view';
import {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 {isFollowing} from './contacts';
import {parseJSON} from './media';
setInterval(() => {
document.querySelectorAll('time[datetime]').forEach((timeElem: HTMLTimeElement) => {
@ -101,6 +100,8 @@ export const renderUpdateContact = (
' ',
elem('span', {data: {contacts: evt.id}, title: time.toISOString()}, evt.content),
]),
elem('div', {className: 'mbox-content'}, [
]),
]),
], {
className: 'mbox-updated-contact',
@ -130,17 +131,18 @@ 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 = (![1, 7].includes(evt.kind) && evt.content !== '') ? parseJSON(evt.content) : (evt.content || '<empty>');
let content = parseJSON(evt.content)
switch (typeof content) {
case 'object':
content = JSON.stringify(content, null, 2);
break;
default:
content = `${content}`;
content = `${evt.content}`;
}
const body = elem('div', {className: 'mbox-body'}, [
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' : ''}`,
@ -176,50 +178,7 @@ export const renderEventDetails = (evt: Event, relay: string) => {
elem('a', {className: 'mbox-img', href: `/${npub}`, tabIndex: -1}, img),
body,
], {
className: 'mbox-plain-event',
className: 'mbox-recommend-server',
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,12 +104,11 @@ export const parseTextContent = (
return elem('a', {href: `/${npub}`, data: {profile: data}}, data.slice(6, 15))
}
}
const WORD = word.toUpperCase();
if (!WORD.match(/^(HTTPS?:\/\/|WWW\.)\S*/)) {
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);
@ -117,12 +116,11 @@ 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, prettierWithoutSlash ? -1 : undefined));
}, url.href.slice(url.protocol.length + 2));
} catch (err) {
return word;
}

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

Loading…
Cancel
Save