From 7539d11a56df39911306d7e63112c2e50caa2cdf Mon Sep 17 00:00:00 2001 From: OFF0 Date: Wed, 16 Aug 2023 13:46:14 +0200 Subject: [PATCH] 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. --- src/index.html | 4 +- src/main.ts | 66 ++++++++++++++++++++++++------- src/styles/view.css | 20 ++++++++-- src/subscriptions.ts | 94 +++++++++++++++++++++++++++++++++++++++----- src/template.ts | 13 ++++-- src/view.ts | 4 +- 6 files changed, 166 insertions(+), 35 deletions(-) diff --git a/src/index.html b/src/index.html index 3fe9195..dd0e1c0 100644 --- a/src/index.html +++ b/src/index.html @@ -102,7 +102,9 @@ diff --git a/src/main.ts b/src/main.ts index eedcf7e..47b13ba 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 {followContact, 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 = (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,20 +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); - break; + return; } } // const container = e.target.closest('[data-append]'); diff --git a/src/styles/view.css b/src/styles/view.css index 9b1dda4..81260dd 100644 --- a/src/styles/view.css +++ b/src/styles/view.css @@ -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; } diff --git a/src/subscriptions.ts b/src/subscriptions.ts index 82979ef..8a70215 100644 --- a/src/subscriptions.ts +++ b/src/subscriptions.ts @@ -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(); + 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(); @@ -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) */ @@ -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, }); }; diff --git a/src/template.ts b/src/template.ts index 20cb28b..60bc842 100644 --- a/src/template.ts +++ b/src/template.ts @@ -7,7 +7,9 @@ export type DOMMap = { }; export type ViewTemplateOptions = { - type: 'feed' + type: 'home'; +} | { + type: 'feed'; } | { type: 'note'; id: string; @@ -23,8 +25,13 @@ 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', { @@ -34,7 +41,7 @@ export const renderViewTemplate = (options: ViewTemplateOptions) => { }, '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, @@ -53,8 +60,6 @@ export const renderViewTemplate = (options: ViewTemplateOptions) => { break; case 'note': break; - case 'feed': - break; case 'event': const id = options.id; content.append( diff --git a/src/view.ts b/src/view.ts index 6992406..82e1d3e 100644 --- a/src/view.ts +++ b/src/view.ts @@ -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'}; /**