Add reads sidebar

This commit is contained in:
Bojan Mojsilovic 2024-05-31 12:42:56 +02:00
parent 219f3ab084
commit b1ad4299eb
10 changed files with 465 additions and 12 deletions

View File

@ -56,3 +56,23 @@
margin-top: 34px;
z-index: 10px;
}
.headingPicks {
@include heading();
text-transform: capitalize;
}
.readsSidebar {
.topic {
display: inline-block;
background-color: var(--background-input);
padding: 6px 10px;
border-radius: 12px;
color: var(--text-secondary);
font-size: 12px;
font-weight: 400;
line-height: 12px;
margin-right: 8px;
}
}

View File

@ -0,0 +1,203 @@
import { Component, createEffect, createSignal, For, onMount, Show } from 'solid-js';
import {
EventCoordinate,
PrimalArticle,
SelectionOption
} from '../../types/primal';
import styles from './HomeSidebar.module.scss';
import SmallNote from '../SmallNote/SmallNote';
import { useAccountContext } from '../../contexts/AccountContext';
import { hookForDev } from '../../lib/devTools';
import SelectionBox from '../SelectionBox/SelectionBox';
import Loader from '../Loader/Loader';
import { readHomeSidebarSelection, saveHomeSidebarSelection } from '../../lib/localStore';
import { useHomeContext } from '../../contexts/HomeContext';
import { useReadsContext } from '../../contexts/ReadsContext';
import { createStore } from 'solid-js/store';
import { APP_ID } from '../../App';
import { subsTo } from '../../sockets';
import { getArticleThread, getReadsTopics } from '../../lib/feed';
import { fetchArticles } from '../../handleNotes';
import { getParametrizedEvent, getParametrizedEvents } from '../../lib/notes';
import { decodeIdentifier } from '../../lib/keys';
const sidebarOptions = [
{
label: 'Trending 24h',
value: 'trending_24h',
},
{
label: 'Trending 12h',
value: 'trending_12h',
},
{
label: 'Trending 4h',
value: 'trending_4h',
},
{
label: 'Trending 1h',
value: 'trending_1h',
},
{
label: '',
value: '',
disabled: true,
separator: true,
},
{
label: 'Most-zapped 24h',
value: 'mostzapped_24h',
},
{
label: 'Most-zapped 12h',
value: 'mostzapped_12h',
},
{
label: 'Most-zapped 4h',
value: 'mostzapped_4h',
},
{
label: 'Most-zapped 1h',
value: 'mostzapped_1h',
},
];
const ReadsSidebar: Component< { id?: string } > = (props) => {
const account = useAccountContext();
const reads= useReadsContext();
const [topPicks, setTopPicks] = createStore<PrimalArticle[]>([]);
const [topics, setTopics] = createStore<string[]>([]);
const [isFetching, setIsFetching] = createSignal(false);
const [isFetchingTopics, setIsFetchingTopics] = createSignal(false);
const [got, setGot] = createSignal(false);
const getTopics = () => {
const subId = `reads_topics_${APP_ID}`;
const unsub = subsTo(subId, {
onEvent: (_, content) => {
const topics = JSON.parse(content.content || '[]') as string[];
setTopics(() => [...topics]);
},
onEose: () => {
setIsFetchingTopics(() => false);
unsub();
}
})
setIsFetchingTopics(() => true);
getReadsTopics(subId);
}
onMount(() => {
if (account?.isKeyLookupDone && reads?.recomendedReads.length === 0) {
reads.actions.doSidebarSearch('');
}
if (account?.isKeyLookupDone) {
getTopics()
}
});
createEffect(() => {
const rec = reads?.recomendedReads || [];
if (rec.length > 0 && !got()) {
setGot(() => true);
let randomIndices = new Set<number>();
while (randomIndices.size < 3) {
const randomIndex = Math.floor(Math.random() * rec.length);
randomIndices.add(randomIndex);
}
const reads = [ ...randomIndices ].map(i => rec[i])
getPEvents(reads)
// getRecomendedArticles(reads)
}
})
const getPEvents = (ids: string[]) => {
const events = ids.reduce<EventCoordinate[]>((acc, id) => {
const d = decodeIdentifier(id);
if (!d.data || d.type !== 'naddr') return acc;
const { pubkey, identifier, kind } = d.data;
return [
...acc,
{ identifier, pubkey, kind },
]
}, []);
getParametrizedEvents(events, `reads_pe_${APP_ID}`);
}
const getRecomendedArticles = async (ids: string[]) => {
if (!account?.publicKey) return;
const subId = `reads_picks_${APP_ID}`;
setIsFetching(() => true);
const articles = await fetchArticles(account.publicKey, ids,subId);
setIsFetching(() => false);
console.log('ARTICLES: ', articles);
setTopPicks(() => [...articles]);
};
return (
<div id={props.id} class={styles.readsSidebar}>
<Show when={account?.isKeyLookupDone}>
<div class={styles.headingPicks}>
Top Picks
</div>
<Show
when={!isFetching()}
fallback={
<Loader />
}
>
<For each={topPicks}>
{(note) => <div>{note.title}</div>}
</For>
</Show>
<div class={styles.headingPicks}>
Topics
</div>
<Show
when={!isFetchingTopics()}
fallback={
<Loader />
}
>
<For each={topics}>
{(topic) => <div class={styles.topic}>{topic}</div>}
</For>
</Show>
</Show>
</div>
);
}
export default hookForDev(ReadsSidebar);

