Compare commits

..

No commits in common. '4cbe6f6cdeabd673ed2a1ec5814c764a2ae50532' and '5a31d78a0761c7d07faadb78431a68a1bb6944e4' have entirely different histories.

@ -1,69 +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[]
} = {};
/**
* 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 view = getViewOptions();
if (evt.pubkey === config.pubkey) {
localStorage.setItem('follwing', JSON.stringify(evt));
}
if (view.type === '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: `/${evt.id}`,
title: dateTime.format(evt.created_at * 1000),
}, [
'following ',
elem('span', {className: 'highlight'}, count),
]);
following.replaceWith(anchor);
setViewElem('following', anchor);
}
}
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);
@ -72,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]);
};
/**
@ -88,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 = (
@ -97,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);
@ -110,129 +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)];
};
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,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.

@ -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,9 +102,7 @@
<!-- 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>

@ -4,13 +4,13 @@ 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} 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, resetContactList, setContactList, updateContactList, updateFollowBtn} from './contacts';
import {getContactUpdateMessage, setContactList, updateContactList} from './contacts';
import {EventWithNip19, EventWithNip19AndReplyTo, textNoteList, replyList} from './notes';
import {createTextNote, renderEventDetails, renderRecommendServer, renderUpdateContact} from './ui';
@ -55,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),
@ -68,20 +69,6 @@ const renderFeed = bounce(() => {
renderProfile(view.id);
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':
const now = Math.floor(Date.now() * 0.001);
textNoteList
@ -100,7 +87,7 @@ const renderFeed = bounce(() => {
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');
@ -123,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};
@ -136,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 = {
@ -157,14 +145,12 @@ 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 (
@ -212,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) => {
@ -243,21 +229,9 @@ const onEvent = (evt: Event, relay: string) => {
// subscribe and change view
const route = (path: string) => {
const contactList = getContacts();
if (path === '/') {
if (contactList.length) {
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'});
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') {
@ -272,7 +246,6 @@ 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`);
@ -288,7 +261,6 @@ const route = (path: string) => {
};
// onload
subOwnContacts(onEvent);
route(location.pathname);
// only push a new entry if there is no history onload
@ -314,10 +286,8 @@ const handleLink = (a: HTMLAnchorElement, e: MouseEvent) => {
}
if (
href === '/'
|| href.startsWith('/feed')
|| href.startsWith('/note')
|| href.startsWith('/npub')
|| href.length === 65
) {
route(href);
history.pushState({}, '', href);
@ -336,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} 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})
};
};
@ -104,9 +104,10 @@ export const getMetadata = (pubkey: string) => {
};
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) {
@ -118,22 +119,11 @@ export const renderProfile = (pubkey: string) => {
header.append(elem('h1', {}, metadata.name));
}
}
const detail = getViewElem('detail');
if (metadata.about) {
const detail = getViewElem(`detail-${pubkey}`);
if (!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));
}
}
};

@ -49,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);
@ -63,38 +66,27 @@ a.mbox-img:focus {
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 {
white-space: nowrap;
}
.mbox-username {
font-weight: 600;
}
.mbox-kind0-name {
color: var(--color-accent);
}
.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,35 +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;
}
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,7 +92,7 @@ body {
@media (orientation: portrait) {
body {
font-size: 1.4rem;
line-height: 1.428571428571429;
line-height: 1.5;
}
}
@ -142,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;
text-decoration: none;
}
a .highlight {
color: var(--color);
}
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;
@ -174,7 +156,7 @@ img[alt] {
pre {
margin: 0;
padding: 0;
padding: .5rem 0;
}
dl {
@ -184,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,29 +135,16 @@ 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;
margin-bottom: 0;
.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);
font-size: 1.1rem;
display: block;
@ -182,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;
}
@ -191,7 +164,3 @@ nav a {
.hero footer {
padding-left: var(--extra-space);
}
.hero footer a {
text-decoration: none;
}

@ -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,33 +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,
});
};

@ -7,9 +7,7 @@ export type DOMMap = {
};
export type ViewTemplateOptions = {
type: 'home';
} | {
type: 'feed';
type: 'feed'
} | {
type: 'note';
id: string;
@ -25,51 +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 detail = elem('p');
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.detail = detail;
dom.following = following;
dom.followStatus = followStatus;
dom.followBtn = followBtn;
content.append(profileHeader);
dom[pubkey] = header;
content.append(header);
document.title = pubkey;
break;
case 'note':
break;
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};
};
};

@ -100,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',
@ -129,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' : ''}`,

@ -117,12 +117,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' | 'event', id?: string}
*/
* @returns {id: 'feed' | 'profile' | 'note' | 'event', id?: string}
*/
export const getViewOptions: GetViewOptions = () => containers[activeContainerIndex]?.options || {type: 'feed'};
/**

Loading…
Cancel
Save