Reads page

This commit is contained in:
Bojan Mojsilovic 2024-05-24 18:56:56 +02:00
parent e075c7741f
commit cca4d11df0
15 changed files with 1914 additions and 218 deletions

View File

@ -0,0 +1,117 @@
.article {
display: flex;
flex-direction: column;
text-decoration: none;
color: unset;
width: 100%;
margin-top: 20px;
padding-inline: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--devider);
.header {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 4px;
margin-bottom: 8px;
.userInfo {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 4px;
.userName {
color: var(--text-secondary);
font-family: Nacelle;
font-size: 14px;
font-weight: 700;
line-height: 14px;
}
.nip05 {
color: var(--text-tertiary);
font-size: 14px;
font-weight: 400;
line-height: 14px;
}
}
.time {
color: var(--text-tertiary);
font-size: 14px;
font-weight: 400;
line-height: 14px;
}
}
.body {
display: flex;
gap: 8px;
margin-bottom: 12px;
.text {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 8px;
flex-grow: 1;
.content {
.title {
color: var(--text-primary);
font-size: 20px;
font-weight: 700;
line-height: 28px;
}
.summary {
color: var(--text-primary);
font-family: Lora;
font-size: 15px;
font-weight: 400;
line-height: 22px;
}
}
.tags {
width: 100%;
.tag {
display: inline-block;
color: var(--text-secondary);
font-size: 12px;
font-weight: 400;
line-height: 12px;
background-color: var(--background-input);
padding: 6px 10px;
border-radius: 12px;
width: fit-content;
margin: 4px;
}
.estimate {
display: inline-block;
color: var(--text-secondary);
font-size: 12px;
font-weight: 400;
line-height: 12px;
}
}
}
.image {
min-width: 164px;
img {
width: 164px;
object-fit: scale-down;
}
}
}
.zaps {
margin-bottom: 16px;
}
}

View File

@ -0,0 +1,118 @@
import { A } from '@solidjs/router';
import { Component, createEffect, For, JSXElement, Show } from 'solid-js';
import { createStore } from 'solid-js/store';
import { Portal } from 'solid-js/web';
import { shortDate } from '../../lib/dates';
import { hookForDev } from '../../lib/devTools';
import { userName } from '../../stores/profile';
import { PrimalArticle } from '../../types/primal';
import Avatar from '../Avatar/Avatar';
import { NoteReactionsState } from '../Note/Note';
import ArticleFooter from '../Note/NoteFooter/ArticleFooter';
import NoteFooter from '../Note/NoteFooter/NoteFooter';
import NoteTopZaps from '../Note/NoteTopZaps';
import NoteTopZapsCompact from '../Note/NoteTopZapsCompact';
import VerificationCheck from '../VerificationCheck/VerificationCheck';
import styles from './ArticlePreview.module.scss';
const ArticlePreview: Component<{
id?: string,
article: PrimalArticle,
}> = (props) => {
const [reactionsState, updateReactionsState] = createStore<NoteReactionsState>({
likes: 0,
liked: false,
reposts: 0,
reposted: false,
replies: 0,
replied: false,
zapCount: 0,
satsZapped: 0,
zapped: false,
zappedAmount: 0,
zappedNow: false,
isZapping: false,
showZapAnim: false,
hideZapIcon: false,
moreZapsAvailable: false,
isRepostMenuVisible: false,
topZaps: [],
topZapsFeed: [],
quoteCount: 0,
});
return (
<A class={styles.article} href={`/e/${props.article.naddr}`}>
<div class={styles.header}>
<div class={styles.userInfo}>
<Avatar user={props.article.author} size="micro"/>
<div class={styles.userName}>{userName(props.article.author)}</div>
<VerificationCheck user={props.article.author} />
<div class={styles.nip05}>{props.article.author.nip05 || ''}</div>
</div>
<div class={styles.time}>
{shortDate(props.article.published)}
</div>
</div>
<div class={styles.body}>
<div class={styles.text}>
<div class={styles.content}>
<div class={styles.title}>
{props.article.title}
</div>
<div class={styles.summary}>
{props.article.summary}
</div>
</div>
<div class={styles.tags}>
<For each={props.article.tags}>
{tag => (
<div class={styles.tag}>
{tag}
</div>
)}
</For>
<div class={styles.estimate}>
{Math.ceil(props.article.wordCount / 238)} minute read
</div>
</div>
</div>
<div class={styles.image}>
<img src={props.article.image} />
</div>
</div>
<Show when={props.article.topZaps.length > 0}>
<div class={styles.zaps}>
<NoteTopZapsCompact
note={props.article}
action={() => {}}
topZaps={props.article.topZaps}
topZapLimit={4}
/>
</div>
</Show>
<div class={styles.footer}>
<ArticleFooter
note={props.article}
state={reactionsState}
updateState={updateReactionsState}
customZapInfo={{
note: props.article,
onConfirm: () => {},
onSuccess: () => {},
onFail: () => {},
onCancel: () => {},
}}
/>
</div>
</A>
);
}
export default hookForDev(ArticlePreview);

View File

@ -0,0 +1,160 @@
import { useIntl } from '@cookbook/solid-intl';
import { Component, createEffect, createSignal, Match, Show, Switch } from 'solid-js';
import { APP_ID } from '../../App';
import { Kind } from '../../constants';
import { useAccountContext } from '../../contexts/AccountContext';
import { useAppContext } from '../../contexts/AppContext';
import { getUserFeed } from '../../lib/feed';
import { logWarning } from '../../lib/logger';
import { getBookmarks, sendBookmarks } from '../../lib/profile';
import { subscribeTo } from '../../sockets';
import { PrimalArticle, PrimalNote } from '../../types/primal';
import ButtonGhost from '../Buttons/ButtonGhost';
import { account, bookmarks as tBookmarks } from '../../translations';
import styles from './BookmarkNote.module.scss';
import { saveBookmarks } from '../../lib/localStore';
import { importEvents, triggerImportEvents } from '../../lib/notes';
const BookmarkArticle: Component<{ note: PrimalArticle, large?: boolean }> = (props) => {
const account = useAccountContext();
const app = useAppContext();
const intl = useIntl();
const [isBookmarked, setIsBookmarked] = createSignal(false);
const [bookmarkInProgress, setBookmarkInProgress] = createSignal(false);
createEffect(() => {
setIsBookmarked(() => account?.bookmarks.includes(props.note.id) || false);
})
const updateBookmarks = async (bookmarkTags: string[][]) => {
if (!account) return;
const bookmarks = bookmarkTags.reduce((acc, t) =>
t[0] === 'e' ? [...acc, t[1]] : [...acc]
, []);
const date = Math.floor((new Date()).getTime() / 1000);
account.actions.updateBookmarks(bookmarks)
saveBookmarks(account.publicKey, bookmarks);
const { success, note} = await sendBookmarks([...bookmarkTags], date, '', account?.relays, account?.relaySettings);
if (success && note) {
triggerImportEvents([note], `bookmark_import_${APP_ID}`)
}
};
const addBookmark = async (bookmarkTags: string[][]) => {
if (account && !bookmarkTags.find(b => b[0] === 'e' && b[1] === props.note.id)) {
const bookmarksToAdd = [...bookmarkTags, ['e', props.note.id]];
if (bookmarksToAdd.length < 2) {
logWarning('BOOKMARK ISSUE: ', `before_bookmark_${APP_ID}`);
app?.actions.openConfirmModal({
title: intl.formatMessage(tBookmarks.confirm.title),
description: intl.formatMessage(tBookmarks.confirm.description),
confirmLabel: intl.formatMessage(tBookmarks.confirm.confirm),
abortLabel: intl.formatMessage(tBookmarks.confirm.abort),
onConfirm: async () => {
await updateBookmarks(bookmarksToAdd);
app.actions.closeConfirmModal();
},
onAbort: app.actions.closeConfirmModal,
})
return;
}
await updateBookmarks(bookmarksToAdd);
}
}
const removeBookmark = async (bookmarks: string[][]) => {
if (account && bookmarks.find(b => b[0] === 'e' && b[1] === props.note.id)) {
const bookmarksToAdd = bookmarks.filter(b => b[0] !== 'e' || b[1] !== props.note.id);
if (bookmarksToAdd.length < 1) {
logWarning('BOOKMARK ISSUE: ', `before_bookmark_${APP_ID}`);
app?.actions.openConfirmModal({
title: intl.formatMessage(tBookmarks.confirm.titleZero),
description: intl.formatMessage(tBookmarks.confirm.descriptionZero),
confirmLabel: intl.formatMessage(tBookmarks.confirm.confirmZero),
abortLabel: intl.formatMessage(tBookmarks.confirm.abortZero),
onConfirm: async () => {
await updateBookmarks(bookmarksToAdd);
app.actions.closeConfirmModal();
},
onAbort: app.actions.closeConfirmModal,
})
return;
}
await updateBookmarks(bookmarksToAdd);
}
}
const doBookmark = (remove: boolean, then?: () => void) => {
if (!account?.publicKey) {
return;
}
let bookmarks: string[][] = []
const unsub = subscribeTo(`before_bookmark_${APP_ID}`, async (type, subId, content) => {
if (type === 'EOSE') {
if (remove) {
await removeBookmark(bookmarks);
}
else {
await addBookmark(bookmarks);
}
then && then();
setBookmarkInProgress(() => false);
unsub();
return;
}
if (type === 'EVENT') {
if (!content || content.kind !== Kind.Bookmarks) return;
bookmarks = content.tags;
}
});
setBookmarkInProgress(() => true);
getBookmarks(account.publicKey, `before_bookmark_${APP_ID}`);
}
return (
<div class={styles.bookmark}>
<ButtonGhost
onClick={(e: MouseEvent) => {
e.preventDefault();
doBookmark(isBookmarked());
}}
disabled={bookmarkInProgress()}
>
<Show
when={isBookmarked()}
fallback={
<div class={`${styles.emptyBookmark} ${props.large ? styles.large : ''}`}></div>
}
>
<div class={`${styles.fullBookmark} ${props.large ? styles.large : ''}`}></div>
</Show>
</ButtonGhost>
</div>
)
}
export default BookmarkArticle;

