diff --git a/src/index.html b/src/index.html index 42e5f25..ae2c206 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'}; /**