Compare commits

...

4 Commits

Author SHA1 Message Date
OFF0 72aef7bf7a
relays: remove noisy eose console log
ci/woodpecker/push/woodpecker Pipeline was successful Details
9 months ago
OFF0 324dc78c6e
links: remove trailing slash in link text
due to URL formatting, the link text of https://example.com was
showing as example.com/

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

in a future commit global tab could become search and have a
search field at the top.
9 months ago
OFF0 36327f6151
contact: add follow and unfollow support
this creates a kind 3 event that includes a list of profiles that
the user is following.

the feed is still the public global feed and individual feed with
only events from followed pubkeys will be added in the next commit.

also added proper primary and secondary button styles.
9 months ago

@ -1,29 +1,69 @@
import {Event, nip19} from 'nostr-tools';
import {Event, nip19, signEvent} from 'nostr-tools';
import {elem} from './utils/dom';
import {dateTime} from './utils/time';
import {isPTag, sortByCreatedAt} from './events';
import {getViewContent} from './view';
import {isNotNonceTag, isPTag} from './events';
import {getViewContent, getViewElem, getViewOptions, setViewElem} from './view';
import {powEvent} from './system';
import {config} from './settings';
import {getMetadata} from './profiles';
import {publish} from './relays';
import {parseJSON} from './media';
const contactHistoryMap: {
[pubkey: string]: Event[]
[pubkey: string]: Event[];
} = {};
/**
* 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 following = getViewContent().querySelector(`[data-following="${evt.pubkey}"]`);
if (following) {
const count = evt.tags.filter(isPTag).length;
const anchor = elem('a', {
data: {following: evt.pubkey},
href: `/${evt.id}`,
title: dateTime.format(new Date(evt.created_at * 1000)),
}, `following ${count}`);
following.replaceWith(anchor);
const view = getViewOptions();
if (evt.pubkey === config.pubkey) {
localStorage.setItem('follwing', JSON.stringify(evt));
}
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);
}
}
}
};
export const setContactList = (evt: Event) => {
let contactHistory = contactHistoryMap[evt.pubkey];
const contactHistory = contactHistoryMap[evt.pubkey];
if (!contactHistory) {
contactHistoryMap[evt.pubkey] = [evt];
updateFollowing(evt);
@ -32,9 +72,8 @@ export const setContactList = (evt: Event) => {
if (contactHistory.find(({id}) => id === evt.id)) {
return;
}
contactHistory.push(evt);
contactHistory.sort(sortByCreatedAt);
updateFollowing(contactHistory[0]);
contactHistory.unshift(evt);
updateFollowing(contactHistory[0]); // TODO: ensure that this is newest contactlist?
};
/**
@ -49,26 +88,8 @@ const findChanges = (current: Event, previous: Event) => {
return [addedContacts, removedContacts];
};
export const updateContactList = (evt: Event) => {
const contactHistory = contactHistoryMap[evt.pubkey];
if (contactHistory.length === 1) {
return [contactHistory[0].tags.filter(isPTag)];
}
const pos = contactHistory.findIndex(({id}) => id === evt.id);
if (evt.id === contactHistory.at(-1)?.id) { // oldest known contact-list update
// update existing contact entries
contactHistory
.slice(0, -1)
.forEach((entry, i) => {
const previous = contactHistory[i + 1];
const [added, removed] = findChanges(entry, previous);
const contactNote = getViewContent().querySelector(`[data-contacts="${entry.id}"]`);
const updated = getContactUpdateMessage(added, removed);
contactNote?.replaceChildren(...updated);
});
return [evt.tags.filter(isPTag)];
}
return findChanges(evt, contactHistory[pos + 1]);
export const resetContactList = (pubkey: string) => {
delete contactHistoryMap[pubkey];
};
export const getContactUpdateMessage = (
@ -76,7 +97,6 @@ export const getContactUpdateMessage = (
removedList: string[][],
) => {
const content = [];
// console.log(addedContacts)
if (addedList.length && addedList[0]) {
const pubkey = addedList[0][1];
const {userName} = getMetadata(pubkey);
@ -90,7 +110,129 @@ export const getContactUpdateMessage = (
content.push(` (+ ${addedList.length - 1} others)`);
}
if (removedList?.length > 0) {
content.push(elem('small', {}, ` and unfollowed ${removedList.length}`));
if (content.length) {
content.push(' and');
}
content.push(' unfollowed ');
if (removedList.length > 1) {
content.push(`${removedList.length}`);
} else {
const removedPubkey = removedList[0][1];
const {userName: removeduserName} = getMetadata(removedPubkey);
const removedNpub = nip19.npubEncode(removedPubkey);
content.push(elem('a', {href: `/${removedNpub}`, data: {profile: removedPubkey}}, removeduserName));
}
}
return content;
};
export const updateContactList = (evt: Event) => {
const contactHistory = contactHistoryMap[evt.pubkey];
if (contactHistory.length === 1) {
return [contactHistory[0].tags.filter(isPTag)];
}
const pos = contactHistory.findIndex(({id}) => id === evt.id);
if (evt.id !== contactHistory.at(-1)?.id) { // not oldest known contact-list update
return findChanges(evt, contactHistory[pos + 1]);
}
// update existing contact entries
contactHistory
.slice(0, -1)
.forEach((entry, i) => {
const previous = contactHistory[i + 1];
const [added, removed] = findChanges(entry, previous);
const contactNote = getViewContent().querySelector(`[data-contacts="${entry.id}"]`);
const updated = getContactUpdateMessage(added, removed);
contactNote?.replaceChildren(...updated);
});
return [evt.tags.filter(isPTag)];
};
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 {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" disabled>send</button>
<button type="button" name="back">back</button>
<button type="submit" id="publish" class="primary" disabled>send</button>
<button type="button" name="back" class="primary">back</button>
</div>
<small id="sendstatus" class="form-status"></small>
</fieldset>
@ -42,7 +42,7 @@
<input type="url" name="picture" id="profile_picture" placeholder="https://your.domain/image.png">
<div class="buttons">
<small id="profilestatus" class="form-status" hidden></small>
<button type="submit" name="publish" tabindex="0" disabled>publish</button>
<button type="submit" name="publish" class="primary" tabindex="0" disabled>publish</button>
</div>
</form>
<form action="#" name="options">
@ -86,8 +86,8 @@
<input type="password" id="privatekey" autocomplete="off">
<div class="buttons">
<small id="keystatus" class="form-status" hidden></small>
<button type="button" name="generate" tabindex="0">new</button>
<button type="button" name="import" tabindex="0" disabled>save</button>
<button type="button" name="generate" class="primary" tabindex="0">new</button>
<button type="button" name="import" class="primary" tabindex="0" disabled>save</button>
</div>
</form>
<footer class="text">
@ -102,7 +102,9 @@
<!-- views are inserted here -->
</main>
<nav>
<a href="/"><!--<span>X</span>-->feed</a>
<a href="/">home</a>
<a href="/feed">global</a>
<span class="spacer"></span>
<button tpye="button" name="settings">settings</button>
</nav>
</div>

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

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

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

@ -117,19 +117,35 @@ form .buttons,
.buttons img,
.buttons small,
.buttons span {
font-weight: normal;
vertical-align: middle;
}
button {
--bg-color: var(--bgcolor-accent);
--border-color: var(--bgcolor-accent);
background-color: var(--bg-color);
border: 0.2rem solid var(--border-color);
border-radius: .2rem;
background-color: transparent;
border: none;
cursor: pointer;
font-weight: bold;
outline-offset: 1px;
word-break: normal;
}
button:active {
--bg-color: rgb(13, 74, 139);
--border-color: rgb(13, 74, 139);
}
.primary,
.secondary {
border: 0.2rem solid var(--bgcolor-accent);
border-radius: .2rem;
padding: .9rem 2rem .7rem 2rem;
}
.primary {
background-color: var(--bgcolor-accent);
}
.secondary {
background-color: transparent;
}
button:focus {
}

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

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

@ -8,8 +8,58 @@ type SubCallback = (
relay: string,
) => void;
export const subPubkeys = (
pubkeys: string[],
onEvent: SubCallback,
) => {
const authorsPrefixes = pubkeys.map(pubkey => pubkey.slice(0, 32));
console.info(`subscribe to homefeed ${authorsPrefixes}`);
unsubAll();
const repliesTo = new Set<string>();
sub({
cb: (evt, relay) => {
if (
evt.tags.some(hasEventTag)
&& !evt.tags.some(isMention)
) {
const note = getReplyTo(evt); // get all reply to events instead?
if (note && !repliesTo.has(note)) {
repliesTo.add(note);
subOnce({
cb: onEvent,
filter: {
ids: [note],
kinds: [1],
limit: 1,
},
relay,
});
}
}
onEvent(evt, relay);
},
filter: {
authors: authorsPrefixes,
kinds: [1],
limit: 20,
},
});
// get metadata
sub({
cb: onEvent,
filter: {
authors: pubkeys,
kinds: [0],
limit: pubkeys.length,
},
unsub: true,
});
};
/** subscribe to global feed */
export const sub24hFeed = (onEvent: SubCallback) => {
export const subGlobalFeed = (onEvent: SubCallback) => {
console.info('subscribe to global feed');
unsubAll();
const now = Math.floor(Date.now() * 0.001);
const pubkeys = new Set<string>();
@ -132,15 +182,17 @@ export const subNote = (
});
};
replies.add(eventId)
sub({
cb: onReply,
filter: {
'#e': [eventId],
kinds: [1, 7],
},
unsub: true,
});
replies.add(eventId);
setTimeout(() => {
sub({
cb: onReply,
filter: {
'#e': [eventId],
kinds: [1, 7],
},
unsub: true, // TODO: probably keep this subscription also after onReply/unsubAll
});
}, 200);
};
/** subscribe to npub key (nip-19) */
@ -194,9 +246,9 @@ export const subProfile = (
sub({
cb: onEvent,
filter: {
authors: [pubkey],
authors: [pubkey, config.pubkey],
kinds: [3],
limit: 3,
limit: 6,
},
});
}, 100);
@ -206,11 +258,33 @@ 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,7 +7,9 @@ export type DOMMap = {
};
export type ViewTemplateOptions = {
type: 'feed'
type: 'home';
} | {
type: 'feed';
} | {
type: 'note';
id: string;
@ -23,14 +25,27 @@ 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'}, nip19.npubEncode(pubkey)),
elem('small', {className: 'hero-npub'}, npub),
elem('div', {className: 'hero-title'}, [
elem('h1', {}, pubkey),
followStatus,
followBtn,
]),
detail,
elem('footer', {}, following),
@ -38,13 +53,13 @@ export const renderViewTemplate = (options: ViewTemplateOptions) => {
dom.header = profileHeader;
dom.detail = detail;
dom.following = following;
dom.followStatus = followStatus;
dom.followBtn = followBtn;
content.append(profileHeader);
document.title = pubkey;
break;
case 'note':
break;
case 'feed':
break;
case 'event':
const id = options.id;
content.append(

@ -100,8 +100,6 @@ export const renderUpdateContact = (
' ',
elem('span', {data: {contacts: evt.id}, title: time.toISOString()}, evt.content),
]),
elem('div', {className: 'mbox-content'}, [
]),
]),
], {
className: 'mbox-updated-contact',
@ -133,13 +131,13 @@ export const renderEventDetails = (evt: Event, relay: string) => {
const {img, name, userName} = getMetadata(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) {
case 'object':
content = JSON.stringify(content, null, 2);
break;
default:
content = `${evt.content}`;
content = `${content}`;
}
const body = elem('div', {className: 'mbox-body'}, [
elem('header', {className: 'mbox-header'}, [

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

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

Loading…
Cancel
Save