Compare commits

...

10 Commits

Author SHA1 Message Date
OFF0 4cbe6f6cde
links: remove trailing slash in link text
ci/woodpecker/push/woodpecker Pipeline was successful Details
due to URL formatting, the link text of https://example.com was
showing as example.com/

removed trailing slash if possible.
1 year ago
OFF0 83248e803f
styling: update button styling
adding proper primary and secondary button styles.
1 year ago
OFF0 9fe697f2b7
contact: show timeline of only followed contacts
added global link in main nav for showing global feed.

in a future commit global tab will become search.
1 year ago
OFF0 fb1093624d
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.
1 year 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
1 year ago
OFF0 636c4610de
event: align content with definition term 1 year ago
OFF0 b2dc778eeb
error: overlay should spawn over the whole screen 1 year ago
OFF0 052b35155e
feed: subtile vertical rythm improvement 1 year ago
OFF0 a2cf5c90b9
profile: fix displaying website metadata
was using the wrong key and did not update
1 year ago
OFF0 f9fc0162ff
event: remove time in title attribute
time is already shown as an entry in the content area
1 year ago

@ -1,29 +1,69 @@
import {Event, nip19} from 'nostr-tools'; import {Event, nip19, signEvent} from 'nostr-tools';
import {elem} from './utils/dom'; import {elem} from './utils/dom';
import {dateTime} from './utils/time'; import {dateTime} from './utils/time';
import {isPTag, sortByCreatedAt} from './events'; import {isNotNonceTag, isPTag} from './events';
import {getViewContent} from './view'; import {getViewContent, getViewElem, getViewOptions, setViewElem} from './view';
import {powEvent} from './system';
import {config} from './settings';
import {getMetadata} from './profiles'; import {getMetadata} from './profiles';
import {publish} from './relays';
import {parseJSON} from './media';
const contactHistoryMap: { const contactHistoryMap: {
[pubkey: string]: Event[] [pubkey: string]: Event[];
} = {}; } = {};
/**
* returns true if user is following pubkey
*/
export const isFollowing = (id: string) => {
const following = contactHistoryMap[config.pubkey]?.at(0);
if (!following) {
return false;
}
return following.tags.some(([tag, value]) => tag === 'p' && value === id);
};
export const updateFollowBtn = (pubkey: string) => {
const followBtn = getViewElem('followBtn');
if (followBtn) {
const hasContact = isFollowing(pubkey);
followBtn.textContent = hasContact ? 'unfollow' : 'follow';
followBtn.classList.remove('primary', 'secondary');
followBtn.classList.add(hasContact ? 'secondary' : 'primary');
followBtn.hidden = false;
}
};
const updateFollowing = (evt: Event) => { const updateFollowing = (evt: Event) => {
const following = getViewContent().querySelector(`[data-following="${evt.pubkey}"]`); const view = getViewOptions();
if (following) { if (evt.pubkey === config.pubkey) {
const count = evt.tags.filter(isPTag).length; localStorage.setItem('follwing', JSON.stringify(evt));
const anchor = elem('a', { }
data: {following: evt.pubkey}, if (view.type === 'profile') {
href: `/${evt.id}`, updateFollowBtn(view.id);
title: dateTime.format(new Date(evt.created_at * 1000)), if (view.id === evt.pubkey) {
}, `following ${count}`); // update following link
following.replaceWith(anchor); const following = getViewElem('following') as HTMLElement;
if (following) {
const count = evt.tags.filter(isPTag).length;
const anchor = elem('a', {
data: {following: evt.pubkey},
href: `/${evt.id}`,
title: dateTime.format(evt.created_at * 1000),
}, [
'following ',
elem('span', {className: 'highlight'}, count),
]);
following.replaceWith(anchor);
setViewElem('following', anchor);
}
}
} }
}; };
export const setContactList = (evt: Event) => { export const setContactList = (evt: Event) => {
let contactHistory = contactHistoryMap[evt.pubkey]; const contactHistory = contactHistoryMap[evt.pubkey];
if (!contactHistory) { if (!contactHistory) {
contactHistoryMap[evt.pubkey] = [evt]; contactHistoryMap[evt.pubkey] = [evt];
updateFollowing(evt); updateFollowing(evt);
@ -32,9 +72,8 @@ export const setContactList = (evt: Event) => {
if (contactHistory.find(({id}) => id === evt.id)) { if (contactHistory.find(({id}) => id === evt.id)) {
return; return;
} }
contactHistory.push(evt); contactHistory.unshift(evt);
contactHistory.sort(sortByCreatedAt); updateFollowing(contactHistory[0]); // TODO: ensure that this is newest contactlist?
updateFollowing(contactHistory[0]);
}; };
/** /**
@ -49,26 +88,8 @@ const findChanges = (current: Event, previous: Event) => {
return [addedContacts, removedContacts]; return [addedContacts, removedContacts];
}; };
export const updateContactList = (evt: Event) => { export const resetContactList = (pubkey: string) => {
const contactHistory = contactHistoryMap[evt.pubkey]; delete contactHistoryMap[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 = ( export const getContactUpdateMessage = (
@ -76,7 +97,6 @@ export const getContactUpdateMessage = (
removedList: string[][], removedList: string[][],
) => { ) => {
const content = []; const content = [];
// console.log(addedContacts)
if (addedList.length && addedList[0]) { if (addedList.length && addedList[0]) {
const pubkey = addedList[0][1]; const pubkey = addedList[0][1];
const {userName} = getMetadata(pubkey); const {userName} = getMetadata(pubkey);
@ -90,7 +110,129 @@ export const getContactUpdateMessage = (
content.push(` (+ ${addedList.length - 1} others)`); content.push(` (+ ${addedList.length - 1} others)`);
} }
if (removedList?.length > 0) { 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; 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)];
};
export const getContacts = () => {
const following = contactHistoryMap[config.pubkey]?.at(0); // TODO: ensure newest contactlist
if (following) {
return following.tags
.filter(isPTag)
.map(([, pubkey]) => pubkey);
}
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 (id: string) => {
const followBtn = getViewElem('followBtn') as HTMLButtonElement;
const statusElem = getViewElem('followStatus') as HTMLElement;
if (!followBtn || !statusElem) {
return;
}
const following = contactHistoryMap[config.pubkey]?.at(0);
const unsignedEvent = {
kind: 3,
pubkey: config.pubkey,
content: '',
tags: updateContactTags(id, 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 {Event} from 'nostr-tools';
import {zeroLeadingBitsCount} from './utils/crypto'; 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 isMention = ([tag, , , marker]: string[]) => tag === 'e' && marker === 'mention';
export const isPTag = ([tag]: string[]) => tag === 'p'; export const isPTag = ([tag]: string[]) => tag === 'p';
export const hasEventTag = (tag: string[]) => tag[0] === 'e'; 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. * validate proof-of-work of a nostr event per nip-13.

@ -24,8 +24,8 @@
<legend>write a new note</legend> <legend>write a new note</legend>
<textarea name="message" rows="1"></textarea> <textarea name="message" rows="1"></textarea>
<div class="buttons"> <div class="buttons">
<button type="submit" id="publish" disabled>send</button> <button type="submit" id="publish" class="primary" disabled>send</button>
<button type="button" name="back">back</button> <button type="button" name="back" class="primary">back</button>
</div> </div>
<small id="sendstatus" class="form-status"></small> <small id="sendstatus" class="form-status"></small>
</fieldset> </fieldset>
@ -42,7 +42,7 @@
<input type="url" name="picture" id="profile_picture" placeholder="https://your.domain/image.png"> <input type="url" name="picture" id="profile_picture" placeholder="https://your.domain/image.png">
<div class="buttons"> <div class="buttons">
<small id="profilestatus" class="form-status" hidden></small> <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> </div>
</form> </form>
<form action="#" name="options"> <form action="#" name="options">
@ -86,8 +86,8 @@
<input type="password" id="privatekey" autocomplete="off"> <input type="password" id="privatekey" autocomplete="off">
<div class="buttons"> <div class="buttons">
<small id="keystatus" class="form-status" hidden></small> <small id="keystatus" class="form-status" hidden></small>
<button type="button" name="generate" tabindex="0">new</button> <button type="button" name="generate" class="primary" tabindex="0">new</button>
<button type="button" name="import" tabindex="0" disabled>save</button> <button type="button" name="import" class="primary" tabindex="0" disabled>save</button>
</div> </div>
</form> </form>
<footer class="text"> <footer class="text">
@ -102,7 +102,9 @@
<!-- views are inserted here --> <!-- views are inserted here -->
</main> </main>
<nav> <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> <button tpye="button" name="settings">settings</button>
</nav> </nav>
</div> </div>

@ -4,13 +4,13 @@ import {elem} from './utils/dom';
import {bounce} from './utils/time'; import {bounce} from './utils/time';
import {isWssUrl} from './utils/url'; import {isWssUrl} from './utils/url';
import {closeSettingsView, config, toggleSettingsView} from './settings'; import {closeSettingsView, config, toggleSettingsView} from './settings';
import {sub24hFeed, subEventID, subNote, subProfile} from './subscriptions' import {subGlobalFeed, subEventID, subNote, subProfile, subPubkeys, subOwnContacts} from './subscriptions'
import {getReplyTo, hasEventTag, isMention, sortByCreatedAt, sortEventCreatedAt} from './events'; import {getReplyTo, hasEventTag, isEvent, isMention, sortByCreatedAt, sortEventCreatedAt} from './events';
import {clearView, getViewContent, getViewElem, getViewOptions, setViewElem, view} from './view'; import {clearView, getViewContent, getViewElem, getViewOptions, setViewElem, view} from './view';
import {handleReaction, handleUpvote} from './reactions'; import {handleReaction, handleUpvote} from './reactions';
import {closePublishView, openWriteInput, togglePublishView} from './write'; import {closePublishView, openWriteInput, togglePublishView} from './write';
import {handleMetadata, renderProfile} from './profiles'; import {handleMetadata, renderProfile} from './profiles';
import {getContactUpdateMessage, setContactList, updateContactList} from './contacts'; import {followContact, getContactUpdateMessage, getContacts, resetContactList, setContactList, updateContactList, updateFollowBtn} from './contacts';
import {EventWithNip19, EventWithNip19AndReplyTo, textNoteList, replyList} from './notes'; import {EventWithNip19, EventWithNip19AndReplyTo, textNoteList, replyList} from './notes';
import {createTextNote, renderEventDetails, renderRecommendServer, renderUpdateContact} from './ui'; import {createTextNote, renderEventDetails, renderRecommendServer, renderUpdateContact} from './ui';
@ -55,7 +55,6 @@ const renderFeed = bounce(() => {
.forEach(renderNote); .forEach(renderNote);
break; break;
case 'profile': case 'profile':
const isEvent = <T>(evt?: T): evt is T => evt !== undefined;
[ [
...textNoteList // get notes ...textNoteList // get notes
.filter(note => note.pubkey === view.id), .filter(note => note.pubkey === view.id),
@ -69,6 +68,20 @@ const renderFeed = bounce(() => {
renderProfile(view.id); renderProfile(view.id);
break; break;
case 'home':
const ids = getContacts();
[
...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': case 'feed':
const now = Math.floor(Date.now() * 0.001); const now = Math.floor(Date.now() * 0.001);
textNoteList textNoteList
@ -87,7 +100,7 @@ const renderFeed = bounce(() => {
const renderReply = (evt: EventWithNip19AndReplyTo) => { const renderReply = (evt: EventWithNip19AndReplyTo) => {
const parent = getViewElem(evt.replyTo); const parent = getViewElem(evt.replyTo);
if (!parent) { // root article has not been rendered if (!parent || getViewElem(evt.id)) {
return; return;
} }
let replyContainer = parent.querySelector('.mbox-replies'); let replyContainer = parent.querySelector('.mbox-replies');
@ -110,7 +123,6 @@ const handleReply = (evt: EventWithNip19, relay: string) => {
} }
const replyTo = getReplyTo(evt); const replyTo = getReplyTo(evt);
if (!replyTo) { if (!replyTo) {
console.warn('expected to find reply-to-event-id', evt);
return; return;
} }
const evtWithReplyTo = {replyTo, ...evt}; const evtWithReplyTo = {replyTo, ...evt};
@ -124,7 +136,7 @@ const handleTextNote = (evt: Event, relay: string) => {
return; return;
} }
if (eventRelayMap[evt.id]) { 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 { } else {
eventRelayMap[evt.id] = [relay]; eventRelayMap[evt.id] = [relay];
const evtWithNip19 = { const evtWithNip19 = {
@ -145,12 +157,14 @@ const handleTextNote = (evt: Event, relay: string) => {
} }
}; };
config.rerenderFeed = () => { const rerenderFeed = () => {
clearView(); clearView();
renderFeed(); renderFeed();
}; };
config.rerenderFeed = rerenderFeed;
const handleContactList = (evt: Event, relay: string) => { const handleContactList = (evt: Event, relay: string) => {
// TODO: if newer and view.type === 'home' rerenderFeed()
setContactList(evt); setContactList(evt);
const view = getViewOptions(); const view = getViewOptions();
if ( if (
@ -198,12 +212,12 @@ const handleRecommendServer = (evt: Event, relay: string) => {
}; };
const onEventDetails = (evt: Event, relay: string) => { const onEventDetails = (evt: Event, relay: string) => {
if (getViewElem(`detail-${evt.id}`)) { if (getViewElem(evt.id)) {
return; return;
} }
const art = renderEventDetails(evt, relay); const article = renderEventDetails(evt, relay);
getViewContent().append(art); getViewContent().append(article);
setViewElem(`detail-${evt.id}`, art); setViewElem(evt.id, article);
}; };
const onEvent = (evt: Event, relay: string) => { const onEvent = (evt: Event, relay: string) => {
@ -229,9 +243,21 @@ const onEvent = (evt: Event, relay: string) => {
// subscribe and change view // subscribe and change view
const route = (path: string) => { const route = (path: string) => {
const contactList = getContacts();
if (path === '/') { if (path === '/') {
sub24hFeed(onEvent); if (contactList.length) {
view('/', {type: 'feed'}); const {pubkey} = config;
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]+$/)) { } else if (path.length === 64 && path.match(/^\/[0-9a-z]+$/)) {
const {type, data} = nip19.decode(path.slice(1)); const {type, data} = nip19.decode(path.slice(1));
if (typeof data !== 'string') { if (typeof data !== 'string') {
@ -246,6 +272,7 @@ const route = (path: string) => {
case 'npub': case 'npub':
subProfile(data, onEvent); subProfile(data, onEvent);
view(path, {type: 'profile', id: data}); view(path, {type: 'profile', id: data});
updateFollowBtn(data);
break; break;
default: default:
console.warn(`type ${type} not yet supported`); console.warn(`type ${type} not yet supported`);
@ -261,6 +288,7 @@ const route = (path: string) => {
}; };
// onload // onload
subOwnContacts(onEvent);
route(location.pathname); route(location.pathname);
// only push a new entry if there is no history onload // only push a new entry if there is no history onload
@ -286,8 +314,10 @@ const handleLink = (a: HTMLAnchorElement, e: MouseEvent) => {
} }
if ( if (
href === '/' href === '/'
|| href.startsWith('/feed')
|| href.startsWith('/note') || href.startsWith('/note')
|| href.startsWith('/npub') || href.startsWith('/npub')
|| href.length === 65
) { ) {
route(href); route(href);
history.pushState({}, '', href); history.pushState({}, '', href);
@ -306,17 +336,26 @@ const handleButton = (button: HTMLButtonElement) => {
case 'back': case 'back':
closePublishView(); closePublishView();
return; 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) { if (id) {
switch(button.name) { switch(button.name) {
case 'reply': case 'reply':
openWriteInput(button, id); openWriteInput(button, id);
break; return;
case 'star': case 'star':
const note = replyList.find(r => r.id === id) || textNoteList.find(n => n.id === (id)); const note = replyList.find(r => r.id === id) || textNoteList.find(n => n.id === (id));
note && handleUpvote(note); note && handleUpvote(note);
break; return;
case 'follow':
followContact(id);
return;
} }
} }
// const container = e.target.closest('[data-append]'); // const container = e.target.closest('[data-append]');

@ -1,7 +1,7 @@
import {Event} from 'nostr-tools'; import {Event} from 'nostr-tools';
import {elem, elemCanvas, parseTextContent} from './utils/dom'; import {elem, elemCanvas, parseTextContent} from './utils/dom';
import {getNoxyUrl} from './utils/url'; import {getNoxyUrl} from './utils/url';
import {getViewContent, getViewElem} from './view'; import {getViewElem} from './view';
import {parseJSON} from './media'; import {parseJSON} from './media';
import {sortByCreatedAt} from './events'; import {sortByCreatedAt} from './events';
@ -37,7 +37,7 @@ const transformMetadata = (data: unknown): Profile | undefined => {
name, name,
...(hasAboutString && {about: data.about as string}), ...(hasAboutString && {about: data.about as string}),
...(hasPictureString && {picture: data.picture as string}), ...(hasPictureString && {picture: data.picture as string}),
...(hasWebsite && {hasWebsite: data.website as string}) ...(hasWebsite && {website: data.website as string})
}; };
}; };
@ -104,10 +104,9 @@ export const getMetadata = (pubkey: string) => {
}; };
export const renderProfile = (pubkey: string) => { export const renderProfile = (pubkey: string) => {
const content = getViewContent(); const header = getViewElem('header');
const header = getViewElem(pubkey);
const metadata = profileMap[pubkey]; const metadata = profileMap[pubkey];
if (!content || !header || !metadata) { if (!header || !metadata) {
return; return;
} }
if (metadata.name) { if (metadata.name) {
@ -119,11 +118,22 @@ export const renderProfile = (pubkey: string) => {
header.append(elem('h1', {}, metadata.name)); header.append(elem('h1', {}, metadata.name));
} }
} }
const detail = getViewElem('detail');
if (metadata.about) { if (metadata.about) {
const detail = getViewElem(`detail-${pubkey}`);
if (!detail.children.length) { if (!detail.children.length) {
const [content] = parseTextContent(metadata.about); const [content] = parseTextContent(metadata.about);
detail?.append(...content); 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));
}
}
}; };

@ -49,9 +49,6 @@ a.mbox-img:focus {
padding-bottom: var(--gap-half); padding-bottom: var(--gap-half);
word-break: break-word; word-break: break-word;
} }
.mbox-content a {
text-decoration: underline;
}
.mbox-img + .mbox-body { .mbox-img + .mbox-body {
--max-width: calc(100% - var(--profileimg-size) - var(--gap-half)); --max-width: calc(100% - var(--profileimg-size) - var(--gap-half));
flex-basis: var(--max-width); flex-basis: var(--max-width);
@ -66,27 +63,38 @@ a.mbox-img:focus {
display: flex; display: flex;
gap: var(--gap-quarter); gap: var(--gap-quarter);
justify-content: space-between; justify-content: space-between;
margin-top: 0; margin: .1rem 0;
min-height: 1.8rem; min-height: 1.8rem;
} }
.mbox-header a { .mbox-header a {
font-size: var(--font-small); font-size: var(--font-small);
line-height: var(--lineheight-small);
text-decoration: none;
} }
.mbox-header small { .mbox-header small {
white-space: nowrap; white-space: nowrap;
} }
.mbox-username { .mbox-username {
font-weight: 600;
} }
.mbox-kind0-name { .mbox-kind0-name {
color: var(--color-accent); color: var(--color-accent);
} }
.mbox-updated-contact,
.mbox-recommend-server {
padding-bottom: var(--gap-quarter);
}
.mbox-updated-contact .mbox-body, .mbox-updated-contact .mbox-body,
.mbox-recommend-server .mbox-body { .mbox-recommend-server .mbox-body {
display: block; display: block;
font-size: var(--font-small); 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, .mbox-updated-contact .mbox-header,

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

@ -117,19 +117,35 @@ form .buttons,
.buttons img, .buttons img,
.buttons small, .buttons small,
.buttons span { .buttons span {
font-weight: normal;
vertical-align: middle; vertical-align: middle;
} }
button { button {
--bg-color: var(--bgcolor-accent); background-color: transparent;
--border-color: var(--bgcolor-accent); border: none;
background-color: var(--bg-color);
border: 0.2rem solid var(--border-color);
border-radius: .2rem;
cursor: pointer; cursor: pointer;
font-weight: bold;
outline-offset: 1px; outline-offset: 1px;
word-break: normal; 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;
}
button:focus { button:focus {
} }

@ -16,6 +16,7 @@
--focus-outline-width: 2px; --focus-outline-width: 2px;
--focus-outline: var(--focus-outline-width) var(--focus-outline-style) var(--focus-outline-color); --focus-outline: var(--focus-outline-width) var(--focus-outline-style) var(--focus-outline-color);
--font-small: 1.2rem; --font-small: 1.2rem;
--lineheight-small: 1.5;
--gap: 2.4rem; --gap: 2.4rem;
--gap-half: 1.2rem; --gap-half: 1.2rem;
--gap-quarter: .6rem; --gap-quarter: .6rem;
@ -41,12 +42,16 @@
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
html { html {
--color: rgb(93, 93, 93); --color: rgb(43, 43, 43);
--color-accent: rgb(130, 130, 130); --color-accent: rgb(118, 118, 118);
--color-accent-line: rgb(163, 163, 163);
--color-danger: #0e0e0e; --color-danger: #0e0e0e;
--color-visited: #7467c4;
--color-visited-line: #9083e3;
--color-inverse: #fff;
--bgcolor: #fff; --bgcolor: #fff;
--bgcolor-nav: gainsboro; --bgcolor-nav: gainsboro;
--bgcolor-accent: #7badfc; --bgcolor-accent: #5194ff;
--bgcolor-danger: rgb(225, 40, 40); --bgcolor-danger: rgb(225, 40, 40);
--bgcolor-danger-input: rgba(255 255 255 / .85); --bgcolor-danger-input: rgba(255 255 255 / .85);
--bgcolor-inactive: #bababa; --bgcolor-inactive: #bababa;
@ -56,15 +61,19 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
html { html {
--color: #e3e3e3; --color: #d9d9d9;
--color-accent: #828282; --color-accent: #828282;
--color-accent-line: #737373;
--color-danger: #e3e3e3; --color-danger: #e3e3e3;
--color-visited: #796ae3;
--color-visited-line: #5d4fce;
--color-inverse: #101010;
--bgcolor: #101010; --bgcolor: #101010;
--bgcolor-nav: rgb(31, 22, 51); --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: rgb(169, 0, 0);
--bgcolor-danger-input: rgba(0 0 0 / .5); --bgcolor-danger-input: rgba(0 0 0 / .5);
--bgcolor-inactive: #202122; --bgcolor-inactive: #353638;
--bgcolor-textinput: #0e0e0e; --bgcolor-textinput: #0e0e0e;
} }
@ -92,7 +101,7 @@ body {
@media (orientation: portrait) { @media (orientation: portrait) {
body { body {
font-size: 1.4rem; font-size: 1.4rem;
line-height: 1.5; line-height: 1.428571428571429;
} }
} }
@ -133,16 +142,25 @@ img {
a { a {
color: var(--color-accent); 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:focus { a .highlight {
color: var(--color);
}
a:focus,
button:focus {
border-radius: var(--focus-border-radius); border-radius: var(--focus-border-radius);
outline: var(--focus-outline); outline: var(--focus-outline);
outline-offset: 0; outline-offset: 0;
} }
a:visited { a:visited {
color: #8377ce; color: var(--color-visited);
text-decoration-color: var(--color-visited-line);
} }
nav a:visited { nav a:visited {
color: inherit; color: inherit;
@ -156,7 +174,7 @@ img[alt] {
pre { pre {
margin: 0; margin: 0;
padding: .5rem 0; padding: 0;
} }
dl { dl {
@ -166,6 +184,7 @@ dl {
} }
dt { dt {
color: var(--color-accent);
grid-column-start: 1; grid-column-start: 1;
} }

@ -24,14 +24,16 @@ aside {
} }
nav { nav {
align-items: center;
background-color: var(--bgcolor-nav); background-color: var(--bgcolor-nav);
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-grow: 1; flex-grow: 1;
flex-shrink: 0; flex-shrink: 0;
justify-content: space-between; justify-content: space-between;
min-height: 4.6rem;
overflow-y: auto; overflow-y: auto;
padding: 0 1.5rem; padding: .2rem 1.5rem;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
} }
@ -42,6 +44,7 @@ nav {
} }
@media (orientation: landscape) { @media (orientation: landscape) {
nav { nav {
align-items: stretch;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
} }
@ -51,6 +54,8 @@ nav button {
--bgcolor-accent: transparent; --bgcolor-accent: transparent;
--border-color: transparent; --border-color: transparent;
border-radius: 0; border-radius: 0;
color: inherit;
font-weight: bold;
padding: 1rem; padding: 1rem;
} }
@media (orientation: landscape) { @media (orientation: landscape) {
@ -58,6 +63,17 @@ nav button {
nav button { nav button {
padding: 2rem 0; padding: 2rem 0;
} }
nav .spacer {
flex-grow: 1;
}
nav button:last-child {
margin-bottom: .4rem;
}
}
@media (orientation: portrait) {
nav .spacer {
display: none;
}
} }
.view { .view {
@ -121,8 +137,6 @@ nav .content {
justify-content: space-between; justify-content: space-between;
} }
nav a { nav a {
display: flex;
flex-direction: column;
text-align: center; text-align: center;
text-decoration: none; text-decoration: none;
} }
@ -135,16 +149,29 @@ nav a {
--extra-space: calc(var(--profileimg-size) + var(--gap-half)); --extra-space: calc(var(--profileimg-size) + var(--gap-half));
padding: var(--gap-half); padding: var(--gap-half);
} }
.hero-title {
.hero h1 { 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;
margin-bottom: 0;
padding-left: var(--extra-space); padding-left: var(--extra-space);
} }
.hero-title button {
line-height: 1;
}
.hero p { .hero p {
max-width: calc(var(--content-width) - var(--extra-space));
padding-left: var(--extra-space); padding-left: var(--extra-space);
} }
.hero small { .hero .hero-npub {
color: var(--color-accent); color: var(--color-accent);
font-size: 1.1rem; font-size: 1.1rem;
display: block; display: block;
@ -155,7 +182,7 @@ nav a {
white-space: nowrap; white-space: nowrap;
} }
@media (min-width: 54ch) { @media (min-width: 54ch) {
.hero small { .hero .hero-npub {
padding-left: var(--extra-space); padding-left: var(--extra-space);
text-align: left; text-align: left;
} }
@ -164,3 +191,7 @@ nav a {
.hero footer { .hero footer {
padding-left: var(--extra-space); padding-left: var(--extra-space);
} }
.hero footer a {
text-decoration: none;
}

@ -8,8 +8,58 @@ type SubCallback = (
relay: string, relay: string,
) => void; ) => 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 */ /** subscribe to global feed */
export const sub24hFeed = (onEvent: SubCallback) => { export const subGlobalFeed = (onEvent: SubCallback) => {
console.info('subscribe to global feed');
unsubAll(); unsubAll();
const now = Math.floor(Date.now() * 0.001); const now = Math.floor(Date.now() * 0.001);
const pubkeys = new Set<string>(); const pubkeys = new Set<string>();
@ -132,15 +182,17 @@ export const subNote = (
}); });
}; };
replies.add(eventId) replies.add(eventId);
sub({ setTimeout(() => {
cb: onReply, sub({
filter: { cb: onReply,
'#e': [eventId], filter: {
kinds: [1, 7], '#e': [eventId],
}, kinds: [1, 7],
unsub: true, },
}); unsub: true, // TODO: probably keep this subscription also after onReply/unsubAll
});
}, 200);
}; };
/** subscribe to npub key (nip-19) */ /** subscribe to npub key (nip-19) */
@ -194,9 +246,9 @@ export const subProfile = (
sub({ sub({
cb: onEvent, cb: onEvent,
filter: { filter: {
authors: [pubkey], authors: [pubkey, config.pubkey],
kinds: [3], kinds: [3],
limit: 3, limit: 6,
}, },
}); });
}, 100); }, 100);
@ -206,11 +258,33 @@ export const subEventID = (
id: string, id: string,
onEvent: SubCallback, onEvent: SubCallback,
) => { ) => {
unsubAll();
sub({ sub({
cb: onEvent, cb: onEvent,
filter: { filter: {
ids: [id], ids: [id],
limit: 1, 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,
}); });
}; };

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

@ -100,8 +100,6 @@ export const renderUpdateContact = (
' ', ' ',
elem('span', {data: {contacts: evt.id}, title: time.toISOString()}, evt.content), elem('span', {data: {contacts: evt.id}, title: time.toISOString()}, evt.content),
]), ]),
elem('div', {className: 'mbox-content'}, [
]),
]), ]),
], { ], {
className: 'mbox-updated-contact', className: 'mbox-updated-contact',
@ -131,18 +129,17 @@ export const renderRecommendServer = (evt: Event, relay: string) => {
export const renderEventDetails = (evt: Event, relay: string) => { export const renderEventDetails = (evt: Event, relay: string) => {
const {img, name, userName} = getMetadata(evt.pubkey); const {img, name, userName} = getMetadata(evt.pubkey);
const time = new Date(evt.created_at * 1000);
const npub = nip19.npubEncode(evt.pubkey); 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) { switch (typeof content) {
case 'object': case 'object':
content = JSON.stringify(content, null, 2); content = JSON.stringify(content, null, 2);
break; break;
default: 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('header', {className: 'mbox-header'}, [
elem('a', { elem('a', {
className: `mbox-username${name ? ' mbox-kind0-name' : ''}`, className: `mbox-username${name ? ' mbox-kind0-name' : ''}`,

@ -117,11 +117,12 @@ export const parseTextContent = (
return word; return word;
} }
firstLink = firstLink || url.href; firstLink = firstLink || url.href;
const prettierWithoutSlash = url.pathname === '/';
return elem('a', { return elem('a', {
href: url.href, href: url.href,
target: '_blank', target: '_blank',
rel: 'noopener noreferrer' rel: 'noopener noreferrer'
}, url.href.slice(url.protocol.length + 2)); }, url.href.slice(url.protocol.length + 2, prettierWithoutSlash ? -1 : undefined));
} catch (err) { } catch (err) {
return word; return word;
} }

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

Loading…
Cancel
Save