View File

@ -0,0 +1,406 @@
import { batch, Component, createEffect, Show } from 'solid-js';
import { MenuItem, PrimalArticle, PrimalNote, ZapOption } from '../../../types/primal';
import { sendArticleRepost, sendRepost, triggerImportEvents } from '../../../lib/notes';
import styles from './NoteFooter.module.scss';
import { useAccountContext } from '../../../contexts/AccountContext';
import { useToastContext } from '../../Toaster/Toaster';
import { useIntl } from '@cookbook/solid-intl';
import { truncateNumber } from '../../../lib/notifications';
import { canUserReceiveZaps, zapNote } from '../../../lib/zap';
import { useSettingsContext } from '../../../contexts/SettingsContext';
import zapMD from '../../../assets/lottie/zap_md_2.json';
import { toast as t } from '../../../translations';
import PrimalMenu from '../../PrimalMenu/PrimalMenu';
import { hookForDev } from '../../../lib/devTools';
import { getScreenCordinates } from '../../../utils';
import ZapAnimation from '../../ZapAnimation/ZapAnimation';
import { CustomZapInfo, useAppContext } from '../../../contexts/AppContext';
import ArticleFooterActionButton from './ArticleFooterActionButton';
import { NoteReactionsState } from '../Note';
import { SetStoreFunction } from 'solid-js/store';
import BookmarkNote from '../../BookmarkNote/BookmarkNote';
import BookmarkArticle from '../../BookmarkNote/BookmarkArticle';
export const lottieDuration = () => zapMD.op * 1_000 / zapMD.fr;
const ArticleFooter: Component<{
note: PrimalArticle,
wide?: boolean,
id?: string,
state: NoteReactionsState,
updateState: SetStoreFunction<NoteReactionsState>,
customZapInfo: CustomZapInfo,
large?: boolean,
onZapAnim?: (zapOption: ZapOption) => void,
}> = (props) => {
const account = useAccountContext();
const toast = useToastContext();
const intl = useIntl();
const settings = useSettingsContext();
const app = useAppContext();
let medZapAnimation: HTMLElement | undefined;
let quickZapDelay = 0;
let footerDiv: HTMLDivElement | undefined;
let repostMenu: HTMLDivElement | undefined;
const repostMenuItems: MenuItem[] = [
{
action: () => doRepost(),
label: 'Repost Note',
icon: 'feed_repost',
},
{
action: () => doQuote(),
label: 'Quote Note',
icon: 'quote',
},
];
const onClickOutside = (e: MouseEvent) => {
if (
!document?.getElementById(`repost_menu_${props.note.id}`)?.contains(e.target as Node)
) {
props.updateState('isRepostMenuVisible', () => false);
}
}
createEffect(() => {
if (props.state.isRepostMenuVisible) {
document.addEventListener('click', onClickOutside);
}
else {
document.removeEventListener('click', onClickOutside);
}
});
const showRepostMenu = (e: MouseEvent) => {
e.preventDefault();
props.updateState('isRepostMenuVisible', () => true);
};
const doQuote = () => {
if (!account?.hasPublicKey()) {
account?.actions.showGetStarted();
return;
}
props.updateState('isRepostMenuVisible', () => false);
account?.actions?.quoteNote(`nostr:${props.note.naddr}`);
account?.actions?.showNewNoteForm();
};
const doRepost = async () => {
if (!account) {
return;
}
if (!account.hasPublicKey()) {
account.actions.showGetStarted();
return;
}
if (account.relays.length === 0) {
toast?.sendWarning(
intl.formatMessage(t.noRelaysConnected),
);
return;
}
props.updateState('isRepostMenuVisible', () => false);
const { success } = await sendArticleRepost(props.note, account.relays, account.relaySettings);
if (success) {
batch(() => {
props.updateState('reposts', (r) => r + 1);
props.updateState('reposted', () => true);
});
toast?.sendSuccess(
intl.formatMessage(t.repostSuccess),
);
}
else {
toast?.sendWarning(
intl.formatMessage(t.repostFailed),
);
}
};
const doReply = () => {};
const doLike = async (e: MouseEvent) => {
// e.preventDefault();
// e.stopPropagation();
// if (!account) {
// return;
// }
// if (!account.hasPublicKey()) {
// account.actions.showGetStarted();
// return;
// }
// if (account.relays.length === 0) {
// toast?.sendWarning(
// intl.formatMessage(t.noRelaysConnected),
// );
// return;
// }
// const success = await account.actions.addLike(props.note);
// if (success) {
// batch(() => {
// props.updateState('likes', (l) => l + 1);
// props.updateState('liked', () => true);
// });
// }
};
const startZap = (e: MouseEvent | TouchEvent) => {
// e.preventDefault();
// e.stopPropagation();
// if (!account?.hasPublicKey()) {
// account?.actions.showGetStarted();
// props.updateState('isZapping', () => false);
// return;
// }
// if (account.relays.length === 0) {
// toast?.sendWarning(
// intl.formatMessage(t.noRelaysConnected),
// );
// return;
// }
// if (!canUserReceiveZaps(props.note.user)) {
// toast?.sendWarning(
// intl.formatMessage(t.zapUnavailable),
// );
// props.updateState('isZapping', () => false);
// return;
// }
// quickZapDelay = setTimeout(() => {
// app?.actions.openCustomZapModal(props.customZapInfo);
// props.updateState('isZapping', () => true);
// }, 500);
};
const commitZap = (e: MouseEvent | TouchEvent) => {
// e.preventDefault();
// e.stopPropagation();
// clearTimeout(quickZapDelay);
// if (!account?.hasPublicKey()) {
// account?.actions.showGetStarted();
// return;
// }
// if (account.relays.length === 0 || !canUserReceiveZaps(props.note.user)) {
// return;
// }
// if (app?.customZap === undefined) {
// doQuickZap();
// }
};
const animateZap = () => {
// setTimeout(() => {
// props.updateState('hideZapIcon', () => true);
// if (!medZapAnimation) {
// return;
// }
// let newLeft = props.wide ? 15 : 13;
// let newTop = props.wide ? -6 : -6;
// if (props.large) {
// newLeft = 2;
// newTop = -9;
// }
// medZapAnimation.style.left = `${newLeft}px`;
// medZapAnimation.style.top = `${newTop}px`;
// const onAnimDone = () => {
// batch(() => {
// props.updateState('showZapAnim', () => false);
// props.updateState('hideZapIcon', () => false);
// props.updateState('zapped', () => true);
// });
// medZapAnimation?.removeEventListener('complete', onAnimDone);
// }
// medZapAnimation.addEventListener('complete', onAnimDone);
// try {
// // @ts-ignore
// medZapAnimation.seek(0);
// // @ts-ignore
// medZapAnimation.play();
// } catch (e) {
// console.warn('Failed to animte zap:', e);
// onAnimDone();
// }
// }, 10);
};
const doQuickZap = async () => {
// if (!account?.hasPublicKey()) {
// account?.actions.showGetStarted();
// return;
// }
// const amount = settings?.defaultZap.amount || 10;
// const message = settings?.defaultZap.message || '';
// const emoji = settings?.defaultZap.emoji;
// batch(() => {
// props.updateState('isZapping', () => true);
// props.updateState('satsZapped', (z) => z + amount);
// props.updateState('showZapAnim', () => true);
// });
// props.onZapAnim && props.onZapAnim({ amount, message, emoji })
// setTimeout(async () => {
// const success = await zapNote(props.note, account.publicKey, amount, message, account.relays);
// props.updateState('isZapping', () => false);
// if (success) {
// props.customZapInfo.onSuccess({
// emoji,
// amount,
// message,
// });
// return;
// }
// props.customZapInfo.onFail({
// emoji,
// amount,
// message,
// });
// }, lottieDuration());
}
const buttonTypeClasses: Record<string, string> = {
zap: styles.zapType,
like: styles.likeType,
reply: styles.replyType,
repost: styles.repostType,
};
createEffect(() => {
if (props.state.showZapAnim) {
animateZap();
}
});
const determineOrient = () => {
const coor = getScreenCordinates(repostMenu);
const height = 100;
return (coor.y || 0) + height < window.innerHeight + window.scrollY ? 'down' : 'up';
}
return (
<div id={props.id} class={`${styles.footer} ${props.wide ? styles.wide : ''}`} ref={footerDiv} onClick={(e) => {e.preventDefault();}}>
<Show when={props.state.showZapAnim}>
<ZapAnimation
id={`note-med-zap-${props.note.id}`}
src={zapMD}
class={props.large ? styles.largeZapLottie : styles.mediumZapLottie}
ref={medZapAnimation}
/>
</Show>
<ArticleFooterActionButton
note={props.note}
onClick={doReply}
type="reply"
highlighted={props.state.replied}
label={props.state.replies === 0 ? '' : truncateNumber(props.state.replies, 2)}
title={props.state.replies.toLocaleString()}
large={props.large}
/>
<ArticleFooterActionButton
note={props.note}
onClick={(e: MouseEvent) => e.preventDefault()}
onMouseDown={startZap}
onMouseUp={commitZap}
onTouchStart={startZap}
onTouchEnd={commitZap}
type="zap"
highlighted={props.state.zapped || props.state.isZapping}
label={props.state.satsZapped === 0 ? '' : truncateNumber(props.state.satsZapped, 2)}
hidden={props.state.hideZapIcon}
title={props.state.satsZapped.toLocaleString()}
large={props.large}
/>
<ArticleFooterActionButton
note={props.note}
onClick={doLike}
type="like"
highlighted={props.state.liked}
label={props.state.likes === 0 ? '' : truncateNumber(props.state.likes, 2)}
title={props.state.likes.toLocaleString()}
large={props.large}
/>
<button
id={`btn_repost_${props.note.id}`}
class={`${styles.stat} ${props.state.reposted ? styles.highlighted : ''}`}
onClick={showRepostMenu}
title={props.state.reposts.toLocaleString()}
>
<div
class={`${buttonTypeClasses.repost}`}
ref={repostMenu}
>
<div
class={`${styles.icon} ${props.large ? styles.large : ''}`}
style={'visibility: visible'}
></div>
<div class={styles.statNumber}>
{props.state.reposts === 0 ? '' : truncateNumber(props.state.reposts, 2)}
</div>
<PrimalMenu
id={`repost_menu_${props.note.id}`}
items={repostMenuItems}
position="note_footer"
orientation={determineOrient()}
hidden={!props.state.isRepostMenuVisible}
/>
</div>
</button>
<div class={styles.bookmarkFoot}>
<BookmarkArticle
note={props.note}
large={props.large}
/>
</div>
</div>
)
}
export default hookForDev(ArticleFooter);