View File

@ -6,7 +6,7 @@ import { Kind, minKnownProfiles } from "../constants";
import { getArticlesFeed, getEvents, getExploreFeed, getFeed, getFutureArticlesFeed, getFutureExploreFeed, getFutureFeed } from "../lib/feed";
import { fetchStoredFeed, saveStoredFeed } from "../lib/localStore";
import { setLinkPreviews } from "../lib/notes";
import { getScoredUsers, searchContent } from "../lib/search";
import { getRecomendedArticleIds, getScoredUsers, searchContent } from "../lib/search";
import { isConnected, refreshSocketListeners, removeSocketListeners, socket } from "../sockets";
import { sortingPlan, convertToNotes, parseEmptyReposts, paginationPlan, isInTags, isRepostInCollection, convertToArticles, isLFRepostInCollection } from "../stores/note";
import {
@ -41,6 +41,7 @@ type ReadsContextStore = {
lastNote: PrimalArticle | undefined,
reposts: Record<string, string> | undefined,
mentionedNotes: Record<string, NostrNoteContent>,
recomendedReads: string[],
future: {
notes: PrimalArticle[],
page: FeedPage,
@ -119,6 +120,7 @@ const initialHomeData = {
isFetching: false,
query: undefined,
},
recomendedReads: [],
};
export const ReadsContext = createContext<ReadsContextStore>();
@ -201,13 +203,13 @@ export const ReadsProvider = (props: { children: ContextChildren }) => {
};
const doSidebarSearch = (query: string) => {
const subid = `reads_sidebar_${APP_ID}`;
const subid = `reads_recomended_${APP_ID}`;
updateStore('sidebar', 'isFetching', () => true);
updateStore('sidebar', 'notes', () => []);
updateStore('sidebar', 'page', { messages: [], users: {}, postStats: {}, mentions: {}, noteActions: {} });
updateStore('sidebar', 'page', { messages: [], users: {}, postStats: {}, mentions: {}, noteActions: {}, topZaps: {} });
getScoredUsers(account?.publicKey, query, 10, subid);
getRecomendedArticleIds(subid);
}
const clearFuture = () => {
@ -647,6 +649,27 @@ export const ReadsProvider = (props: { children: ContextChildren }) => {
const [type, subId, content] = message;
if (subId === `reads_recomended_${APP_ID}`) {
if (type === 'EOSE') {
// saveSidebarPage(store.sidebar.page);
return;
}
if (!content) {
return;
}
if (type === 'EVENT') {
const recomended = JSON.parse(content?.content || '{}');
const ids = recomended.reads.reduce((acc: string[], r: string[]) => r[0] ? [ ...acc, r[0] ] : acc, []);
updateStore('recomendedReads', () => [ ...ids ])
return;
}
}
if (subId === `reads_sidebar_${APP_ID}`) {
if (type === 'EOSE') {
saveSidebarPage(store.sidebar.page);

View File

@ -4,17 +4,15 @@ import { getEvents } from "./lib/feed";
import { setLinkPreviews } from "./lib/notes";
import { updateStore, store } from "./services/StoreService";
import { subscribeTo } from "./sockets";
import { convertToNotes } from "./stores/note";
import { convertToArticles, convertToNotes } from "./stores/note";
import { account } from "./translations";
import { FeedPage, NostrEventContent, NostrEventType, NostrMentionContent, NostrNoteActionsContent, NostrNoteContent, NostrStatsContent, NostrUserContent, NoteActions, PrimalNote, TopZap } from "./types/primal";
import { FeedPage, NostrEventContent, NostrEventType, NostrMentionContent, NostrNoteActionsContent, NostrNoteContent, NostrStatsContent, NostrUserContent, NoteActions, PrimalArticle, PrimalNote, TopZap } from "./types/primal";
import { parseBolt11 } from "./utils";
export const fetchNotes = (pubkey: string | undefined, noteIds: string[], subId: string) => {
return new Promise<PrimalNote[]>((resolve, reject) => {
if (!pubkey) reject('Missing pubkey');
let note: PrimalNote;
let page: FeedPage = {
users: {},
messages: [],
@ -176,6 +174,178 @@ export const fetchNotes = (pubkey: string | undefined, noteIds: string[], subId:
const quoteStats = JSON.parse(content.content);
// updateStore('quoteCount', () => quoteStats.count || 0);
return;
}
};
});
};
export const fetchArticles = (pubkey: string | undefined, noteIds: string[], subId: string) => {
return new Promise<PrimalArticle[]>((resolve, reject) => {
if (!pubkey) reject('Missing pubkey');
let page: FeedPage = {
users: {},
messages: [],
postStats: {},
mentions: {},
noteActions: {},
relayHints: {},
topZaps: {},
since: 0,
until: 0,
}
let lastNote: PrimalArticle | undefined;
const unsub = subscribeTo(subId, (type, _, content) => {
if (type === 'EOSE') {
unsub();
const notes = convertToArticles(page, page.topZaps);
resolve(notes);
return;
}
if (type === 'EVENT') {
if (!content) return;
updatePage(content);
}
});
getEvents(pubkey, [...noteIds], subId, true);
const updatePage = (content: NostrEventContent) => {
if (content.kind === Kind.Metadata) {
const user = content as NostrUserContent;
page.users[user.pubkey] = { ...user };
return;
}
if ([Kind.LongForm, Kind.Repost].includes(content.kind)) {
const message = content as NostrNoteContent;
if (lastNote?.noteId !== nip19.noteEncode(message.id)) {
page.messages.push({...message});
}
return;
}
if (content.kind === Kind.NoteStats) {
const statistic = content as NostrStatsContent;
const stat = JSON.parse(statistic.content);
page.postStats[stat.event_id] = { ...stat };
return;
}
if (content.kind === Kind.Mentions) {
const mentionContent = content as NostrMentionContent;
const mention = JSON.parse(mentionContent.content);
if (!page.mentions) {
page.mentions = {};
}
page.mentions[mention.id] = { ...mention };
return;
}
if (content.kind === Kind.NoteActions) {
const noteActionContent = content as NostrNoteActionsContent;
const noteActions = JSON.parse(noteActionContent.content) as NoteActions;
page.noteActions[noteActions.event_id] = { ...noteActions };
return;
}
if (content.kind === Kind.LinkMetadata) {
const metadata = JSON.parse(content.content);
const data = metadata.resources[0];
if (!data) {
return;
}
const preview = {
url: data.url,
title: data.md_title,
description: data.md_description,
mediaType: data.mimetype,
contentType: data.mimetype,
images: [data.md_image],
favicons: [data.icon_url],
};
setLinkPreviews(() => ({ [data.url]: preview }));
return;
}
if (content.kind === Kind.RelayHint) {
const hints = JSON.parse(content.content);
page.relayHints = { ...page.relayHints, ...hints };
return;
}
if (content?.kind === Kind.Zap) {
const zapTag = content.tags.find(t => t[0] === 'description');
if (!zapTag) return;
const zapInfo = JSON.parse(zapTag[1] || '{}');
let amount = '0';
let bolt11Tag = content?.tags?.find(t => t[0] === 'bolt11');
if (bolt11Tag) {
try {
amount = `${parseBolt11(bolt11Tag[1]) || 0}`;
} catch (e) {
const amountTag = zapInfo.tags.find((t: string[]) => t[0] === 'amount');
amount = amountTag ? amountTag[1] : '0';
}
}
const eventId = (zapInfo.tags.find((t: string[]) => t[0] === 'e') || [])[1];
const zap: TopZap = {
id: zapInfo.id,
amount: parseInt(amount || '0'),
pubkey: zapInfo.pubkey,
message: zapInfo.content,
eventId,
};
if (page.topZaps[eventId] === undefined) {
page.topZaps[eventId] = [{ ...zap }];
return;
}
if (page.topZaps[eventId].find(i => i.id === zap.id)) {
return;
}
const newZaps = [ ...page.topZaps[eventId], { ...zap }].sort((a, b) => b.amount - a.amount);
page.topZaps[eventId] = [ ...newZaps ];
return;
}
if (content.kind === Kind.NoteQuoteStats) {
const quoteStats = JSON.parse(content.content);
// updateStore('quoteCount', () => quoteStats.count || 0);
return;
}

View File

@ -321,3 +321,13 @@ export const getMostZapped4h = (
]},
]));
};
export const getReadsTopics = (
subid: string,
) => {
sendMessage(JSON.stringify([
"REQ",
subid,
{cache: ["get_reads_topics"]},
]));
};

View File

@ -5,7 +5,7 @@ import { createStore } from "solid-js/store";
import LinkPreview from "../components/LinkPreview/LinkPreview";
import { addrRegex, appleMusicRegex, emojiRegex, hashtagRegex, interpunctionRegex, Kind, linebreakRegex, lnRegex, lnUnifiedRegex, mixCloudRegex, nostrNestsRegex, noteRegex, noteRegexLocal, profileRegex, profileRegexG, soundCloudRegex, spotifyRegex, tagMentionRegex, twitchRegex, urlRegex, urlRegexG, wavlakeRegex, youtubeRegex } from "../constants";
import { sendMessage, subscribeTo } from "../sockets";
import { MediaSize, NostrRelays, NostrRelaySignedEvent, PrimalArticle, PrimalNote, SendNoteResult } from "../types/primal";
import { EventCoordinate, MediaSize, NostrRelays, NostrRelaySignedEvent, PrimalArticle, PrimalNote, SendNoteResult } from "../types/primal";
import { npubToHex } from "./keys";
import { logError, logInfo, logWarning } from "./logger";
import { getMediaUrl as getMediaUrlDefault } from "./media";
@ -343,7 +343,7 @@ export const sendArticleRepost = async (note: PrimalArticle, relays: Relay[], re
kind: Kind.Repost,
tags: [
['e', note.id],
['p', note.author.pubkey],
['p', note.pubkey],
],
created_at: Math.floor((new Date()).getTime() / 1000),
};
@ -615,3 +615,12 @@ export const getParametrizedEvent = (pubkey: string, identifier: string, kind: n
{cache: ["parametrized_replaceable_event", { pubkey, kind, identifier, extended_response: true }]},
]));
};
export const getParametrizedEvents = (events: EventCoordinate[], subid: string) => {
sendMessage(JSON.stringify([
"REQ",
subid,
{cache: ["parametrized_replaceable_events", { events, extended_response: true }]},
]));
};

View File

@ -75,3 +75,11 @@ export const getScoredUsers = (user_pubkey: string | undefined, selector: string
{cache: ['scored', { user_pubkey, selector }]},
]));
};
export const getRecomendedArticleIds = (subid: string) => {
sendMessage(JSON.stringify([
"REQ",
subid,
{cache: ['get_recommended_reads']},
]));
};

