mirror of
https://github.com/PrimalHQ/primal-web-app.git
synced 2024-09-30 00:41:09 +00:00
Reads page
This commit is contained in:
parent
e075c7741f
commit
cca4d11df0
117
src/components/ArticlePreview/ArticlePreview.module.scss
Normal file
117
src/components/ArticlePreview/ArticlePreview.module.scss
Normal 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;
|
||||
}
|
||||
|
||||
}
|
118
src/components/ArticlePreview/ArticlePreview.tsx
Normal file
118
src/components/ArticlePreview/ArticlePreview.tsx
Normal 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);
|
160
src/components/BookmarkNote/BookmarkArticle.tsx
Normal file
160
src/components/BookmarkNote/BookmarkArticle.tsx
Normal 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;
|
406
src/components/Note/NoteFooter/ArticleFooter.tsx
Normal file
406
src/components/Note/NoteFooter/ArticleFooter.tsx
Normal 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);
|
51
src/components/Note/NoteFooter/ArticleFooterActionButton.tsx
Normal file
51
src/components/Note/NoteFooter/ArticleFooterActionButton.tsx
Normal 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;
|
@ -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,
|
||||
}
|
||||
|
@ -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') => {
|
||||
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -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 };
|
||||
|
||||
|
@ -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 } =
|
||||
|
@ -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,
|
||||
|
3
src/pages/Reads.module.scss
Normal file
3
src/pages/Reads.module.scss
Normal file
@ -0,0 +1,3 @@
|
||||
.article {
|
||||
color: var(--text-primary);
|
||||
}
|
@ -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;
|
||||
|
@ -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
20
src/types/primal.d.ts
vendored
@ -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 = {
|
||||
|
Loading…
Reference in New Issue
Block a user