View File

@ -0,0 +1,51 @@
import { Component, createEffect, onCleanup } from 'solid-js';
import { PrimalArticle, PrimalNote } from '../../../types/primal';
import styles from './NoteFooter.module.scss';
const buttonTypeClasses: Record<string, string> = {
zap: styles.zapType,
like: styles.likeType,
reply: styles.replyType,
repost: styles.repostType,
};
const ArticleFooterActionButton: Component<{
type: 'zap' | 'like' | 'reply' | 'repost',
note: PrimalArticle,
disabled?: boolean,
highlighted?: boolean,
onClick?: (e: MouseEvent) => void,
onMouseDown?: (e: MouseEvent) => void,
onMouseUp?: (e: MouseEvent) => void,
onTouchStart?: (e: TouchEvent) => void,
onTouchEnd?: (e: TouchEvent) => void,
label: string | number,
hidden?: boolean,
title?: string,
large?: boolean,
}> = (props) => {
return (
<button
id={`btn_${props.type}_${props.note.id}`}
class={`${styles.stat} ${props.highlighted ? styles.highlighted : ''}`}
onClick={props.onClick ?? (() => {})}
onMouseDown={props.onMouseDown ?? (() => {})}
onMouseUp={props.onMouseUp ?? (() => {})}
onTouchStart={props.onTouchStart ?? (() => {})}
onTouchEnd={props.onTouchEnd ?? (() => {})}
disabled={props.disabled}
>
<div class={`${buttonTypeClasses[props.type]} ${props.large ? styles.large : ''}`}>
<div
class={`${styles.icon} ${props.large ? styles.large : ''}`}
style={props.hidden ? 'visibility: hidden': 'visibility: visible'}
></div>
<div class={styles.statNumber}>{props.label || ''}</div>
</div>
</button>
)
}
export default ArticleFooterActionButton;

View File

@ -142,6 +142,7 @@ export enum Kind {
UserRelays=10_000_139,
RelayHint=10_000_141,
NoteQuoteStats=10_000_143,
WordCount=10_000_144,
WALLET_OPERATION = 10_000_300,
}