View File

@ -491,7 +491,10 @@ const Longform: Component< { naddr: string } > = (props) => {
action={() => {}}
/>
<PrimalMarkdown content={note.content || ''} readonly={true} />
<PrimalMarkdown
noteId={props.naddr}
content={note.content || ''}
readonly={true} />
<div class={styles.tags}>
<For each={note.tags}>

View File

@ -35,6 +35,7 @@ import { useAppContext } from '../contexts/AppContext';
import { useReadsContext } from '../contexts/ReadsContext';
import ArticlePreview from '../components/ArticlePreview/ArticlePreview';
import PageCaption from '../components/PageCaption/PageCaption';
import ReadsSidebar from '../components/HomeSidebar/ReadsSidebar';
const Home: Component = () => {
@ -125,6 +126,10 @@ const Home: Component = () => {
setNewPostAuthors(() => []);
}
onMount(() => {
context?.actions.doSidebarSearch('')
})
return (
<div class={styles.homeContent}>
<PageTitle title={intl.formatMessage(branding)} />
@ -137,7 +142,7 @@ const Home: Component = () => {
<PageCaption title={intl.formatMessage(reads.pageTitle)} />
<StickySidebar>
<HomeSidebar />
<ReadsSidebar />
</StickySidebar>
<Show

View File

@ -803,3 +803,5 @@ export type PageRange = {
until: number,
order_by: string,
};
export type EventCoordinate = { pubkey: string, identifier: string, kind: number };