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.
pull/84/head
OFF0 9 months ago
parent f92cfbbb31
commit 7539d11a56
Signed by: offbyn
GPG Key ID: 94A2F643C51F37FA

@ -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 {followContact, 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 (
@ -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,20 +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.dataset.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': case 'follow':
followContact(id); followContact(id);
break; return;
} }
} }
// const container = e.target.closest('[data-append]'); // const container = e.target.closest('[data-append]');

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

@ -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) */
@ -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,8 +25,13 @@ 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 npub = nip19.npubEncode(pubkey);
const detail = elem('p'); const detail = elem('p');
const followStatus = elem('small'); const followStatus = elem('small');
const followBtn = elem('button', { const followBtn = elem('button', {
@ -34,7 +41,7 @@ export const renderViewTemplate = (options: ViewTemplateOptions) => {
}, 'follow'); }, 'follow');
const following = elem('span'); const following = elem('span');
const profileHeader = elem('header', {className: 'hero'}, [ 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('div', {className: 'hero-title'}, [
elem('h1', {}, pubkey), elem('h1', {}, pubkey),
followStatus, followStatus,
@ -53,8 +60,6 @@ export const renderViewTemplate = (options: ViewTemplateOptions) => {
break; break;
case 'note': case 'note':
break; break;
case 'feed':
break;
case 'event': case 'event':
const id = options.id; const id = options.id;
content.append( content.append(

@ -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