View File

@ -605,39 +605,6 @@ export const HomeProvider = (props: { children: ContextChildren }) => {
return;
}
// if (content.kind === Kind.EventZapInfo) {
// const zapInfo = JSON.parse(content.content)
// const eventId = zapInfo.event_id || 'UNKNOWN';
// if (eventId === 'UNKNOWN') return;
// const zap: TopZap = {
// id: zapInfo.zap_receipt_id,
// amount: parseInt(zapInfo.amount_sats || '0'),
// pubkey: zapInfo.sender,
// message: zapInfo.content,
// eventId,
// };
// const oldZaps = store.topZaps[eventId];
// if (oldZaps === undefined) {
// updateStore('topZaps', () => ({ [eventId]: [{ ...zap }]}));
// return;
// }
// if (oldZaps.find(i => i.id === zap.id)) {
// return;
// }
// const newZaps = [ ...oldZaps, { ...zap }].sort((a, b) => b.amount - a.amount);
// updateStore('topZaps', eventId, () => [ ...newZaps ]);
// return;
// }
};
const savePage = (page: FeedPage, scope?: 'future') => {

View File

@ -3,12 +3,12 @@ import { createContext, createEffect, onCleanup, useContext } from "solid-js";
import { createStore, reconcile, unwrap } from "solid-js/store";
import { APP_ID } from "../App";
import { Kind } from "../constants";
import { getArticlesFeed, getEvents, getExploreFeed, getFeed, getFutureExploreFeed, getFutureFeed } from "../lib/feed";
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 { isConnected, refreshSocketListeners, removeSocketListeners, socket, subscribeTo, subsTo } from "../sockets";
import { sortingPlan, convertToNotes, parseEmptyReposts, paginationPlan, isInTags, isRepostInCollection } from "../stores/note";
import { isConnected, refreshSocketListeners, removeSocketListeners, socket } from "../sockets";
import { sortingPlan, convertToNotes, parseEmptyReposts, paginationPlan, isInTags, isRepostInCollection, convertToArticles, isLFRepostInCollection } from "../stores/note";
import {
ContextChildren,
FeedPage,
@ -23,7 +23,7 @@ import {
NoteActions,
PrimalArticle,
PrimalFeed,
PrimalNote,
PrimalUser,
SelectionOption,
TopZap,
} from "../types/primal";
@ -31,30 +31,94 @@ import { parseBolt11 } from "../utils";
import { useAccountContext } from "./AccountContext";
import { useSettingsContext } from "./SettingsContext";
type Event = any;
type EventPage = Record<number, {
events: Event[],
since: number,
until: number,
}>;
type ReadsContextData = {
events: Record<number, Event[]>,
pages: EventPage[],
currentPageNumber: number,
}
type ReadsContextStore = ReadsContextData & {
type ReadsContextStore = {
notes: PrimalArticle[],
isFetching: boolean,
scrollTop: number,
selectedFeed: PrimalFeed | undefined,
page: FeedPage,
lastNote: PrimalArticle | undefined,
reposts: Record<string, string> | undefined,
mentionedNotes: Record<string, NostrNoteContent>,
future: {
notes: PrimalArticle[],
page: FeedPage,
reposts: Record<string, string> | undefined,
scope: string,
timeframe: string,
latest_at: number,
},
sidebar: {
notes: PrimalArticle[],
page: FeedPage,
isFetching: boolean,
query: SelectionOption | undefined,
},
actions: {
fetchPage: (page: number, kind: number) => void;
saveNotes: (newNotes: PrimalArticle[]) => void,
clearNotes: () => void,
fetchNotes: (topic: string, subId: string, until?: number) => void,
fetchNextPage: () => void,
selectFeed: (feed: PrimalFeed | undefined) => void,
updateScrollTop: (top: number) => void,
updatePage: (content: NostrEventContent) => void,
savePage: (page: FeedPage) => void,
checkForNewNotes: (topic: string | undefined) => void,
loadFutureContent: () => void,
doSidebarSearch: (query: string) => void,
updateSidebarQuery: (selection: SelectionOption) => void,
getFirstPage: () => void,
}
}
const initialData: ReadsContextData = {
events: {},
pages: [],
currentPageNumber: 0,
const initialHomeData = {
notes: [],
isFetching: false,
scrollTop: 0,
selectedFeed: undefined,
page: {
messages: [],
users: {},
postStats: {},
mentions: {},
noteActions: {},
topZaps: {},
wordCount: {},
},
reposts: {},
lastNote: undefined,
mentionedNotes: {},
future: {
notes: [],
reposts: {},
page: {
messages: [],
users: {},
postStats: {},
mentions: {},
noteActions: {},
topZaps: {},
wordCount: {},
},
scope: '',
timeframe: '',
latest_at: 0,
},
sidebar: {
notes: [],
page: {
messages: [],
users: {},
postStats: {},
mentions: {},
noteActions: {},
topZaps: {},
wordCount: {},
},
isFetching: false,
query: undefined,
},
};
export const ReadsContext = createContext<ReadsContextStore>();
@ -64,201 +128,679 @@ export const ReadsProvider = (props: { children: ContextChildren }) => {
const settings = useSettingsContext();
const account = useAccountContext();
const handleEvent = (event: NostrEventContent, page: number) => {
const { kind, content } = event;
if (!store.pages[page]) {
updateStore('pages', page, {})
}
if (!store.pages[page][kind]) {
updateStore('pages', page, { [kind]: { events: [], since: 0, until: 0 }})
}
updateStore('pages', page, kind, 'events', (es) => [...es, content]);
};
const handleEose = (page: number) => {
console.log('STORE: ', store.pages);
};
// ACTIONS --------------------------------------
const fetchPage = (page: number, kind: number) => {
const subId = `e_${kind}_${page}_${APP_ID}`;
const updateSidebarQuery = (selection: SelectionOption) => {
updateStore('sidebar', 'query', () => ({ ...selection }));
};
const unsub = subsTo(subId, {
onEvent: (_, content) => {
handleEvent(content, page);
},
onEose: (_) => {
handleEose(page);
unsub();
},
onNotice: (_, reason) => {},
});
const saveSidebarNotes = (newNotes: PrimalArticle[]) => {
updateStore('sidebar', 'notes', () => [ ...newNotes.slice(0, 24) ]);
updateStore('sidebar', 'isFetching', () => false);
};
const until = 0;
const limit = 10;
const offset = 0;
const updateSidebarPage = (content: NostrEventContent) => {
if (content.kind === Kind.Metadata) {
const user = content as NostrUserContent;
if (kind === Kind.LongForm) {
getArticlesFeed(
account?.publicKey,
account?.publicKey,
subId,
until,
limit,
offset,
)
updateStore('sidebar', 'page', 'users',
(usrs) => ({ ...usrs, [user.pubkey]: { ...user } })
);
return;
}
if ([Kind.Text, Kind.Repost].includes(content.kind)) {
const message = content as NostrNoteContent;
if (store.sidebar.page.messages.find(m => m.id === message.id)) {
return;
}
updateStore('sidebar', 'page', 'messages',
(msgs) => [ ...msgs, { ...message }]
);
return;
}
if (content.kind === Kind.NoteStats) {
const statistic = content as NostrStatsContent;
const stat = JSON.parse(statistic.content);
updateStore('sidebar', 'page', 'postStats',
(stats) => ({ ...stats, [stat.event_id]: { ...stat } })
);
return;
}
if (content.kind === Kind.Mentions) {
const mentionContent = content as NostrMentionContent;
const mention = JSON.parse(mentionContent.content);
updateStore('sidebar', 'page', 'mentions',
(mentions) => ({ ...mentions, [mention.id]: { ...mention } })
);
return;
}
if (content.kind === Kind.NoteActions) {
const noteActionContent = content as NostrNoteActionsContent;
const noteActions = JSON.parse(noteActionContent.content) as NoteActions;
updateStore('sidebar', 'page', 'noteActions',
(actions) => ({ ...actions, [noteActions.event_id]: { ...noteActions } })
);
return;
}
};
const saveSidebarPage = (page: FeedPage) => {
const newPosts = convertToArticles(page);
saveSidebarNotes(newPosts);
};
const doSidebarSearch = (query: string) => {
const subid = `reads_sidebar_${APP_ID}`;
updateStore('sidebar', 'isFetching', () => true);
updateStore('sidebar', 'notes', () => []);
updateStore('sidebar', 'page', { messages: [], users: {}, postStats: {}, mentions: {}, noteActions: {} });
getScoredUsers(account?.publicKey, query, 10, subid);
}
const clearFuture = () => {
updateStore('future', () => ({
notes: [],
reposts: {},
page: {
messages: [],
users: {},
postStats: {},
mentions: {},
noteActions: {},
topZaps: {},
},
scope: '',
timeframe: '',
latest_at: 0,
}))
}
const saveNotes = (newNotes: PrimalArticle[], scope?: 'future') => {
if (scope) {
updateStore(scope, 'notes', (notes) => [ ...notes, ...newNotes ]);
return;
}
updateStore('notes', (notes) => [ ...notes, ...newNotes ]);
updateStore('isFetching', () => false);
};
const checkForNewNotes = (topic: string | undefined) => {
if (!topic) {
return;
}
if (store.future.notes.length > 100) {
return;
}
const [scope, timeframe] = topic.split(';');
if (scope !== store.future.scope || timeframe !== store.future.timeframe) {
clearFuture();
updateStore('future', 'scope', () => scope);
updateStore('future', 'timeframe', () => timeframe);
}
let since = 0;
if (store.notes[0]) {
since = store.notes[0].repost ?
store.notes[0].repost.note.created_at :
store.notes[0].published;
}
if (store.future.notes[0]) {
const lastFutureNote = unwrap(store.future.notes).sort((a, b) => b.published - a.published)[0];
since = lastFutureNote.repost ?
lastFutureNote.repost.note.created_at :
lastFutureNote.published;
}
updateStore('future', 'page', () =>({
messages: [],
users: {},
postStats: {},
mentions: {},
noteActions: {},
}))
if (scope && timeframe) {
if (timeframe !== 'latest') {
return;
}
getFutureExploreFeed(
account?.publicKey,
`reads_future_${APP_ID}`,
scope,
timeframe,
since,
);
return;
}
getFutureArticlesFeed(account?.publicKey, topic, `reads_future_${APP_ID}`, since);
}
const loadFutureContent = () => {
if (store.future.notes.length === 0) {
return;
}
updateStore('notes', (notes) => [...store.future.notes, ...notes]);
clearFuture();
};
const fetchNotes = (topic: string, subId: string, until = 0, includeReplies?: boolean) => {
const [scope, timeframe] = topic.split(';');
updateStore('isFetching', true);
updateStore('page', () => ({ messages: [], users: {}, postStats: {} }));
if (scope && timeframe) {
if (scope === 'search') {
searchContent(account?.publicKey, `reads_feed_${subId}`, decodeURI(timeframe));
return;
}
getExploreFeed(
account?.publicKey,
`reads_feed_${subId}`,
scope,
timeframe,
until,
);
return;
}
getArticlesFeed(account?.publicKey, topic, `reads_feed_${subId}`, until, 20);
};
const clearNotes = () => {
updateStore('scrollTop', () => 0);
updateStore('page', () => ({ messages: [], users: {}, postStats: {}, noteActions: {} }));
updateStore('notes', () => []);
updateStore('reposts', () => undefined);
updateStore('lastNote', () => undefined);
clearFuture();
};
const fetchNextPage = () => {
if (store.isFetching) {
return;
}
const lastNote = store.notes[store.notes.length - 1];
if (!lastNote) {
return;
}
updateStore('lastNote', () => ({ ...lastNote }));
const topic = store.selectedFeed?.hex;
const includeReplies = store.selectedFeed?.includeReplies;
if (!topic) {
return;
}
const [scope, timeframe] = topic.split(';');
if (scope === 'search') {
return;
}
const pagCriteria = timeframe || 'latest';
const criteria = 'published'; //paginationPlan(pagCriteria);
const noteData: Record<string, any> = lastNote.repost ?
lastNote.repost.note :
lastNote;
const until = noteData[criteria];
if (until > 0) {
fetchNotes(topic, `${APP_ID}`, until, includeReplies);
}
};
const updateScrollTop = (top: number) => {
updateStore('scrollTop', () => top);
};
let currentFeed: PrimalFeed | undefined;
const selectFeed = (feed: PrimalFeed | undefined) => {
if (feed?.hex !== undefined && (feed.hex !== currentFeed?.hex || feed.includeReplies !== currentFeed?.includeReplies)) {
currentFeed = { ...feed };
saveStoredFeed(account?.publicKey, currentFeed);
updateStore('selectedFeed', reconcile({...feed}));
clearNotes();
fetchNotes(feed.hex , `${APP_ID}`, 0, feed.includeReplies);
}
};
const getFirstPage = () => {
const feed = store.selectedFeed;
if (!feed?.hex) return;
clearNotes();
fetchNotes(feed.hex , `${APP_ID}`, 0, feed.includeReplies);
};
const updatePage = (content: NostrEventContent, scope?: 'future') => {
if (content.kind === Kind.WordCount) {
const count = JSON.parse(content.content) as { event_id: string, words: number };
if (scope) {
updateStore(scope, 'page', 'wordCount',
() => ({ [count.event_id]: count.words })
);
return;
}
updateStore('page', 'wordCount',
() => ({ [count.event_id]: count.words })
);
return;
}
if (content.kind === Kind.Metadata) {
const user = content as NostrUserContent;
if (scope) {
updateStore(scope, 'page', 'users',
(usrs) => ({ ...usrs, [user.pubkey]: { ...user } })
);
return;
}
updateStore('page', 'users',
(usrs) => ({ ...usrs, [user.pubkey]: { ...user } })
);
return;
}
if ([Kind.LongForm, Kind.Repost].includes(content.kind)) {
const message = content as NostrNoteContent;
const isRepost = message.kind === Kind.Repost;
if (scope) {
const isFirstNote = message.kind === Kind.LongForm ?
store.notes[0]?.id === message.id :
store.notes[0]?.repost?.note.noteId === message.id;
const scopeNotes = store[scope].notes;
const isaAlreadyIn = message.kind === Kind.Text &&
scopeNotes &&
scopeNotes.find(n => n.id === message.id);
let isAlreadyReposted = isLFRepostInCollection(store[scope].page.messages, message);
// const isAlreadyFetched = message.kind === Kind.Text ?
// store.future.notes[0]?.post?.noteId === messageId :
// store.future.notes[0]?.repost?.note.noteId === messageId;
if (isFirstNote || isaAlreadyIn || isAlreadyReposted) return;
updateStore(scope, 'page', 'messages',
(msgs) => [ ...msgs, { ...message }]
);
return;
}
const isLastNote = message.kind === Kind.LongForm ?
store.lastNote?.id === message.id :
store.lastNote?.repost?.note.noteId === message.id;
let isAlreadyReposted = isRepostInCollection(store.page.messages, message);
if (isLastNote || isAlreadyReposted) return;
updateStore('page', 'messages',
(msgs) => [ ...msgs, { ...message }]
);
return;
}
if (content.kind === Kind.NoteStats) {
const statistic = content as NostrStatsContent;
const stat = JSON.parse(statistic.content);
if (scope) {
updateStore(scope, 'page', 'postStats',
(stats) => ({ ...stats, [stat.event_id]: { ...stat } })
);
return;
}
updateStore('page', 'postStats',
(stats) => ({ ...stats, [stat.event_id]: { ...stat } })
);
return;
}
if (content.kind === Kind.Mentions) {
const mentionContent = content as NostrMentionContent;
const mention = JSON.parse(mentionContent.content);
if (scope) {
updateStore(scope, 'page', 'mentions',
(mentions) => ({ ...mentions, [mention.id]: { ...mention } })
);
return;
}
updateStore('page', 'mentions',
(mentions) => ({ ...mentions, [mention.id]: { ...mention } })
);
return;
}
if (content.kind === Kind.NoteActions) {
const noteActionContent = content as NostrNoteActionsContent;
const noteActions = JSON.parse(noteActionContent.content) as NoteActions;
if (scope) {
updateStore(scope, 'page', 'noteActions',
(actions) => ({ ...actions, [noteActions.event_id]: { ...noteActions } })
);
return;
}
updateStore('page', 'noteActions',
(actions) => ({ ...actions, [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.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 (scope) {
const oldZaps = store[scope].page.topZaps[eventId];
if (oldZaps === undefined) {
updateStore(scope, 'page', 'topZaps', () => ({ [eventId]: [{ ...zap }]}));
return;
}
if (oldZaps.find(i => i.id === zap.id)) {
return;
}
const newZaps = [ ...oldZaps, { ...zap }].sort((a, b) => b.amount - a.amount);
updateStore(scope, 'page', 'topZaps', eventId, () => [ ...newZaps ]);
return;
}
const oldZaps = store.page.topZaps[eventId];
if (oldZaps === undefined) {
updateStore('page', 'topZaps', () => ({ [eventId]: [{ ...zap }]}));
return;
}
if (oldZaps.find(i => i.id === zap.id)) {
return;
}
const newZaps = [ ...oldZaps, { ...zap }].sort((a, b) => b.amount - a.amount);
updateStore('page', 'topZaps', eventId, () => [ ...newZaps ]);
return;
}
};
const savePage = (page: FeedPage, scope?: 'future') => {
const topic = (store.selectedFeed?.hex || '').split(';');
// const sortingFunction = sortingPlan(topic[1]);
const topZaps = scope ? store[scope].page.topZaps : store.page.topZaps
const newPosts = convertToArticles(page, topZaps);
saveNotes(newPosts, scope);
};
// SOCKET HANDLERS ------------------------------
// const onMessage = (event: MessageEvent) => {
// const message: NostrEvent | NostrEOSE = JSON.parse(event.data);
const onMessage = (event: MessageEvent) => {
const message: NostrEvent | NostrEOSE = JSON.parse(event.data);
// const [type, subId, content] = message;
const [type, subId, content] = message;
// if (subId === `home_sidebar_${APP_ID}`) {
// if (type === 'EOSE') {
// saveSidebarPage(store.sidebar.page);
// return;
// }
if (subId === `reads_sidebar_${APP_ID}`) {
if (type === 'EOSE') {
saveSidebarPage(store.sidebar.page);
return;
}
// if (!content) {
// return;
// }
if (!content) {
return;
}
// if (type === 'EVENT') {
// updateSidebarPage(content);
// return;
// }
// }
if (type === 'EVENT') {
updateSidebarPage(content);
return;
}
}
// if (subId === `home_feed_${APP_ID}`) {
// if (type === 'EOSE') {
// const reposts = parseEmptyReposts(store.page);
// const ids = Object.keys(reposts);
if (subId === `reads_feed_${APP_ID}`) {
if (type === 'EOSE') {
const reposts = parseEmptyReposts(store.page);
const ids = Object.keys(reposts);
// if (ids.length === 0) {
// savePage(store.page);
// return;
// }
if (ids.length === 0) {
savePage(store.page);
return;
}
// updateStore('reposts', () => reposts);
updateStore('reposts', () => reposts);
// getEvents(account?.publicKey, ids, `home_reposts_${APP_ID}`);
getEvents(account?.publicKey, ids, `reads_reposts_${APP_ID}`);
// return;
// }
return;
}
// if (type === 'EVENT') {
// updatePage(content);
// return;
// }
// }
if (type === 'EVENT') {
updatePage(content);
return;
}
}
// if (subId === `home_reposts_${APP_ID}`) {
// if (type === 'EOSE') {
// savePage(store.page);
// return;
// }
if (subId === `reads_reposts_${APP_ID}`) {
if (type === 'EOSE') {
savePage(store.page);
return;
}
// if (type === 'EVENT') {
// const repostId = (content as NostrNoteContent).id;
// const reposts = store.reposts || {};
// const parent = store.page.messages.find(m => m.id === reposts[repostId]);
if (type === 'EVENT') {
const repostId = (content as NostrNoteContent).id;
const reposts = store.reposts || {};
const parent = store.page.messages.find(m => m.id === reposts[repostId]);
// if (parent) {
// updateStore('page', 'messages', (msg) => msg.id === parent.id, 'content', () => JSON.stringify(content));
// }
if (parent) {
updateStore('page', 'messages', (msg) => msg.id === parent.id, 'content', () => JSON.stringify(content));
}
// return;
// }
// }
return;
}
}
// if (subId === `home_future_${APP_ID}`) {
// if (type === 'EOSE') {
// const reposts = parseEmptyReposts(store.future.page);
// const ids = Object.keys(reposts);
if (subId === `reads_future_${APP_ID}`) {
if (type === 'EOSE') {
const reposts = parseEmptyReposts(store.future.page);
const ids = Object.keys(reposts);
// if (ids.length === 0) {
// savePage(store.future.page, 'future');
// return;
// }
if (ids.length === 0) {
savePage(store.future.page, 'future');
return;
}
// updateStore('future', 'reposts', () => reposts);
updateStore('future', 'reposts', () => reposts);
// getEvents(account?.publicKey, ids, `home_future_reposts_${APP_ID}`);
getEvents(account?.publicKey, ids, `reads_future_reposts_${APP_ID}`);
// return;
// }
return;
}
// if (type === 'EVENT') {
// updatePage(content, 'future');
// return;
// }
// }
if (type === 'EVENT') {
updatePage(content, 'future');
return;
}
}
// if (subId === `home_future_reposts_${APP_ID}`) {
// if (type === 'EOSE') {
// savePage(store.future.page, 'future');
// return;
// }
if (subId === `reads_future_reposts_${APP_ID}`) {
if (type === 'EOSE') {
savePage(store.future.page, 'future');
return;
}
// if (type === 'EVENT') {
// const repostId = (content as NostrNoteContent).id;
// const reposts = store.future.reposts || {};
// const parent = store.future.page.messages.find(m => m.id === reposts[repostId]);
if (type === 'EVENT') {
const repostId = (content as NostrNoteContent).id;
const reposts = store.future.reposts || {};
const parent = store.future.page.messages.find(m => m.id === reposts[repostId]);
// if (parent) {
// updateStore('future', 'page', 'messages', (msg) => msg.id === parent.id, 'content', () => JSON.stringify(content));
// }
if (parent) {
updateStore('future', 'page', 'messages', (msg) => msg.id === parent.id, 'content', () => JSON.stringify(content));
}
// return;
// }
// }
return;
}
}
// };
};
// const onSocketClose = (closeEvent: CloseEvent) => {
// const webSocket = closeEvent.target as WebSocket;
const onSocketClose = (closeEvent: CloseEvent) => {
const webSocket = closeEvent.target as WebSocket;
// removeSocketListeners(
// webSocket,
// { message: onMessage, close: onSocketClose },
// );
// };
removeSocketListeners(
webSocket,
{ message: onMessage, close: onSocketClose },
);
};
// EFFECTS --------------------------------------
// createEffect(() => {
// if (isConnected()) {
// refreshSocketListeners(
// socket(),
// { message: onMessage, close: onSocketClose },
// );
// }
// });
createEffect(() => {
if (isConnected()) {
refreshSocketListeners(
socket(),
{ message: onMessage, close: onSocketClose },
);
}
});
// onCleanup(() => {
// removeSocketListeners(
// socket(),
// { message: onMessage, close: onSocketClose },
// );
// });
createEffect(() => {
if (account?.isKeyLookupDone && settings?.defaultFeed) {
const storedFeed = fetchStoredFeed(account.publicKey);
selectFeed(storedFeed || settings?.defaultFeed);
}
});
onCleanup(() => {
removeSocketListeners(
socket(),
{ message: onMessage, close: onSocketClose },
);
});
// STORES ---------------------------------------
const [store, updateStore] = createStore<ReadsContextStore>({
...initialData,
...initialHomeData,
actions: {
fetchPage,
saveNotes,
clearNotes,
fetchNotes,
fetchNextPage,
selectFeed,
updateScrollTop,
updatePage,
savePage,
checkForNewNotes,
loadFutureContent,
doSidebarSearch,
updateSidebarQuery,
getFirstPage,
},
});

View File

@ -50,7 +50,6 @@ export const fetchNotes = (pubkey: string | undefined, noteIds: string[], subId:
const updatePage = (content: NostrEventContent) => {
if (content.kind === Kind.Metadata) {
const user = content as NostrUserContent;
console.log('USER: ', user);
page.users[user.pubkey] = { ...user };

View File

@ -46,14 +46,14 @@ export const getFeed = (user_pubkey: string | undefined, pubkey: string | undef
]));
}
export const getArticlesFeed = (user_pubkey: string | undefined, pubkey: string | undefined, subid: string, until = 0, limit = 20, offset=0) => {
export const getArticlesFeed = (user_pubkey: string | undefined, pubkey: string | undefined, subid: string, until = 0, limit = 20) => {
if (!pubkey) {
return;
}
const start = until === 0 ? 'since' : 'until';
let payload = { limit, [start]: until, pubkey, offset };
let payload = { limit, [start]: until, pubkey };
if (user_pubkey) {
payload.user_pubkey = user_pubkey;
@ -66,6 +66,25 @@ export const getArticlesFeed = (user_pubkey: string | undefined, pubkey: string
]));
}
export const getFutureArticlesFeed = (user_pubkey: string | undefined, pubkey: string | undefined, subid: string, since: number) => {
if (!pubkey) {
return;
}
let payload: { since: number, pubkey: string, user_pubkey?: string, limit: number } =
{ since, pubkey, limit: 100 };
if (user_pubkey) {
payload.user_pubkey = user_pubkey;
}
sendMessage(JSON.stringify([
"REQ",
subid,
{cache: ["long_form_content_feed", payload]},
]));
};
export const getEvents = (user_pubkey: string | undefined, eventIds: string[], subid: string, extendResponse?: boolean) => {
let payload: {event_ids: string[], user_pubkey?: string, extended_response?: boolean } =

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, PrimalNote, SendNoteResult } from "../types/primal";
import { 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";
@ -337,6 +337,20 @@ export const sendRepost = async (note: PrimalNote, relays: Relay[], relaySetting
return await sendEvent(event, relays, relaySettings);
}
export const sendArticleRepost = async (note: PrimalArticle, relays: Relay[], relaySettings?: NostrRelays) => {
const event = {
content: JSON.stringify(note.msg),
kind: Kind.Repost,
tags: [
['e', note.id],
['p', note.author.pubkey],
],
created_at: Math.floor((new Date()).getTime() / 1000),
};
return await sendEvent(event, relays, relaySettings);
}
export const sendNote = async (text: string, relays: Relay[], tags: string[][], relaySettings?: NostrRelays) => {
const event = {
content: text,

View File

@ -0,0 +1,3 @@
.article {
color: var(--text-primary);
}

View File

@ -27,39 +27,158 @@ import { PrimalUser } from '../types/primal';
import Avatar from '../components/Avatar/Avatar';
import { userName } from '../stores/profile';
import { useAccountContext } from '../contexts/AccountContext';
import { reads as tReads, branding } from '../translations';
import { feedNewPosts, placeholders, branding } from '../translations';
import Search from '../components/Search/Search';
import { setIsHome } from '../components/Layout/Layout';
import PageTitle from '../components/PageTitle/PageTitle';
import { useAppContext } from '../contexts/AppContext';
import PageCaption from '../components/PageCaption/PageCaption';
import { useReadsContext } from '../contexts/ReadsContext';
import { Kind } from '../constants';
import ArticlePreview from '../components/ArticlePreview/ArticlePreview';
const Reads: Component = () => {
const Home: Component = () => {
const context = useReadsContext();
const account = useAccountContext();
const intl = useIntl();
const reads = useReadsContext();
const app = useAppContext();
const isPageLoading = () => context?.isFetching;
let checkNewNotesTimer: number = 0;
const [hasNewPosts, setHasNewPosts] = createSignal(false);
const [newNotesCount, setNewNotesCount] = createSignal(0);
const [newPostAuthors, setNewPostAuthors] = createStore<PrimalUser[]>([]);
const newPostCount = () => newNotesCount() < 100 ? newNotesCount() : 100;
onMount(() => {
reads?.actions.fetchPage(0, Kind.LongForm);
setIsHome(true);
scrollWindowTo(context?.scrollTop);
});
createEffect(() => {
if ((context?.future.notes.length || 0) > 99 || app?.isInactive) {
clearInterval(checkNewNotesTimer);
return;
}
const hex = context?.selectedFeed?.hex;
if (checkNewNotesTimer) {
clearInterval(checkNewNotesTimer);
setHasNewPosts(false);
setNewNotesCount(0);
setNewPostAuthors(() => []);
}
const timeout = 25_000 + Math.random() * 10_000;
checkNewNotesTimer = setInterval(() => {
context?.actions.checkForNewNotes(hex);
}, timeout);
});
createEffect(() => {
const count = context?.future.notes.length || 0;
if (count === 0) {
return
}
if (!hasNewPosts()) {
setHasNewPosts(true);
}
if (newPostAuthors.length < 3) {
const users = context?.future.notes.map(note => note.author) || [];
const uniqueUsers = users.reduce<PrimalUser[]>((acc, user) => {
const isDuplicate = acc.find(u => u && u.pubkey === user.pubkey);
return isDuplicate ? acc : [ ...acc, user ];
}, []).slice(0, 3);
setNewPostAuthors(() => [...uniqueUsers]);
}
setNewNotesCount(count);
});
onCleanup(()=> {
clearInterval(checkNewNotesTimer);
setIsHome(false);
});
const loadNewContent = () => {
if (newNotesCount() > 100 || app?.appState === 'waking') {
context?.actions.getFirstPage();
return;
}
context?.actions.loadFutureContent();
scrollWindowTo(0, true);
setHasNewPosts(false);
setNewNotesCount(0);
setNewPostAuthors(() => []);
}
return (
<div class={styles.homeContent}>
<PageTitle title={intl.formatMessage(branding)} />
<PageCaption title={intl.formatMessage(tReads.pageTitle)} />
<Wormhole
to="search_section"
>
<Search />
</Wormhole>
<div class={styles.normalCentralHeader}>
<HomeHeader
hasNewPosts={hasNewPosts}
loadNewContent={loadNewContent}
newPostCount={newPostCount}
newPostAuthors={newPostAuthors}
/>
</div>
<div class={styles.phoneCentralHeader}>
<HomeHeaderPhone />
</div>
<StickySidebar>
<HomeSidebar />
</StickySidebar>
<Show
when={context?.notes && context.notes.length > 0}
>
<div class={styles.feed}>
<For each={context?.notes} >
{note => <ArticlePreview article={note} />}
</For>
</div>
</Show>
<Switch>
<Match
when={!isPageLoading() && context?.notes && context?.notes.length === 0}
>
<div class={styles.noContent}>
<Loader />
</div>
</Match>
<Match
when={isPageLoading()}
>
<div class={styles.noContent}>
<Loader />
</div>
</Match>
</Switch>
<Paginator loadNextPage={context?.actions.fetchNextPage}/>
</div>
)
}
export default Reads;
export default Home;

View File

@ -4,7 +4,7 @@ import { Kind } from "../constants";
import { hexToNpub } from "../lib/keys";
import { logError } from "../lib/logger";
import { sanitize } from "../lib/notes";
import { RepostInfo, NostrNoteContent, FeedPage, PrimalNote, PrimalRepost, NostrEventContent, NostrEOSE, NostrEvent, PrimalUser, TopZap } from "../types/primal";
import { RepostInfo, NostrNoteContent, FeedPage, PrimalNote, PrimalRepost, NostrEventContent, NostrEOSE, NostrEvent, PrimalUser, TopZap, PrimalArticle } from "../types/primal";
import { convertToUser, emptyUser } from "./profile";
@ -87,6 +87,33 @@ export const isRepostInCollection = (collection: NostrNoteContent[], repost: Nos
return false;
};
export const isLFRepostInCollection = (collection: NostrNoteContent[], repost: NostrNoteContent) => {
const otherTags = collection.reduce((acc: string[][], m) => {
if (m.kind !== Kind.Repost) return acc;
const t = m.tags.find(t => t[0] === 'e');
if (!t) return acc;
return [...acc, t];
}, []);
if (repost.kind === Kind.Repost) {
const tag = repost.tags.find(t => t[0] === 'e');
return tag && !!otherTags.find(t => t[1] === tag[1]);
}
if (repost.kind === Kind.LongForm) {
const id = repost.id;
return !!otherTags.find(t => t[1] === id);
}
return false;
};
export const isInTags = (tags: string[][], tagName: string, value: string) => {
@ -124,6 +151,22 @@ const parseKind6 = (message: NostrNoteContent) => {
}
};
const parseLFKind6 = (message: NostrNoteContent) => {
try {
return JSON.parse(message.content);
} catch (e) {
return {
kind: Kind.LongForm,
content: '',
id: message.id,
created_at: message.created_at,
pubkey: message.pubkey,
sig: message.sig,
tags: message.tags,
}
}
};
// const getNoteReferences = (message: NostrNoteContent) => {
// const regex = /\#\[([0-9]*)\]/g;
// let refs = [];
@ -328,6 +371,125 @@ export const convertToNotes: ConvertToNotes = (page, topZaps) => {
});
}
type ConvertToArticles = (page: FeedPage | undefined, topZaps?: Record<string, TopZap[]>) => PrimalArticle[];
export const convertToArticles: ConvertToArticles = (page, topZaps) => {
if (page === undefined) {
return [];
}
const mentions = page.mentions || {};
return page.messages.map((message) => {
const msg: NostrNoteContent = message.kind === Kind.Repost ? parseKind6(message) : message;
const pubkey = msg.pubkey;
const identifier = (msg.tags.find(t => t[0] === 'd') || [])[1];
const kind = msg.kind;
const user = page?.users[msg.pubkey];
const mentionIds = Object.keys(mentions)
let userMentionIds = msg.tags?.reduce((acc, t) => t[0] === 'p' ? [...acc, t[1]] : acc, []);
let tz: TopZap[] = [];
if (topZaps && topZaps[msg.id]) {
tz = topZaps[msg.id] || [];
for(let i=0; i<tz.length; i++) {
if (userMentionIds.includes(tz[i].pubkey)) continue;
userMentionIds.push(tz[i].pubkey);
}
}
let mentionedNotes: Record<string, PrimalNote> = {};
let mentionedUsers: Record<string, PrimalUser> = {};
if (mentionIds.length > 0) {
for (let i = 0;i<mentionIds.length;i++) {
const id = mentionIds[i];
const m = mentions && mentions[id];
if (!m) {
continue;
}
for (let i = 0;i<m.tags.length;i++) {
const t = m.tags[i];
if (t[0] === 'p') {
mentionedUsers[t[1]] = convertToUser(page.users[t[1]] || emptyUser(t[1]));
}
}
mentionedNotes[id] = {
// @ts-ignore TODO: Investigate this typing
post: { ...m, noteId: nip19.noteEncode(m.id) },
user: convertToUser(page.users[m.pubkey] || emptyUser(m.pubkey)),
mentionedUsers,
};
}
}
if (userMentionIds && userMentionIds.length > 0) {
for (let i = 0;i<userMentionIds.length;i++) {
const id = userMentionIds[i];
const m = page.users && page.users[id];
mentionedUsers[id] = convertToUser(m || emptyUser(id));
}
}
const wordCount = page.wordCount ? page.wordCount[message.id] || 0 : 0;
let article: PrimalArticle = {
id: msg.id,
title: '',
summary: '',
image: '',
tags: [],
published: msg.created_at || 0,
content: msg.content,
author: convertToUser(user),
topZaps: [...tz],
naddr: nip19.naddrEncode({ identifier, pubkey, kind }),
msg,
mentionedNotes,
mentionedUsers,
wordCount,
};
msg.tags.forEach(tag => {
switch (tag[0]) {
case 't':
article.tags.push(tag[1]);
break;
case 'title':
article.title = tag[1];
break;
case 'summary':
article.summary = tag[1];
break;
case 'image':
article.image = tag[1];
break;
case 'published':
article.published = parseInt(tag[1]);
break;
default:
break;
}
});
return article;
});
}
const sortBy = (a: PrimalNote, b: PrimalNote, property: string) => {
const aData: Record<string, any> = a.repost ? a.repost.note : a.post;

20
src/types/primal.d.ts vendored
View File

@ -235,6 +235,13 @@ export type NostrQuoteStatsInfo = {
tags?: string[][],
};
export type NostrWordCount = {
kind: Kind.WordCount,
content: string,
created_at?: number,
tags?: string[][],
};
export type NostrEventContent =
NostrNoteContent |
NostrUserContent |
@ -266,7 +273,8 @@ export type NostrEventContent =
NostrBookmarks |
NostrRelayHint |
NostrZapInfo |
NostrQuoteStatsInfo;
NostrQuoteStatsInfo |
NostrWordCount;
export type NostrEvent = [
type: "EVENT",
@ -334,6 +342,7 @@ export type FeedPage = {
topZaps: Record<string, TopZap[]>,
since?: number,
until?: number,
wordCount?: Record<string, number>,
};
export type TrendingNotesStore = {
@ -492,6 +501,7 @@ export type PrimalNote = {
topZaps: TopZap[],
};
export type PrimalArticle = {
title: string,
summary: string,
@ -501,6 +511,14 @@ export type PrimalArticle = {
content: string,
author: PrimalUser,
topZaps: TopZap[],
repost?: PrimalRepost,
mentionedNotes?: Record<string, PrimalNote>,
mentionedUsers?: Record<string, PrimalUser>,
replyTo?: string,
id: string,
naddr: string,
msg: NostrNoteContent,
wordCount: number,
};
export type PrimalFeed = {