Compare commits

..

48 Commits

Author SHA1 Message Date
Dominik
fb2e26cb23
Merge 6c73249ba7 into a69f7d5950 2024-06-13 00:05:32 +00:00
Bojan Mojsilovic
a69f7d5950 Proper subs from profile and article 2024-06-10 15:44:26 +02:00
Bojan Mojsilovic
4440e0d499 Fix some styling 2024-06-10 15:06:43 +02:00
Bojan Mojsilovic
23d8bb980b Show all reads for guests 2024-06-10 14:51:03 +02:00
Bojan Mojsilovic
f309b2bfc0 Handle USD subscriptions 2024-06-10 13:23:41 +02:00
Bojan Mojsilovic
40bc2ec8a7 fix 2024-06-07 17:52:59 +02:00
Bojan Mojsilovic
4524f7d4fc Update featured author layout 2024-06-07 16:33:06 +02:00
Bojan Mojsilovic
de05c5d0a1 Filter-out non-sats prices 2024-06-07 16:24:16 +02:00
Bojan Mojsilovic
bd09ed7668 More subs stuff 2024-06-06 17:15:00 +02:00
Bojan Mojsilovic
fcb3926e67 Basic subscribe flow 2024-06-06 13:05:49 +02:00
Bojan Mojsilovic
388c2e689d Link article author to profile 2024-06-05 13:50:02 +02:00
Bojan Mojsilovic
829675481d Profile reads 2024-06-05 13:12:28 +02:00
Bojan Mojsilovic
ebdda42433 Reply to article 2024-06-05 12:16:38 +02:00
Bojan Mojsilovic
5b5afea786 Fix displaying missing images 2024-06-04 17:08:17 +02:00
Bojan Mojsilovic
e445b11019 Fix reads header 2024-06-04 16:48:48 +02:00
Bojan Mojsilovic
cfa16f5964 Article sidebar 2024-06-04 16:00:09 +02:00
Bojan Mojsilovic
1ce4ecd7da fix bookmarks 2024-06-04 15:54:10 +02:00
Bojan Mojsilovic
b7b99350a8 fix paging 2024-06-03 17:43:42 +02:00
Bojan Mojsilovic
635b504d5e Basic reeds select 2024-06-03 16:25:47 +02:00
Bojan Mojsilovic
f22a02318c Reads preview changes 2024-06-03 15:39:40 +02:00
Bojan Mojsilovic
0081870198 Redo long-form note view 2024-06-03 15:29:57 +02:00
Bojan Mojsilovic
622dad8ecc Add read image zoom 2024-05-31 18:00:33 +02:00
Bojan Mojsilovic
26d8885b9c Style image previews 2024-05-31 17:46:49 +02:00
Bojan Mojsilovic
d32d2af392 Table styles 2024-05-31 16:29:52 +02:00
Bojan Mojsilovic
70a935bc6f Remove Lora font 2024-05-31 16:11:23 +02:00
Bojan Mojsilovic
254499a4e4 Add reading estimate 2024-05-31 16:09:37 +02:00
Bojan Mojsilovic
b4b51a242d Fix top reads 2024-05-31 14:59:19 +02:00
Bojan Mojsilovic
b1ad4299eb Add reads sidebar 2024-05-31 12:42:56 +02:00
Bojan Mojsilovic
219f3ab084 Handle image zoom in reads 2024-05-31 12:42:13 +02:00
Bojan Mojsilovic
b606e90532 Fix footer and context menu in article preview 2024-05-30 14:34:03 +02:00
Bojan Mojsilovic
e28299e9f0 Improved Markdown styling 2024-05-29 19:38:18 +02:00
Bojan Mojsilovic
a7eec46a27 Fix lottie animations 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic
2fc28409cd Fix notification note size 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic
f7e59e4f9b Fix new layout 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic
d152ac47d7 New home header 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic
77494df791 New feed note layout 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic
f9b790ac58 Widen content 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic
e1d565e939 New nav menu styling 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic
2993a97e7f Fix test page 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic
3914a3c0c9 Enable reactions 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic
cca4d11df0 Reads page 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic
e075c7741f WIP 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic
129aca7a54 Refactor thread route 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic
8f1a53d2ed Adjust styling for lf notes 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic
786fda989a First markdown render try 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic
52b4aba426 Markdown test 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic
6a2ce12501 Basic longform render 2024-05-29 19:02:53 +02:00
Bojan Mojsilovic
4c7de1e307 Fix home feed 2024-05-29 19:02:53 +02:00
62 changed files with 4227 additions and 447 deletions

3
src/assets/icons/dot.svg Normal file
View File

@ -0,0 +1,3 @@
<svg width="6" height="6" viewBox="0 0 6 6" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.2 5.384C2.76267 5.384 2.35733 5.28267 1.984 5.08C1.62133 4.86667 1.33333 4.57867 1.12 4.216C0.906667 3.85333 0.8 3.45333 0.8 3.016C0.8 2.568 0.906667 2.168 1.12 1.816C1.33333 1.45333 1.62133 1.17067 1.984 0.967999C2.35733 0.754666 2.76267 0.648 3.2 0.648C3.648 0.648 4.05333 0.754666 4.416 0.967999C4.77867 1.17067 5.06667 1.45333 5.28 1.816C5.504 2.168 5.616 2.568 5.616 3.016C5.616 3.45333 5.504 3.85333 5.28 4.216C5.06667 4.57867 4.77867 4.86667 4.416 5.08C4.05333 5.28267 3.648 5.384 3.2 5.384Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 626 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -1,4 +1,5 @@
.article {
.article, .articleShort {
position: relative;
display: flex;
flex-direction: column;
text-decoration: none;
@ -43,6 +44,10 @@
font-size: 14px;
font-weight: 400;
line-height: 14px;
&::before {
content: '';
}
}
}
@ -61,17 +66,28 @@
.content {
.title {
color: var(--text-primary);
font-size: 20px;
font-weight: 700;
line-height: 28px;
font-size: 24px;
font-weight: 800;
line-height: 32px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
}
.summary {
color: var(--text-primary);
font-family: Lora;
color: var(--brand-text);
font-size: 15px;
font-weight: 400;
line-height: 22px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 5;
line-clamp: 5;
-webkit-box-orient: vertical;
}
}
@ -88,24 +104,45 @@
padding: 6px 10px;
border-radius: 12px;
width: fit-content;
margin: 4px;
margin-block: 4px;
margin-inline: 3px;
}
.estimate {
display: inline-block;
color: var(--text-secondary);
font-size: 12px;
font-weight: 400;
font-weight: 600;
line-height: 12px;
padding: 6px 10px;
border: 1px solid var(--subtile-devider );
border-radius: 12px;
margin-right: 3px;
}
}
}
.image {
min-width: 164px;
min-height: 70px;
max-height: 240px;
height: fit-content;
border: 1px solid var(--devider);
border-radius: 8px;
overflow: hidden;
text-align: center;
img {
width: 164px;
max-width: 162px;
max-height: 238px;
object-fit: scale-down;
}
.placeholderImage {
width: 164px;
height: 118px;
background-image: var(--reads-placeholder-image);
background-size: contain;
}
}
}
@ -115,3 +152,65 @@
}
}
.upRightFloater {
position: absolute;
top: -6px;
right: 8px;
}
.articleShort {
padding: 0;
.header {
.userInfo {
.userName {
font-size: 15px;
font-weight: 700;
line-height: normal;
}
}
.time {
font-size: 15px;
font-weight: 400;
line-height: normal;
}
}
.body {
.text {
.content {
.title {
font-size: 16px;
font-weight: 800;
line-height: 24px;
}
.estimate {
color: var(--text-tertiary);
font-size: 15px;
font-weight: 400;
line-height: 24px;
}
}
}
.image {
min-width: 100px;
height: fit-content;
border: 1px solid var(--devider);
border-radius: 8px;
overflow: hidden;
img {
width: 100px;
object-fit: scale-down;
}
.placeholderImage {
width: 100px;
height: 70px;
background-image: var(--reads-placeholder-image);
background-size: contain;
}
}
}
}

View File

@ -12,6 +12,7 @@ import { PrimalArticle, ZapOption } from '../../types/primal';
import { uuidv4 } from '../../utils';
import Avatar from '../Avatar/Avatar';
import { NoteReactionsState } from '../Note/Note';
import NoteContextTrigger from '../Note/NoteContextTrigger';
import ArticleFooter from '../Note/NoteFooter/ArticleFooter';
import NoteFooter from '../Note/NoteFooter/NoteFooter';
import NoteTopZaps from '../Note/NoteTopZaps';
@ -53,6 +54,7 @@ const ArticlePreview: Component<{
let latestTopZap: string = '';
let latestTopZapFeed: string = '';
let articleContextMenu: HTMLDivElement | undefined;
const onConfirmZap = (zapOption: ZapOption) => {
app?.actions.closeCustomZapModal();
@ -181,19 +183,48 @@ const ArticlePreview: Component<{
onCancel: onCancelZap,
});
const openReactionModal = (openOn = 'likes') => {
app?.actions.openReactionModal(props.article.id, {
likes: reactionsState.likes,
zaps: reactionsState.zapCount,
reposts: reactionsState.reposts,
quotes: reactionsState.quoteCount,
openOn,
});
};
const onContextMenuTrigger = () => {
app?.actions.openContextMenu(
props.article,
articleContextMenu?.getBoundingClientRect(),
() => {
app?.actions.openCustomZapModal(customZapInfo());
},
openReactionModal,
);
}
return (
<A class={styles.article} href={`/e/${props.article.naddr}`}>
<div class={styles.upRightFloater}>
<NoteContextTrigger
ref={articleContextMenu}
onClick={onContextMenuTrigger}
/>
</div>
<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>
<Avatar user={props.article.user} size="micro"/>
<div class={styles.userName}>{userName(props.article.user)}</div>
<VerificationCheck user={props.article.user} />
<div class={styles.nip05}>{props.article.user.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}>
@ -205,20 +236,30 @@ const ArticlePreview: Component<{
</div>
</div>
<div class={styles.tags}>
<For each={props.article.tags}>
<div class={styles.estimate}>
{Math.ceil(props.article.wordCount / 238)} minute read
</div>
<For each={props.article.tags.slice(0, 3)}>
{tag => (
<div class={styles.tag}>
{tag}
</div>
)}
</For>
<div class={styles.estimate}>
{Math.ceil(props.article.wordCount / 238)} minute read
</div>
<Show when={props.article.tags.length > 3}>
<div class={styles.tag}>
+ {props.article.tags.length - 3}
</div>
</Show>
</div>
</div>
<div class={styles.image}>
<img src={props.article.image} />
<Show
when={props.article.image}
fallback={<div class={styles.placeholderImage}></div>}
>
<img src={props.article.image} />
</Show>
</div>
</div>

View File

@ -0,0 +1,65 @@
import { A } from '@solidjs/router';
import { batch, Component, createEffect, For, JSXElement, Show } from 'solid-js';
import { createStore } from 'solid-js/store';
import { Portal } from 'solid-js/web';
import { useAccountContext } from '../../contexts/AccountContext';
import { CustomZapInfo, useAppContext } from '../../contexts/AppContext';
import { useThreadContext } from '../../contexts/ThreadContext';
import { date, shortDate } from '../../lib/dates';
import { hookForDev } from '../../lib/devTools';
import { userName } from '../../stores/profile';
import { PrimalArticle, ZapOption } from '../../types/primal';
import { uuidv4 } from '../../utils';
import Avatar from '../Avatar/Avatar';
import { NoteReactionsState } from '../Note/Note';
import NoteContextTrigger from '../Note/NoteContextTrigger';
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) => {
return (
<A class={styles.articleShort} href={`/e/${props.article.noteId}`}>
<div class={styles.header}>
<div class={styles.userInfo}>
<Avatar user={props.article.user} size="micro"/>
<div class={styles.userName}>{userName(props.article.user)}</div>
</div>
<div class={styles.time}>
{date(props.article.published).label}
</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.estimate}>
{Math.ceil(props.article.wordCount / 238)} minutes
</div>
</div>
</div>
<div class={styles.image}>
<Show
when={props.article.image}
fallback={<div class={styles.placeholderImage}></div>}
>
<img src={props.article.image} />
</Show>
</div>
</div>
</A>
);
}
export default hookForDev(ArticlePreview);

View File

@ -0,0 +1,66 @@
.authorSubscribeCard {
display: flex;
flex-direction: column;
gap: 16px;
border-radius: 8px;
background: var(--background-header-input);
width: 300px;
padding: 16px;
.userInfo {
display: flex;
gap: 8px;
.userData {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
overflow: hidden;
.userName {
display: flex;
color: var(--text-primary);
font-size: 18px;
font-weight: 600;
line-height: 18px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.nip05 {
color: var(--text-tertiary);
font-size: 15px;
font-weight: 400;
line-height: 16px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
}
.userPitch {
color: var(--text-primary);
font-size: 15px;
font-weight: 400;
line-height: 22px;
}
.actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
>button {
text-align: center;
font-size: 14px;
font-weight: 600;
line-height: 16px;
padding-inline: 16px;
width: 100%;
}
}
}

View File

@ -0,0 +1,153 @@
import { A, useNavigate } from '@solidjs/router';
import { batch, Component, createEffect, createSignal, For, JSXElement, onMount, Show } from 'solid-js';
import { createStore } from 'solid-js/store';
import { Portal } from 'solid-js/web';
import { APP_ID } from '../../App';
import { Kind } from '../../constants';
import { useAccountContext } from '../../contexts/AccountContext';
import { CustomZapInfo, useAppContext } from '../../contexts/AppContext';
import { useThreadContext } from '../../contexts/ThreadContext';
import { fetchUserProfile } from '../../handleNotes';
import { date, shortDate } from '../../lib/dates';
import { hookForDev } from '../../lib/devTools';
import { sendEvent } from '../../lib/notes';
import { zapSubscription } from '../../lib/zap';
import { userName } from '../../stores/profile';
import { PrimalArticle, PrimalUser, ZapOption } from '../../types/primal';
import { uuidv4 } from '../../utils';
import Avatar from '../Avatar/Avatar';
import ButtonPrimary from '../Buttons/ButtonPrimary';
import ButtonSecondary from '../Buttons/ButtonSecondary';
import Loader from '../Loader/Loader';
import { Tier, TierCost } from '../SubscribeToAuthorModal/SubscribeToAuthorModal';
import VerificationCheck from '../VerificationCheck/VerificationCheck';
import styles from './AuthorSubscribe.module.scss';
const AuthoreSubscribe: Component<{
id?: string,
pubkey: string,
}> = (props) => {
const account = useAccountContext();
const app = useAppContext();
const navigate = useNavigate();
const [isFetching, setIsFetching] = createSignal(false);
const [author, setAuthor] = createSignal<PrimalUser>();
const getAuthorData = async () => {
if (!account?.publicKey) return;
const subId = `reads_fpi_${APP_ID}`;
setIsFetching(() => true);
const profile = await fetchUserProfile(account.publicKey, props.pubkey, subId);
setIsFetching(() => false);
setAuthor(() => ({ ...profile }));
};
onMount(() => {
getAuthorData();
});
const doSubscription = async (tier: Tier, cost: TierCost, exchangeRate?: Record<string, Record<string, number>>) => {
const a = author();
if (!a || !account || !cost) return;
if (cost.unit === 'USD' && (!exchangeRate || !exchangeRate['USD'])) return;
const subEvent = {
kind: Kind.Subscribe,
content: '',
created_at: Math.floor((new Date()).getTime() / 1_000),
tags: [
['p', a.pubkey],
['e', tier.id],
['amount', cost.amount, cost.unit, cost.cadence],
['event', JSON.stringify(tier.event)],
// Copy any zap splits
...(tier.event.tags?.filter(t => t[0] === 'zap') || []),
],
}
const { success, note } = await sendEvent(subEvent, account.relays, account.relaySettings);
if (success && note) {
const isZapped = await zapSubscription(note, a, account.publicKey, account.relays, exchangeRate);
if (!isZapped) {
unsubscribe(note.id);
}
}
}
const unsubscribe = async (eventId: string) => {
const a = author();
if (!a || !account) return;
const unsubEvent = {
kind: Kind.Unsubscribe,
content: '',
created_at: Math.floor((new Date()).getTime() / 1_000),
tags: [
['p', a.pubkey],
['e', eventId],
],
};
await sendEvent(unsubEvent, account.relays, account.relaySettings);
}
const openSubscribe = () => {
app?.actions.openAuthorSubscribeModal(author(), doSubscription);
};
return (
<div class={styles.featuredAuthor}>
<Show
when={!isFetching()}
fallback={<Loader />}
>
<div class={styles.authorSubscribeCard}>
<div class={styles.userInfo}>
<Avatar user={author()} size="ml" />
<div class={styles.userData}>
<div class={styles.userName}>
{userName(author())}
<VerificationCheck user={author()} />
</div>
<div class={styles.nip05}>
{author()?.nip05}
</div>
</div>
</div>
<div class={styles.userPitch}>
{author()?.about || ''}
</div>
<div class={styles.actions}>
<ButtonSecondary
light={true}
onClick={() => navigate(`/p/${author()?.npub}`)}
>
view profile
</ButtonSecondary>
<ButtonPrimary onClick={openSubscribe}>
subscribe
</ButtonPrimary>
</div>
</div>
</Show>
</div>
);
}
export default hookForDev(AuthoreSubscribe);

View File

@ -164,6 +164,21 @@
}
}
.mlAvatar {
@include avatar;
width: 60px;
height: 60px;
.missingBack {
width: 60px;
height: 60px;
}
.iconBackground {
@include iconBackground;
}
}
.largeAvatar {
@include avatar;
width: 72px;
@ -290,6 +305,13 @@
font-size: 16px;
}
.mlMissing {
@include missing;
width: 60px;
height: 60px;
font-size: 16px;
}
.largeMissing {
@include missing;
width: 68px;

View File

@ -11,7 +11,7 @@ import styles from './Avatar.module.scss';
const Avatar: Component<{
src?: string | undefined,
size?: "micro" | "xxs" | "xss" | "xs" | "vvs" | "vs" | "sm" | "md" | "lg" | "xl" | "xxl",
size?: "micro" | "xxs" | "xss" | "xs" | "vvs" | "vs" | "sm" | "md" | "ml" | "lg" | "xl" | "xxl",
user?: PrimalUser,
highlightBorder?: boolean,
id?: string,
@ -34,6 +34,7 @@ const Avatar: Component<{
vs: styles.vsAvatar,
sm: styles.smallAvatar,
md: styles.midAvatar,
ml: styles.mlAvatar,
lg: styles.largeAvatar,
xl: styles.extraLargeAvatar,
xxl: styles.xxlAvatar,
@ -48,6 +49,7 @@ const Avatar: Component<{
vs: styles.vsMissing,
sm: styles.smallMissing,
md: styles.midMissing,
ml: styles.mlMissing,
lg: styles.largeMissing,
xl: styles.extraLargeMissing,
xxl: styles.xxlMissing,

View File

@ -16,7 +16,7 @@ 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 BookmarkArticle: Component<{ note: PrimalArticle | undefined, large?: boolean }> = (props) => {
const account = useAccountContext();
const app = useAppContext();
const intl = useIntl();
@ -24,8 +24,13 @@ const BookmarkArticle: Component<{ note: PrimalArticle, large?: boolean }> = (pr
const [isBookmarked, setIsBookmarked] = createSignal(false);
const [bookmarkInProgress, setBookmarkInProgress] = createSignal(false);
createEffect(() => {
setIsBookmarked(() => account?.bookmarks.includes(props.note.id) || false);
const note = props.note;
if (note) {
setIsBookmarked(() => account?.bookmarks.includes(note.id) || false);
}
})
const updateBookmarks = async (bookmarkTags: string[][]) => {
@ -47,7 +52,7 @@ const BookmarkArticle: Component<{ note: PrimalArticle, large?: boolean }> = (pr
};
const addBookmark = async (bookmarkTags: string[][]) => {
if (account && !bookmarkTags.find(b => b[0] === 'e' && b[1] === props.note.id)) {
if (account && props.note && !bookmarkTags.find(b => b[0] === 'e' && b[1] === props.note?.id)) {
const bookmarksToAdd = [...bookmarkTags, ['e', props.note.id]];
if (bookmarksToAdd.length < 2) {
@ -73,8 +78,8 @@ const BookmarkArticle: Component<{ note: PrimalArticle, large?: boolean }> = (pr
}
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 (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}`);

View File

@ -37,3 +37,14 @@
}
}
}
.right {
display: flex;
justify-content: flex-end;
padding-left: 6px !important;
padding-right: 0px !important;
&.rightL {
width: 22px !important;
}
}

View File

@ -16,7 +16,7 @@ import styles from './BookmarkNote.module.scss';
import { saveBookmarks } from '../../lib/localStore';
import { importEvents, triggerImportEvents } from '../../lib/notes';
const BookmarkNote: Component<{ note: PrimalNote, large?: boolean }> = (props) => {
const BookmarkNote: Component<{ note: PrimalNote, large?: boolean, right?: boolean }> = (props) => {
const account = useAccountContext();
const app = useAppContext();
const intl = useIntl();
@ -136,6 +136,7 @@ const BookmarkNote: Component<{ note: PrimalNote, large?: boolean }> = (props) =
return (
<div class={styles.bookmark}>
<ButtonGhost
class={`${props.right ? styles.right : ''} ${props.large ? styles.rightL : ''}`}
onClick={(e: MouseEvent) => {
e.preventDefault();

View File

@ -118,6 +118,7 @@ const EmbeddedNote: Component<{
shorten={true}
isEmbeded={true}
width={noteContent?.getBoundingClientRect().width}
margins={2}
/>
</div>
</>

View File

@ -8,7 +8,7 @@ import { FeedOption, PrimalFeed, SelectionOption } from '../../types/primal';
import SelectBox from '../SelectBox/SelectBox';
import SelectionBox from '../SelectionBox/SelectionBox';
const FeedSelect: Component<{ isPhone?: boolean, id?: string}> = (props) => {
const FeedSelect: Component<{ isPhone?: boolean, id?: string, big?: boolean}> = (props) => {
const account = useAccountContext();
const home = useHomeContext();
@ -123,6 +123,7 @@ const FeedSelect: Component<{ isPhone?: boolean, id?: string}> = (props) => {
value={selectedValue()}
isSelected={isSelected}
isPhone={props.isPhone}
big={props.big}
/>
);
}

View File

@ -0,0 +1,129 @@
import { Component } from 'solid-js';
import { useAccountContext } from '../../contexts/AccountContext';
import { useHomeContext } from '../../contexts/HomeContext';
import { useReadsContext } from '../../contexts/ReadsContext';
import { useSettingsContext } from '../../contexts/SettingsContext';
import { hookForDev } from '../../lib/devTools';
import { fetchStoredFeed } from '../../lib/localStore';
import { FeedOption, PrimalFeed, SelectionOption } from '../../types/primal';
import SelectBox from '../SelectBox/SelectBox';
import SelectionBox from '../SelectionBox/SelectionBox';
const ReedSelect: Component<{ isPhone?: boolean, id?: string, big?: boolean}> = (props) => {
const account = useAccountContext();
const reeds = useReadsContext();
const settings = useSettingsContext();
const findFeed = (hex: string, includeReplies: string) => {
const ir = includeReplies === 'undefined' ? undefined :
includeReplies === 'true';
return settings?.availableFeeds.find(f => {
const isHex = f.hex === hex;
const isOpt = typeof ir === typeof f.includeReplies ?
f.includeReplies === ir :
false;
return isHex && isOpt;
});
};
const selectFeed = (option: FeedOption) => {
const [hex, includeReplies] = option.value?.split('_') || [];
// const selector = document.getElementById('defocus');
// selector?.focus();
// selector?.blur();
const feed = {
hex: option.value,
name: option.label,
};
reeds?.actions.clearNotes();
reeds?.actions.selectFeed(feed);
};
const isSelected = (option: FeedOption) => {
const selected = reeds?.selectedFeed;
if (selected?.hex && option.value) {
const t = option.value.split('_');
const isHex = encodeURI(selected.hex) == t[0];
const isOpt = t[1] === 'undefined' ?
selected.includeReplies === undefined :
selected.includeReplies?.toString() === t[1];
return isHex && isOpt;
}
return false;
}
const options:() => SelectionOption[] = () => {
let opts = [];
if (account?.publicKey) {
opts.push(
{
label: 'My Reads',
value: account?.publicKey || '',
}
);
}
opts.push(
{
label: 'All Reads',
value: 'none',
}
);
return [ ...opts ];
};
const initialValue = () => {
const selected = reeds?.selectedFeed;
if (!selected) {
const feed = options()[0];
selectFeed(feed);
return feed;
}
return {
label: selected.name,
value: selected.hex || '',
}
}
const selectedValue = () => {
if (!reeds?.selectedFeed)
return initialValue();
const value = `${encodeURI(reeds.selectedFeed.hex || '')}`;
return {
label: reeds.selectedFeed.name,
value,
};
};
return (
<SelectionBox
options={options()}
onChange={selectFeed}
initialValue={initialValue()}
value={selectedValue()}
isSelected={isSelected}
isPhone={props.isPhone}
big={props.big}
/>
);
}
export default hookForDev(ReedSelect);

View File

@ -123,6 +123,13 @@
border-bottom: 1px solid var(--devider);
z-index: var(--z-index-header);
&.readsFeed {
position: relative;
border-bottom: none;
padding-inline: 0;
z-index: var(--z-index-header);
}
.newContentItem {
color: var(--accent-links);
@ -155,7 +162,7 @@
outline: none;
padding-block: 21px;
padding-inline: 12px;
padding-inline: 20px;
border: none;
border-radius: 0;
justify-content: flex-start;

View File

@ -119,7 +119,7 @@ const HomeHeader: Component< {
<Show
when={settings?.availableFeeds && settings?.availableFeeds.length > 0 && home?.selectedFeed}
>
<FeedSelect />
<FeedSelect big={true} />
</Show>
<Show

View File

@ -0,0 +1,37 @@
import { Component, createSignal, For, onCleanup, onMount, Show } from 'solid-js';
import Avatar from '../Avatar/Avatar';
import styles from './HomeHeader.module.scss';
import FeedSelect from '../FeedSelect/FeedSelect';
import { useAccountContext } from '../../contexts/AccountContext';
import SmallCallToAction from '../SmallCallToAction/SmallCallToAction';
import { useHomeContext } from '../../contexts/HomeContext';
import { useIntl } from '@cookbook/solid-intl';
import { useSettingsContext } from '../../contexts/SettingsContext';
import { placeholders as t, actions as tActions, feedNewPosts } from '../../translations';
import { hookForDev } from '../../lib/devTools';
import ButtonPrimary from '../Buttons/ButtonPrimary';
import CreateAccountModal from '../CreateAccountModal/CreateAccountModal';
import LoginModal from '../LoginModal/LoginModal';
import { userName } from '../../stores/profile';
import { PrimalUser } from '../../types/primal';
import ReedSelect from '../FeedSelect/ReedSelect';
const ReadsHeader: Component< {
id?: string,
hasNewPosts: () => boolean,
loadNewContent: () => void,
newPostCount: () => number,
newPostAuthors: PrimalUser[],
} > = (props) => {
return (
<div id={props.id}>
<div class={`${styles.bigFeedSelect} ${styles.readsFeed}`}>
<ReedSelect big={true} />
</div>
</div>
);
}
export default hookForDev(ReadsHeader);

View File

@ -0,0 +1,96 @@
import { Component, createEffect, createSignal, For, onMount, Show } from 'solid-js';
import {
EventCoordinate,
PrimalArticle,
PrimalUser,
SelectionOption
} from '../../types/primal';
import styles from './HomeSidebar.module.scss';
import SmallNote from '../SmallNote/SmallNote';
import { useAccountContext } from '../../contexts/AccountContext';
import { hookForDev } from '../../lib/devTools';
import SelectionBox from '../SelectionBox/SelectionBox';
import Loader from '../Loader/Loader';
import { readHomeSidebarSelection, saveHomeSidebarSelection } from '../../lib/localStore';
import { useHomeContext } from '../../contexts/HomeContext';
import { useReadsContext } from '../../contexts/ReadsContext';
import { createStore } from 'solid-js/store';
import { APP_ID } from '../../App';
import { subsTo } from '../../sockets';
import { getArticleThread, getReadsTopics, getUserArticleFeed } from '../../lib/feed';
import { fetchUserArticles } from '../../handleNotes';
import { getParametrizedEvent, getParametrizedEvents } from '../../lib/notes';
import { decodeIdentifier } from '../../lib/keys';
import ArticleShort from '../ArticlePreview/ArticleShort';
import { userName } from '../../stores/profile';
const ArticleSidebar: Component< { id?: string, user: PrimalUser, article: PrimalArticle } > = (props) => {
const account = useAccountContext();
const [recomended, setRecomended] = createStore<PrimalArticle[]>([]);
const [isFetchingArticles, setIsFetchingArticles] = createSignal(false);
const getArticles = async () => {
const subId = `article_recomended_${APP_ID}`;
setIsFetchingArticles(() => true);
const articles = await fetchUserArticles(account?.publicKey, props.user.pubkey, 'authored', subId);
setRecomended(() => [...articles.filter(a => a.id !== props.article.id)]);
setIsFetchingArticles(() => false);
}
createEffect(() => {
if (account?.isKeyLookupDone && props.user) {
getArticles();
}
});
return (
<div id={props.id} class={styles.articleSidebar}>
<Show when={account?.isKeyLookupDone && props.article}>
<div class={styles.headingPicks}>
Total zaps
</div>
<div class={styles.section}>
<div class={styles.totalZaps}>
<span class={styles.totalZapsIcon} />
<span class={styles.amount}>26,450</span>
<span class={styles.unit}>sats</span>
</div>
</div>
<Show
when={!isFetchingArticles()}
fallback={
<Loader />
}
>
<Show
when={recomended.length > 0}
>
<div class={styles.headingReads}>
More Reads from {userName(props.article.user)}
</div>
<div class={styles.section}>
<For each={recomended}>
{(note) => <ArticleShort article={note} />}
</For>
</div>
</Show>
</Show>
</Show>
</div>
);
}
export default hookForDev(ArticleSidebar);

View File

@ -56,3 +56,99 @@
margin-top: 34px;
z-index: 10px;
}
.headingPicks {
@include heading();
font-weight: 600;
text-transform: capitalize;
height: fit-content;
margin-bottom: 12px;
padding-bottom: 0px;
}
.headingReads {
@include heading();
font-weight: 600;
text-transform: none;
height: fit-content;
margin-bottom: 12px;
padding-bottom: 0px;
}
.readsSidebar {
margin-left: -8px;
.section {
margin-bottom: 28px;
>a:last-child {
border-bottom: none;
}
}
.topic {
display: inline-block;
background-color: var(--background-input);
padding: 6px 10px;
border-radius: 12px;
color: var(--text-secondary);
font-size: 12px;
font-weight: 400;
line-height: 12px;
margin-right: 8px;
}
}
.articleSidebar {
.section {
margin-bottom: 28px;
max-height: 526px;
overflow-y: scroll;
>a:last-child {
border-bottom: none;
}
.totalZaps {
display: flex;
align-items: flex-end;
gap: 4px;
.totalZapsIcon {
display: inline-block;
width: 18px;
height: 32px;
background: var(--active-zap);
-webkit-mask: url(../../assets/icons/feed_zap_fill_2.svg) no-repeat 0px 0 / 19px 32px;
mask: url(../../assets/icons/feed_zap_fill_2.svg) no-repeat 0px 0 / 19px 32px;
}
.amount {
color: var(--text-primary);
font-size: 28px;
font-weight: 700;
line-height: 32px;
}
.unit {
color: var(--text-primary);
font-size: 22px;
font-weight: 400;
line-height: 32px;
}
}
}
.topic {
display: inline-block;
background-color: var(--background-input);
padding: 6px 10px;
border-radius: 12px;
color: var(--text-secondary);
font-size: 12px;
font-weight: 400;
line-height: 12px;
margin-right: 8px;
}
}

View File

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

View File

@ -21,6 +21,7 @@ import NoteContextMenu from '../Note/NoteContextMenu';
import LnQrCodeModal from '../LnQrCodeModal/LnQrCodeModal';
import ConfirmModal from '../ConfirmModal/ConfirmModal';
import CashuQrCodeModal from '../CashuQrCodeModal/CashuQrCodeModal';
import SubscribeToAuthorModal from '../SubscribeToAuthorModal/SubscribeToAuthorModal';
export const [isHome, setIsHome] = createSignal(false);
@ -191,6 +192,12 @@ const Layout: Component = () => {
onConfirm={app?.confirmInfo?.onConfirm}
onAbort={app?.confirmInfo?.onAbort}
/>
<SubscribeToAuthorModal
author={app?.subscribeToAuthor}
onClose={app?.actions.closeAuthorSubscribeModal}
onSubscribe={app?.subscribeToTier}
/>
</div>
</Show>
</div>

View File

@ -115,10 +115,11 @@
display: flex;
justify-content: center;
align-items: center;
width: 18px;
min-width: 18px;
height: 18px;
margin-left: 2px;
margin-top: -8px;
font-family: 'Roboto Condensed';
font-weight: 500;

View File

@ -13,7 +13,7 @@ import { getUserProfiles } from "../../../lib/profile";
import { subscribeTo } from "../../../sockets";
import { convertToNotes, referencesToTags } from "../../../stores/note";
import { convertToUser, nip05Verification, truncateNpub, userName } from "../../../stores/profile";
import { EmojiOption, FeedPage, NostrMentionContent, NostrNoteContent, NostrStatsContent, NostrUserContent, PrimalNote, PrimalUser, SendNoteResult } from "../../../types/primal";
import { EmojiOption, FeedPage, NostrMentionContent, NostrNoteContent, NostrStatsContent, NostrUserContent, PrimalArticle, PrimalNote, PrimalUser, SendNoteResult } from "../../../types/primal";
import { debounce, getScreenCordinates, isVisibleInContainer, uuidv4 } from "../../../utils";
import Avatar from "../../Avatar/Avatar";
import EmbeddedNote from "../../EmbeddedNote/EmbeddedNote";
@ -52,7 +52,7 @@ type AutoSizedTextArea = HTMLTextAreaElement & { _baseScrollHeight: number };
const EditBox: Component<{
id?: string,
replyToNote?: PrimalNote,
replyToNote?: PrimalNote | PrimalArticle,
onClose?: () => void,
onSuccess?: (note: SendNoteResult) => void,
open?: boolean,
@ -587,8 +587,8 @@ const EditBox: Component<{
createEffect(() => {
if (props.open) {
const draft = readNoteDraft(account?.publicKey, props.replyToNote?.post.noteId);
const draftUserRefs = readNoteDraftUserRefs(account?.publicKey, props.replyToNote?.post.noteId);
const draft = readNoteDraft(account?.publicKey, props.replyToNote?.noteId);
const draftUserRefs = readNoteDraftUserRefs(account?.publicKey, props.replyToNote?.noteId);
setUserRefs(reconcile(draftUserRefs));
@ -618,8 +618,8 @@ const EditBox: Component<{
if (message().length === 0) return;
// save draft just in case there is an unintended interuption
saveNoteDraft(account?.publicKey, message(), props.replyToNote?.post.noteId);
saveNoteDraftUserRefs(account?.publicKey, userRefs, props.replyToNote?.post.noteId);
saveNoteDraft(account?.publicKey, message(), props.replyToNote?.noteId);
saveNoteDraftUserRefs(account?.publicKey, userRefs, props.replyToNote?.noteId);
});
const onEscape = (e: KeyboardEvent) => {
@ -667,8 +667,8 @@ const EditBox: Component<{
return;
}
saveNoteDraft(account?.publicKey, '', props.replyToNote?.post.noteId);
saveNoteDraftUserRefs(account?.publicKey, {}, props.replyToNote?.post.noteId);
saveNoteDraft(account?.publicKey, '', props.replyToNote?.noteId);
saveNoteDraftUserRefs(account?.publicKey, {}, props.replyToNote?.noteId);
clearEditor();
};
@ -680,8 +680,8 @@ const EditBox: Component<{
};
const persistNote = (note: string) => {
saveNoteDraft(account?.publicKey, note, props.replyToNote?.post.noteId);
saveNoteDraftUserRefs(account?.publicKey, userRefs, props.replyToNote?.post.noteId);
saveNoteDraft(account?.publicKey, note, props.replyToNote?.noteId);
saveNoteDraftUserRefs(account?.publicKey, userRefs, props.replyToNote?.noteId);
clearEditor();
};
@ -721,10 +721,10 @@ const EditBox: Component<{
const rep = props.replyToNote;
if (rep) {
let rootTag = rep.post.tags.find(t => t[0] === 'e' && t[3] === 'root');
let rootTag = rep.msg.tags.find(t => t[0] === 'e' && t[3] === 'root');
const rHints = (rep.post.relayHints && rep.post.relayHints[rep.post.id]) ?
rep.post.relayHints[rep.post.id] :
const rHints = (rep.relayHints && rep.relayHints[rep.id]) ?
rep.relayHints[rep.id] :
'';
// If the note has a root tag, that meens it is not a root note itself
@ -735,26 +735,26 @@ const EditBox: Component<{
v,
);
tags.push([...tagWithHint]);
tags.push(['e', rep.post.id, rHints, 'reply']);
tags.push(['e', rep.id, rHints, 'reply']);
}
// Otherwise, add the note as the root tag for this reply
else {
tags.push([
'e',
rep.post.id,
rep.id,
rHints,
'root',
]);
}
// Copy all `p` tags from the note we are repling to
const repPeople = rep.post.tags.filter(t => t[0] === 'p');
const repPeople = rep.msg.tags.filter(t => t[0] === 'p');
tags = [...tags, ...(unwrap(repPeople))];
// If the author of the note is missing, add them
if (!tags.find(t => t[0] === 'p' && t[1] === rep.post.pubkey)) {
tags.push(['p', rep.post.pubkey]);
if (!tags.find(t => t[0] === 'p' && t[1] === rep.pubkey)) {
tags.push(['p', rep.pubkey]);
}
}
@ -772,7 +772,7 @@ const EditBox: Component<{
toast?.sendSuccess(intl.formatMessage(tToast.publishNoteSuccess));
props.onSuccess && props.onSuccess({ success, reasons, note });
setIsPostingInProgress(false);
saveNoteDraft(account.publicKey, '', rep?.post.noteId)
saveNoteDraft(account.publicKey, '', rep?.noteId)
clearEditor();
}
unsub();
@ -1046,7 +1046,7 @@ const EditBox: Component<{
// // setNoteRefs((refs) => ({
// // ...refs,
// // [newNote.post.noteId]: newNote
// // [newNote.noteId]: newNote
// // }));
subUserRef(hex);
@ -1107,10 +1107,10 @@ const EditBox: Component<{
setNoteRefs((refs) => ({
...refs,
[newNote.post.noteId]: newNote
[newNote.noteId]: newNote
}));
subNoteRef(newNote.post.noteId);
subNoteRef(newNote.noteId);
unsub();
return;

View File

@ -3,7 +3,7 @@
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
padding: 12px 20px;
margin: 0px;
text-decoration: none;
@ -361,12 +361,16 @@
background-color: var(--background-card);
display: flex;
flex-direction: column;
padding-inline: 12px;
padding-inline: 0px;
padding-top: 0;
padding-bottom: 12px;
border-radius: 0;
border: none;
.header {
padding-inline: 12px;
}
.content {
grid-area: content;
display: flex;
@ -386,6 +390,7 @@
line-height: 24px;
width: 100%;
margin-bottom: 12px;
padding-inline: 12px;
a:hover {
text-decoration: underline;
@ -406,6 +411,7 @@
.time {
padding-block: 20px;
padding-inline: 12px;
border-bottom: 1px solid var(--devider);
margin-bottom: 16px;
color: var(--text-tertiary);
@ -445,6 +451,13 @@
}
}
.topZaps {
padding-inline: 12px;
}
.footer {
padding-inline: 12px;
}
}
}

View File

@ -276,7 +276,10 @@ const Note: Component<{
<div class={styles.noteNotifications}>
<div class={styles.content}>
<div class={styles.message}>
<ParsedNote note={props.note} shorten={true} />
<ParsedNote
note={props.note}
shorten={true}
/>
</div>
<div class={styles.footer}>
@ -285,6 +288,7 @@ const Note: Component<{
state={reactionsState}
updateState={updateReactionsState}
customZapInfo={customZapInfo()}
size="short"
/>
</div>
</div>
@ -301,7 +305,9 @@ const Note: Component<{
>
<div class={styles.border}></div>
<NoteHeader note={props.note} primary={true} />
<div class={styles.header}>
<NoteHeader note={props.note} primary={true} />
</div>
<div class={styles.upRightFloater}>
<NoteContextTrigger
@ -313,14 +319,20 @@ const Note: Component<{
<div class={styles.content}>
<div class={styles.message}>
<ParsedNote note={props.note} width={Math.min(640, window.innerWidth)} />
<ParsedNote
note={props.note}
width={Math.min(640, window.innerWidth)}
margins={12}
/>
</div>
<NoteTopZaps
topZaps={reactionsState.topZaps}
zapCount={reactionsState.zapCount}
action={() => openReactionModal('zaps')}
/>
<div class={styles.topZaps}>
<NoteTopZaps
topZaps={reactionsState.topZaps}
zapCount={reactionsState.zapCount}
action={() => openReactionModal('zaps')}
/>
</div>
<div
class={styles.time}
@ -337,15 +349,17 @@ const Note: Component<{
</button>
</div>
<NoteFooter
note={props.note}
state={reactionsState}
updateState={updateReactionsState}
customZapInfo={customZapInfo()}
wide={true}
large={true}
onZapAnim={addTopZap}
/>
<div class={styles.footer}>
<NoteFooter
note={props.note}
state={reactionsState}
updateState={updateReactionsState}
customZapInfo={customZapInfo()}
size="wide"
large={true}
onZapAnim={addTopZap}
/>
</div>
</div>
</div>
</Match>
@ -390,6 +404,7 @@ const Note: Component<{
note={props.note}
shorten={props.shorten}
width={Math.min(640, window.innerWidth - 72)}
margins={20}
/>
</div>
@ -406,7 +421,6 @@ const Note: Component<{
updateState={updateReactionsState}
customZapInfo={customZapInfo()}
onZapAnim={addTopZapFeed}
wide={true}
/>
</A>
</Match>
@ -457,7 +471,8 @@ const Note: Component<{
<ParsedNote
note={props.note}
shorten={props.shorten}
width={Math.min(528, window.innerWidth - 72)}
width={Math.min(566, window.innerWidth - 72)}
margins={1}
/>
</div>
@ -468,13 +483,16 @@ const Note: Component<{
topZapLimit={4}
/>
<NoteFooter
note={props.note}
state={reactionsState}
updateState={updateReactionsState}
customZapInfo={customZapInfo()}
onZapAnim={addTopZapFeed}
/>
<div class={styles.footer}>
<NoteFooter
note={props.note}
state={reactionsState}
updateState={updateReactionsState}
customZapInfo={customZapInfo()}
onZapAnim={addTopZapFeed}
size="short"
/>
</div>
</div>
</div>
</A>
@ -515,6 +533,7 @@ const Note: Component<{
note={props.note}
shorten={props.shorten}
width={Math.min(528, window.innerWidth - 72)}
margins={12}
noLightbox={true}
altEmbeds={true}
/>

View File

@ -60,12 +60,12 @@ const NoteContextMenu: Component<{
const doMuteUser = () => {
account?.actions.addToMuteList(note()?.post.pubkey);
account?.actions.addToMuteList(note()?.pubkey);
props.onClose();
};
const doUnmuteUser = () => {
account?.actions.removeFromMuteList(note()?.post.pubkey);
account?.actions.removeFromMuteList(note()?.pubkey);
props.onClose();
};
@ -77,21 +77,21 @@ const NoteContextMenu: Component<{
const copyNoteLink = () => {
if (!props.data) return;
navigator.clipboard.writeText(`${window.location.origin}/e/${note().post.noteId}`);
navigator.clipboard.writeText(`${window.location.origin}/e/${note().noteId}`);
props.onClose()
toaster?.sendSuccess(intl.formatMessage(tToast.notePrimalLinkCoppied));
};
const copyNoteText = () => {
if (!props.data) return;
navigator.clipboard.writeText(`${note().post.content}`);
navigator.clipboard.writeText(`${note().content}`);
props.onClose()
toaster?.sendSuccess(intl.formatMessage(tToast.notePrimalTextCoppied));
};
const copyNoteId = () => {
if (!props.data) return;
navigator.clipboard.writeText(`${note().post.noteId}`);
navigator.clipboard.writeText(`${note().noteId}`);
props.onClose()
toaster?.sendSuccess(intl.formatMessage(tToast.noteIdCoppied));
};
@ -128,7 +128,7 @@ const NoteContextMenu: Component<{
const onClickOutside = (e: MouseEvent) => {
if (
!props.data ||
!document?.getElementById(`note_context_${note().post.id}`)?.contains(e.target as Node)
!document?.getElementById(`note_context_${note().id}`)?.contains(e.target as Node)
) {
props.onClose()
}
@ -222,7 +222,7 @@ const NoteContextMenu: Component<{
];
};
const noteContext = () => account?.publicKey !== note()?.post.pubkey ?
const noteContext = () => account?.publicKey !== note()?.pubkey ?
[ ...noteContextForEveryone, ...noteContextForOtherPeople()] :
noteContextForEveryone;
@ -251,7 +251,7 @@ const NoteContextMenu: Component<{
/>
<PrimalMenu
id={`note_context_${note()?.post.id}`}
id={`note_context_${note()?.id}`}
items={noteContext()}
hidden={!props.open}
position="note_footer"

View File

@ -28,7 +28,7 @@ export const lottieDuration = () => zapMD.op * 1_000 / zapMD.fr;
const ArticleFooter: Component<{
note: PrimalArticle,
wide?: boolean,
size?: 'wide' | 'normal' | 'short',
id?: string,
state: NoteReactionsState,
updateState: SetStoreFunction<NoteReactionsState>,
@ -49,6 +49,8 @@ const ArticleFooter: Component<{
let footerDiv: HTMLDivElement | undefined;
let repostMenu: HTMLDivElement | undefined;
const size = () => props.size ?? 'normal';
const repostMenuItems: MenuItem[] = [
{
action: () => doRepost(),
@ -181,7 +183,7 @@ const ArticleFooter: Component<{
return;
}
if (!canUserReceiveZaps(props.note.author)) {
if (!canUserReceiveZaps(props.note.user)) {
toast?.sendWarning(
intl.formatMessage(t.zapUnavailable),
);
@ -206,7 +208,7 @@ const ArticleFooter: Component<{
return;
}
if (account.relays.length === 0 || !canUserReceiveZaps(props.note.author)) {
if (account.relays.length === 0 || !canUserReceiveZaps(props.note.user)) {
return;
}
@ -223,12 +225,17 @@ const ArticleFooter: Component<{
return;
}
let newLeft = props.wide ? 15 : 13;
let newTop = props.wide ? -6 : -6;
let newLeft = 33;
let newTop = -6;
if (props.large) {
newLeft = 2;
newTop = -9;
if (size() === 'wide' && props.large) {
newLeft = 14;
newTop = -10;
}
if (size() === 'short') {
newLeft = 14;
newTop = -6;
}
medZapAnimation.style.left = `${newLeft}px`;
@ -319,7 +326,12 @@ const ArticleFooter: Component<{
}
return (
<div id={props.id} class={`${styles.footer} ${props.wide ? styles.wide : ''}`} ref={footerDiv} onClick={(e) => {e.preventDefault();}}>
<div
id={props.id}
class={`${styles.footer} ${styles[size()]}`}
ref={footerDiv}
onClick={(e) => {e.preventDefault();}}
>
<Show when={props.state.showZapAnim}>
<ZapAnimation

View File

@ -55,12 +55,38 @@
.footer {
display: grid;
grid-template-columns: 125px 125px 125px 125px auto;
grid-template-columns: 145px 145px 145px 145px auto;
position: relative;
width: 100%;
.bookmarkFoot {
display: flex;
justify-content: flex-end;
max-width: 20px;
}
&.wide {
grid-template-columns: 137px 137px 137px 135px auto;
grid-template-columns: 148px 148px 148px 148px auto;
.bookmarkFoot {
display: flex;
justify-content: flex-end;
max-width: 100%;
}
}
&.short {
grid-template-columns: 126px 126px 126px 126px auto;
.bookmarkFoot {
display: flex;
justify-content: flex-end;
max-width: 100%;
}
}
&.normal {
width: 100%;
}
.context {
@ -172,10 +198,6 @@
}
}
.bookmarkFoot {
display: flex;
justify-content: flex-end;
}
}
.largeZapLottie {

View File

@ -27,7 +27,7 @@ export const lottieDuration = () => zapMD.op * 1_000 / zapMD.fr;
const NoteFooter: Component<{
note: PrimalNote,
wide?: boolean,
size?: 'wide' | 'normal' | 'short',
id?: string,
state: NoteReactionsState,
updateState: SetStoreFunction<NoteReactionsState>,
@ -48,6 +48,8 @@ const NoteFooter: Component<{
let footerDiv: HTMLDivElement | undefined;
let repostMenu: HTMLDivElement | undefined;
const size = () => props.size ?? 'normal';
const repostMenuItems: MenuItem[] = [
{
action: () => doRepost(),
@ -61,6 +63,7 @@ const NoteFooter: Component<{
},
];
const onClickOutside = (e: MouseEvent) => {
if (
!document?.getElementById(`repost_menu_${props.note.post.id}`)?.contains(e.target as Node)
@ -222,12 +225,17 @@ const NoteFooter: Component<{
return;
}
let newLeft = props.wide ? 15 : 13;
let newTop = props.wide ? -6 : -6;
let newLeft = 33;
let newTop = -6;
if (props.large) {
newLeft = 2;
newTop = -9;
if (size() === 'wide' && props.large) {
newLeft = 14;
newTop = -10;
}
if (size() === 'short') {
newLeft = 14;
newTop = -6;
}
medZapAnimation.style.left = `${newLeft}px`;
@ -318,7 +326,12 @@ const NoteFooter: Component<{
}
return (
<div id={props.id} class={`${styles.footer} ${props.wide ? styles.wide : ''}`} ref={footerDiv} onClick={(e) => {e.preventDefault();}}>
<div
id={props.id}
class={`${styles.footer} ${styles[size()]}`}
ref={footerDiv}
onClick={(e) => e.preventDefault() }
>
<Show when={props.state.showZapAnim}>
<ZapAnimation
@ -395,6 +408,7 @@ const NoteFooter: Component<{
<BookmarkNote
note={props.note}
large={props.large}
right={true}
/>
</div>

View File

@ -4,13 +4,14 @@ import { useThreadContext } from "../../contexts/ThreadContext";
import Avatar from "../Avatar/Avatar";
import { TransitionGroup } from 'solid-transition-group';
import styles from "./Note.module.scss";
import { TopZap } from "../../types/primal";
import { PrimalUser, TopZap } from "../../types/primal";
const NoteTopZaps: Component<{
topZaps: TopZap[],
zapCount: number,
action: () => void,
id?: string,
users?: PrimalUser[]
}> = (props) => {
const threadContext = useThreadContext();
@ -42,7 +43,7 @@ const NoteTopZaps: Component<{
}
const zapSender = (zap: TopZap) => {
return threadContext?.users.find(u => u.pubkey === zap.pubkey);
return (props.users || threadContext?.users || []).find(u => u.pubkey === zap.pubkey);
};
return (

View File

@ -147,6 +147,7 @@ const ParsedNote: Component<{
shorten?: boolean,
isEmbeded?: boolean,
width?: number,
margins?: number,
noLightbox?: boolean,
altEmbeds?: boolean,
}> = (props) => {
@ -619,9 +620,10 @@ const ParsedNote: Component<{
let w: number | undefined = undefined;
if (mVideo) {
const margins = props.margins || 20
const ratio = mVideo.w / mVideo.h;
h = (noteWidth() / ratio);
w = h > 680 ? 680 * ratio : noteWidth();
h = ((noteWidth() - 2*margins) / ratio);
w = h > 680 ? 680 * ratio : noteWidth() - 2*margins;
h = h > 680 ? 680 : h;
}

View File

@ -9,33 +9,229 @@
.editor {
margin-block: 8px;
* {
p {
color: var(--text-primary);
font-size: 16px;
font-weight: 400;
line-height: 24px;
margin-top: 0;
margin-bottom: 20px;
}
p, li {
font-family: Lora;
font-size: 15px;
font-style: normal;
h1 {
color: var(--text-primary);
font-size: 32px;
font-weight: 700;
line-height: 40px;
margin-top: 10px;
margin-bottom: 10px;
}
h2 {
color: var(--text-primary);
font-size: 28px;
font-weight: 700;
line-height: 36px;
margin-top: 10px;
margin-bottom: 10px;
}
h3 {
color: var(--text-primary);
font-size: 24px;
font-weight: 700;
line-height: 32px;
margin-top: 10px;
margin-bottom: 10px;
}
h4 {
color: var(--text-primary);
font-size: 22px;
font-weight: 700;
line-height: 30px;
margin-top: 10px;
margin-bottom: 10px;
}
h5 {
color: var(--text-primary);
font-size: 20px;
font-weight: 700;
line-height: 28px;
margin-top: 10px;
margin-bottom: 10px;
}
h6 {
color: var(--text-primary);
font-size: 18px;
font-weight: 700;
line-height: 26px;
margin-top: 10px;
margin-bottom: 10px;
}
hr {
border-top:1px solid var(--subtile-devider);
margin-top: 10px;
margin-bottom: 20px;
}
ul {
margin-left: 0px;
padding-left: 14px;
margin-bottom: 20px;
li {
&::marker {
content: '';
}
padding-left: 8px;
color: var(--text-primary);
font-size: 16px;
font-weight: 400;
line-height: 24px;
margin-top: 0;
margin-bottom: 12px;
ul {
padding-left: 30px;
}
}
}
ol {
margin-left: 0px;
padding-left: 24px;
margin-bottom: 20px;
li {
padding-left: 8px;
color: var(--text-primary);
font-size: 16px;
font-weight: 400;
line-height: 24px;
margin-top: 0;
margin-bottom: 12px;
ol {
padding-left: 40px;
}
}
}
dl {
margin-left: 0px;
padding-left: 14px;
margin-bottom: 20px;
dt {
color: var(--text-primary);
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
dd {
padding-left: 8px;
color: var(--text-primary);
font-size: 16px;
font-weight: 400;
line-height: 24px;
margin-top: 0;
margin-bottom: 12px;
dl {
padding-left: 30px;
}
}
}
img {
margin-top: 0;
margin-bottom: 20px;
border-radius: 4px;
overflow: hidden;
}
img + sup {
display: block;
margin-top: -6px;
}
blockquote {
color: var(--text-primary);
font-size: 16px;
font-weight: 400;
line-height: 22px;
line-height: 24px;
padding-left: 12px;
padding-bottom: 0;
margin-top: 0;
margin-bottom: 20px;
border-left: 4px solid var(--text-tertiary);
}
a {
color: var(--accent-links);
font-family: Lora;
font-size: 15px;
font-style: normal;
font-size: 16px;
font-weight: 400;
line-height: 22px;
line-height: 24px;
}
pre, code, mark {
background-color: var(--background-input);
}
code {
color: var(--text-primary);
font-family: "Fira Mono", monospace;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 24px;
}
ins {
color: var(--warning-bright);
}
del {
color: inherit;
}
table {
th {
color: var(--text-primary);
font-size: 16px;
font-weight: 700;
line-height: 24px;
border-bottom: 1px solid var(--text-secondary);
padding: 12px 8px;
* {
margin-bottom: 0;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
}
}
td {
color: var(--text-primary);
font-size: 16px;
font-weight: 400;
line-height: 24px;
border-bottom: 1px solid var(--subtile-devider);
padding: 12px 8px;
* {
margin-bottom: 0;
font-size: inherit;
font-weight: inherit;
line-height: inherit;
}
}
}
}
}

View File

@ -27,7 +27,7 @@ import { APP_ID } from '../../App';
import { getUserProfileInfo } from '../../lib/profile';
import { useAccountContext } from '../../contexts/AccountContext';
import { Kind } from '../../constants';
import { PrimalNote, PrimalUser } from '../../types/primal';
import { PrimalArticle, PrimalNote, PrimalUser } from '../../types/primal';
import { convertToUser, userName } from '../../stores/profile';
import { A } from '@solidjs/router';
import { createStore } from 'solid-js/store';
@ -35,11 +35,14 @@ import { nip19 } from 'nostr-tools';
import { fetchNotes } from '../../handleNotes';
import { logError } from '../../lib/logger';
import EmbeddedNote from '../EmbeddedNote/EmbeddedNote';
import NoteImage from '../NoteImage/NoteImage';
import PhotoSwipeLightbox from 'photoswipe/lightbox';
const PrimalMarkdown: Component<{
id?: string,
content?: string,
readonly?: boolean,
noteId: string,
}> = (props) => {
const account = useAccountContext();
@ -49,6 +52,24 @@ const account = useAccountContext();
const [userMentions, setUserMentions] = createStore<Record<string, PrimalUser>>({});
const [noteMentions, setNoteMentions] = createStore<Record<string, PrimalNote>>({});
const id = () => {
return `note_${props.noteId}`;
}
const lightbox = new PhotoSwipeLightbox({
gallery: `#${id()}`,
children: `a.image_${props.noteId}`,
showHideAnimationType: 'zoom',
initialZoomLevel: 'fit',
secondaryZoomLevel: 2,
maxZoomLevel: 3,
pswpModule: () => import('photoswipe')
});
onMount(() => {
lightbox.init();
});
const fetchUserInfo = (npub: string) => {
const pubkey = npubToHex(npub);
@ -97,6 +118,20 @@ const account = useAccountContext();
return regex.test(content)
}
const isImg = (el: Element) => {
// @ts-ignore
return el.firstChild?.tagName === 'IMG';
}
const renderImage = (el: Element) => {
const img = el.firstChild as HTMLImageElement;
return <NoteImage
class={`noteimage image_${props.noteId}`}
src={img.src}
/>
}
const renderMention = (el: Element) => {
const regex = /nostr:([A-z0-9]+)/;
@ -108,7 +143,7 @@ const account = useAccountContext();
const [nostr, id] = match;
if (id.startsWith('npub1')) {
if (id.startsWith('npub')) {
fetchUserInfo(id);
return (
@ -121,7 +156,7 @@ const account = useAccountContext();
);
}
if (id.startsWith('note1')) {
if (id.startsWith('note')) {
fetchNoteInfo(id);
return (
<Show
@ -199,13 +234,16 @@ const account = useAccountContext();
<div ref={ref} class={styles.editor} style="display: none;" />
<div class={styles.editor}>
<div id={id()} class={styles.editor}>
<For each={htmlArray()}>
{el => (
<Switch fallback={<>{el}</>}>
<Match when={isMention(el)}>
{renderMention(el)}
</Match>
<Match when={isImg(el)}>
{renderImage(el)}
</Match>
</Switch>
)}
</For>

View File

@ -13,6 +13,7 @@ import { store } from "../../services/StoreService";
import { userName } from "../../stores/profile";
import { profile as t, actions as tActions } from "../../translations";
import { PrimalUser } from "../../types/primal";
import ArticlePreview from "../ArticlePreview/ArticlePreview";
import Avatar from "../Avatar/Avatar";
import ButtonCopy from "../Buttons/ButtonCopy";
import Loader from "../Loader/Loader";
@ -133,6 +134,9 @@ const ProfileTabs: Component<{
setCurrentTab(() => value);
switch(value) {
case 'articles':
profile.articles.length === 0 && profile.actions.fetchArticles(profile.profileKey);
break;
case 'notes':
profile.notes.length === 0 && profile.actions.fetchNotes(profile.profileKey);
break;
@ -161,6 +165,18 @@ const ProfileTabs: Component<{
>
<Tabs.Root onChange={onChangeValue}>
<Tabs.List class={styles.profileTabs}>
<Show when={(profile?.userStats.long_form_note_count || 0) > 0}>
<Tabs.Trigger class={styles.profileTab} value="articles">
<div class={styles.stat}>
<div class={styles.statNumber}>
{humanizeNumber(profile?.userStats?.long_form_note_count || 0)}
</div>
<div class={styles.statName}>
{intl.formatMessage(t.stats.articles)}
</div>
</div>
</Tabs.Trigger>
</Show>
<Tabs.Trigger class={styles.profileTab} value="notes">
<div class={styles.stat}>
<div class={styles.statNumber}>
@ -231,6 +247,62 @@ const ProfileTabs: Component<{
<Tabs.Indicator class={styles.profileTabIndicator} />
</Tabs.List>
<Tabs.Content class={styles.tabContent} value="articles">
<div class={styles.profileNotes}>
<Switch
fallback={
<div class={styles.loader}>
<Loader />
</div>
}>
<Match when={isMuted(profile?.profileKey)}>
<div class={styles.mutedProfile}>
{intl.formatMessage(
t.isMuted,
{ name: profile?.userProfile ? userName(profile?.userProfile) : profile?.profileKey },
)}
<button
onClick={unMuteProfile}
>
{intl.formatMessage(tActions.unmute)}
</button>
</div>
</Match>
<Match when={isFiltered()}>
<div class={styles.mutedProfile}>
{intl.formatMessage(t.isFiltered)}
<button
onClick={addToAllowlist}
>
{intl.formatMessage(tActions.addToAllowlist)}
</button>
</div>
</Match>
<Match when={profile && profile.articles.length === 0 && !profile.isFetching}>
<div class={styles.mutedProfile}>
{intl.formatMessage(
t.noNotes,
{ name: profile?.userProfile ? userName(profile?.userProfile) : profile?.profileKey },
)}
</div>
</Match>
<Match when={profile && profile.articles.length > 0}>
<For each={profile?.articles}>
{article => (
<ArticlePreview article={article} />
)}
</For>
<Paginator
loadNextPage={() => {
profile?.actions.fetchNextArticlesPage();
}}
isSmall={true}
/>
</Match>
</Switch>
</div>
</Tabs.Content>
<Tabs.Content class={styles.tabContent} value="notes">
<div class={styles.profileNotes}>
<Switch

View File

@ -9,6 +9,32 @@
}
.selectionIcon {
width: 10px;
height: 10px;
display: inline-block;
margin-inline: 4px;
background-color: var(--text-secondary);
-webkit-mask: url(../../assets/icons/caret.svg) no-repeat 0 0/ 10px 10px;
mask: url(../../assets/icons/caret.svg) no-repeat 0 0/ 10px 10px;
}
.selectionBox {
position: relative;
}
.trigger {
background-color: var(--background-site);
margin: 0;
padding: 0;
border: none;
color: var(--text-secondary);
font-size: 18px;
font-weight: 600;
line-height: 20px;
}
.selectionIconBig {
width: 14px;
height: 14px;
display: inline-block;
@ -18,11 +44,7 @@
mask: url(../../assets/icons/caret.svg) no-repeat 0 / 14px;
}
.selectionBox {
position: relative;
}
.trigger {
.triggerBig {
background-color: var(--background-site);
margin: 0;
padding: 0;

View File

@ -18,6 +18,7 @@ const SelectionBox: Component<{
options: SelectionOption[],
onChange: (option: any) => void,
initialValue?: string | SelectionOption,
big?: boolean,
value?: string | SelectionOption,
id?: string,
}> = (props) => {
@ -55,12 +56,12 @@ const SelectionBox: Component<{
onChange={props.onChange}
gutter={8}
>
<Select.Trigger class={styles.trigger}>
<Select.Trigger class={props.big ? styles.triggerBig : styles.trigger}>
<Select.Value<SelectionOption>>
{state => state.selectedOption()?.label || ''}
</Select.Value>
<Select.Icon>
<div class={styles.selectionIcon}></div>
<div class={props.big ? styles.selectionIconBig : styles.selectionIcon}></div>
</Select.Icon>
</Select.Trigger>
<Select.Content>

View File

@ -0,0 +1,290 @@
.subscribeToAuthor {
position: fixed;
min-width: 472px;
min-height: 344px;
color: var(--text-primary);
background-color: var(--background-input);
border-radius: 8px;
display: flex;
flex-direction: column;
padding: 20px 24px 28px 24px;
.header {
display: flex;
flex-direction: row;
justify-content: space-between;
margin-bottom: 24px;
.title {
color: var(--text-primary);
font-size: 20px;
font-weight: 700;
line-height: 20px;
}
.userInfo {
display: flex;
gap: 8px;
.userData {
display: flex;
flex-direction: column;
gap: 4px;
overflow: hidden;
.userName {
display: flex;
color: var(--text-primary);
font-size: 18px;
font-weight: 600;
line-height: 18px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.nip05 {
color: var(--text-tertiary);
font-size: 15px;
font-weight: 400;
line-height: 16px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
}
.close {
border: none;
outline: none;
padding: 0;
margin: 0;
box-shadow: none;
width: 20px;
height: 20px;
display: inline-block;
margin: 0px 0px;
background-color: var(--text-secondary);
-webkit-mask: url(../../assets/icons/close.svg) no-repeat center;
mask: url(../../assets/icons/close.svg) no-repeat center;
&:hover {
background-color: var(--text-primary);
}
}
}
.modalBody {
.tiers {
display: flex;
gap: 12px;
min-height: 220px;
.tier {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
border: 1px solid var(--subtile-devider);
border-radius: 8px;
background: none;
margin: 0;
padding: 16px;
width: 400px;
.tierTitle {
color: var(--text-primary);
font-size: 20px;
font-weight: 600;
line-height: 20px;
}
.content {
display: flex;
border-top: 1px solid var(--subtile-devider);
padding-top: 16px;
color: var(--text-secondary);
font-size: 16px;
font-weight: 400;
line-height: 24px;
width: 100%;
overflow: hidden;
}
.perks {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
width: 100%;
.perk {
display: flex;
align-items: center;
gap: 4px;
// &::before {
// content: '';
// }
.text {
color: var(--text-secondary);
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
.checkIcon {
width: 16px;
height: 16px;
display: inline-block;
background-color: var(--success-color);
-webkit-mask: url(../../assets/icons/check.svg) no-repeat 0 / 80%;
mask: url(../../assets/icons/check.svg) no-repeat 0 / 80%;
&.left {
margin-right: 8px;
}
&.right {
margin-left: 8px;
}
}
}
}
&.selected {
border: 1px solid var(--accent);
}
}
}
}
}
.footer {
height: 36px;
width: 100%;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
margin-top: 20px;
.mint {
color: var(--text-secondary);
font-size: 15px;
font-weight: 400;
line-height: 18px;
letter-spacing: 0.15px;
}
.payAction {
height: 36px;
min-width: 120px;
button {
width: 100%;
height: 100%;
}
}
}
.zapIcon {
width: 22px;
height: 22px;
display: inline-block;
margin-right: 9px;
background: var(--sidebar-section-icon-gradient);
-webkit-mask: url(../../assets/icons/explore/zaps_hollow.svg) no-repeat 2px 0 / 19px 22px;
mask: url(../../assets/icons/explore/zaps_hollow.svg) no-repeat 2px 0 / 19px 22px;
}
.selectCosts {
}
.selectTrigger {
background-color: var(--background-input);
width: 360px;
border: none;
outline: none;
margin: 0;
padding: 0;
.selectValue {
.cost {
.duration {
display: flex;
gap: 6px;
.chevIcon {
width: 6px;
height: 16px;
background-color: var(--text-tertiary);
-webkit-mask: url(../../assets/icons/chevron_right.svg) no-repeat 0 / 100%;
mask: url(../../assets/icons/chevron_right.svg) no-repeat 0 / 100%;
rotate: 90deg;
}
}
}
}
}
.selectContent {
background-color: var(--background-sheet);
z-index: 9999;
width: 390px;
.selectListbox {
border: 1px solid var(--subtile-devider);
border-radius: 8px;
background-color: var(--background-sheet);
padding: 0;
.cost {
border-radius: 8px;
padding-block: 12px;
padding-inline: 12px;
cursor: pointer;
&:hover {
background-color: var(--background-input);
}
}
}
}
.cost {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
.amount {
color: var(--text-primary);
font-size: 20px;
font-weight: 600;
line-height: 20px;
}
.duration {
color: var(--text-secondary);
font-size: 16px;
font-weight: 400;
line-height: 16px;
}
}
.noTiers {
display: flex;
justify-content: center;
width: 100%;
color: var(--text-secondary);
font-size: 16px;
font-weight: 600;
line-height: 16px;
text-transform: uppercase;
}

View File

@ -0,0 +1,383 @@
import { useIntl } from '@cookbook/solid-intl';
import { Component, createEffect, For, Show } from 'solid-js';
import { createStore } from 'solid-js/store';
import { Kind } from '../../constants';
import { hookForDev } from '../../lib/devTools';
import { NostrTier, PrimalUser } from '../../types/primal';
import ButtonPrimary from '../Buttons/ButtonPrimary';
import Modal from '../Modal/Modal';
import styles from './SubscribeToAuthorModal.module.scss';
import { userName } from '../../stores/profile';
import Avatar from '../Avatar/Avatar';
import VerificationCheck from '../VerificationCheck/VerificationCheck';
import { APP_ID } from '../../App';
import { subsTo, subTo } from '../../sockets';
import { getAuthorSubscriptionTiers } from '../../lib/feed';
import ButtonSecondary from '../Buttons/ButtonSecondary';
import { Select } from '@kobalte/core';
import Loader from '../Loader/Loader';
import { logInfo } from '../../lib/logger';
import { getExchangeRate, getMembershipStatus } from '../../lib/membership';
import { useAccountContext } from '../../contexts/AccountContext';
export const satsInBTC = 100_000_000;
export type TierCost = {
amount: string,
unit: string,
cadence: string,
id: string,
}
export type Tier = {
title: string,
content: string,
id: string,
perks: string[],
costs: TierCost[],
activeCost: TierCost | undefined,
client: string,
event: NostrTier,
};
export type TierStore = {
tiers: Tier[],
selectedTier: Tier | undefined,
selectedCost: TierCost | undefined,
isFetchingTiers: boolean,
exchangeRate: Record<string, Record<string, number>>,
}
export const payUnits = ['sats', 'sat', 'msat', 'msats', 'USD', 'usd', ''];
const SubscribeToAuthorModal: Component<{
id?: string,
author: PrimalUser | undefined,
onClose: () => void,
onSubscribe: (tier: Tier, cost: TierCost, exchangeRate?: Record<string, Record<string, number>>) => void,
}> = (props) => {
const account = useAccountContext();
const [store, updateStore] = createStore<TierStore>({
tiers: [],
selectedTier: undefined,
selectedCost: undefined,
isFetchingTiers: false,
exchangeRate: {},
});
let walletSocket: WebSocket | undefined;
createEffect(() => {
const author = props.author;
if (author) {
getTiers(author);
}
});
createEffect(() => {
if (props.author && (!walletSocket || walletSocket.readyState === WebSocket.CLOSED)) {
openWalletSocket(() => {
if (!walletSocket || walletSocket.readyState !== WebSocket.OPEN) return;
const subId = `er_${APP_ID}`;
const unsub = subTo(walletSocket, subId, (type, _, content) => {
if (type === 'EVENT') {
const response: { rate: string } = JSON.parse(content?.content || '{ "rate": 1 }');
const BTCForTarget = parseFloat(response.rate) || 1;
const satsToTarget = BTCForTarget / satsInBTC;
const targetToBTC = 1 / BTCForTarget;
const targetToSats = 1 / satsToTarget;
updateStore('exchangeRate', () => ({
USD: {
sats: targetToSats,
BTC: targetToBTC,
USD: 1,
},
sats: {
sats: 1,
USD: satsToTarget,
BTC: 1 / satsInBTC,
},
BTC: {
sats: satsInBTC,
USD: BTCForTarget,
BTC: 1,
}
}));
}
if (type === 'EOSE') {
unsub();
walletSocket?.close();
}
});
getExchangeRate(account?.publicKey, subId, "USD", walletSocket);
});
} else {
walletSocket?.close();
}
})
const getTiers = (author: PrimalUser) => {
if (!author) return;
const subId = `subscription_tiers_${APP_ID}`;
let tiers: Tier[] = [];
const unsub = subsTo(subId, {
onEvent: (_, content) => {
if (content.kind === Kind.TierList) {
return;
}
if (content.kind === Kind.Tier) {
const t = content as NostrTier;
let costs = t.tags?.filter((t: string[]) => t[0] === 'amount').map((t: string[]) => (
{
amount: t[1],
unit: t[2],
cadence: t[3],
id: `${t[1]}_${t[2]}_${t[3]}`
})) || [];
const tier = {
title: (t.tags?.find((t: string[]) => t[0] === 'title') || [])[1] || t.content || '',
id: t.id || '',
content: t.content || '',
perks: t.tags?.filter((t: string[]) => t[0] === 'perk').map((t: string[]) => t[1]) || [],
costs,
client: (t.tags?.find((t: string[]) => t[0] === 'client') || [])[1] || t.content || '',
event: t,
activeCost: costs[0],
}
tiers.push(tier)
return;
}
},
onEose: () => {
unsub();
updateStore('isFetchingTiers', () => false);
updateStore('tiers', () => [...tiers]);
const tier: Tier | undefined = tiers.length > 0 ? Object.assign(tiers[0]) : undefined;
updateStore('selectedTier', () => tier ? ({ ...tier }) : undefined);
updateStore('selectedCost', () => tier ? ({ ...tier?.costs[0] }) : undefined);
},
})
updateStore('isFetchingTiers', () => true);
getAuthorSubscriptionTiers(author.pubkey, subId)
}
const selectTier = (tier: Tier) => {
if (tier.id !== store.selectedTier?.id) {
updateStore('selectedTier', () => ({ ...tier }));
updateStore('selectedCost', (sc) => ({ ...costOptions(tier)[0] }) );
}
}
const isSelectedTier = (tier: Tier) => tier.id === store.selectedTier?.id;
const costOptions = (tier: Tier) => {
return tier.costs.filter(cost => payUnits.includes(cost.unit));
}
const displayCost = (cost: TierCost | undefined) => {
let text = '';
switch(cost?.unit) {
case 'msat':
case 'msats':
case '':
text = `${Math.ceil(parseInt(cost?.amount || '0') / 1_000)} sats`;
break;
case 'sats':
case 'sat':
text = `${cost.amount} sats`;
break;
case 'USD':
case 'usd':
text = `${cost.amount} USD`;
}
return text;
};
const openWalletSocket = (onOpen: () => void) => {
walletSocket = new WebSocket('wss://wallet.primal.net/v1');
walletSocket.addEventListener('close', () => {
logInfo('WALLET SOCKET CLOSED');
});
walletSocket.addEventListener('open', () => {
logInfo('WALLET SOCKET OPENED');
onOpen();
});
}
return (
<Modal open={props.author !== undefined} onClose={props.onClose}>
<div id={props.id} class={styles.subscribeToAuthor}>
<div class={styles.header}>
<div class={styles.userInfo}>
<Avatar user={props.author} />
<div class={styles.userData}>
<div class={styles.userName}>
{userName(props.author)}
<VerificationCheck user={props.author} />
</div>
<div class={styles.nip05}>
{props.author?.nip05}
</div>
</div>
</div>
<button class={styles.close} onClick={props.onClose}>
</button>
</div>
<div class={styles.modalBody}>
<div class={styles.tiers}>
<Show
when={!store.isFetchingTiers}
fallback={<div><Loader/></div>}
>
<For
each={store.tiers}
fallback={
<div class={styles.noTiers}>
No compatible tiers found
</div>
}
>
{(tier) => (
<button
class={`${styles.tier} ${isSelectedTier(tier) ? styles.selected : ''}`}
onClick={() => selectTier(tier)}
>
<div class={styles.tierTitle}>{tier.title}</div>
<Show
when={costOptions(tier).length > 1 && store.selectedTier?.id === tier.id}
fallback={<div class={styles.cost}>
<div class={styles.amount}>
{displayCost(costOptions(tier)[0])}
</div>
<div class={styles.duration}>
{costOptions(tier)[0].cadence}
</div>
</div>}
>
<Select.Root
class={styles.selectCosts}
options={costOptions(tier)}
optionValue="id"
value={store.selectedCost}
onChange={(cost) => {
// updateStore('tiers', index(), 'activeCost', () => ({ ...cost }));
// updateStore('selectedTier', 'activeCost', () => ({ ...cost }));
updateStore('selectedCost', () => ({ ...cost }));
}}
itemComponent={props => (
<Select.Item item={props.item} class={styles.cost}>
<div class={styles.amount}>
{displayCost(props.item.rawValue)}
</div>
<div class={styles.duration}>
{props.item.rawValue.cadence}
</div>
</Select.Item>
)}
>
<Select.Trigger class={styles.selectTrigger}>
<Select.Value class={styles.selectValue}>
{state => {
const cost = state.selectedOption() as TierCost;
return (
<div class={styles.cost}>
<div class={styles.amount}>
{displayCost(cost)}
</div>
<div class={styles.duration}>
<div>{cost?.cadence}</div>
<div class={styles.chevIcon}></div>
</div>
</div>
)
}}
</Select.Value>
</Select.Trigger>
<Select.Portal>
<Select.Content class={styles.selectContent}>
<Select.Listbox class={styles.selectListbox} />
</Select.Content>
</Select.Portal>
</Select.Root>
</Show>
<div class={styles.content}>
{tier.content}
</div>
<div class={styles.perks}>
<For each={tier.perks}>
{perk => (
<div class={styles.perk}>
<div class={styles.checkIcon}></div>
<div class={styles.text}>{perk}</div>
</div>
)}
</For>
</div>
</button>
)}
</For>
</Show>
</div>
</div>
<div class={styles.footer}>
<div class={styles.payAction}>
<ButtonSecondary
light={true}
onClick={props.onClose}
>
cancel
</ButtonSecondary>
</div>
<Show when={store.selectedTier}>
<div class={styles.payAction}>
<ButtonPrimary
onClick={() => store.selectedTier && store.selectedCost && props.onSubscribe(store.selectedTier, store.selectedCost, store.exchangeRate)}
>
subscribe
</ButtonPrimary>
</div>
</Show>
</div>
</div>
</Modal>
);
}
export default hookForDev(SubscribeToAuthorModal);

View File

@ -102,16 +102,19 @@ export enum Kind {
ChannelHideMessage = 43,
ChannelMuteUser = 44,
LongForm = 30_023,
Subscribe = 7_001,
Unsubscribe = 7_002,
Zap = 9_735,
MuteList = 10_000,
RelayList = 10_002,
Bookmarks = 10_003,
CategorizedPeople = 30_000,
TierList = 17_000,
CategorizedPeople = 30_000,
LongForm = 30_023,
Settings = 30_078,
Tier = 37_001,
ACK = 10_000_098,
NoteStats = 10_000_100,
@ -143,6 +146,7 @@ export enum Kind {
RelayHint=10_000_141,
NoteQuoteStats=10_000_143,
WordCount=10_000_144,
FeaturedAuthors=10_000_148,
WALLET_OPERATION = 10_000_300,
}

View File

@ -9,6 +9,9 @@ import {
} from "solid-js";
import { PrimalArticle, PrimalNote, PrimalUser, ZapOption } from "../types/primal";
import { CashuMint } from "@cashu/cashu-ts";
import { Tier, TierCost } from "../components/SubscribeToAuthorModal/SubscribeToAuthorModal";
import { Kind } from "../constants";
import { sendEvent } from "../lib/notes";
export type ReactionStats = {
@ -29,7 +32,7 @@ export type CustomZapInfo = {
};
export type NoteContextMenuInfo = {
note: PrimalNote,
note: PrimalNote | PrimalArticle,
position: DOMRect | undefined,
openCustomZap?: () => void,
openReactions?: () => void,
@ -66,13 +69,15 @@ export type AppContextStore = {
showConfirmModal: boolean,
confirmInfo: ConfirmInfo | undefined,
cashuMints: Map<string, CashuMint>,
subscribeToAuthor: PrimalUser | undefined,
subscribeToTier: (tier: Tier) => void,
actions: {
openReactionModal: (noteId: string, stats: ReactionStats) => void,
closeReactionModal: () => void,
openCustomZapModal: (custonZapInfo: CustomZapInfo) => void,
closeCustomZapModal: () => void,
resetCustomZap: () => void,
openContextMenu: (note: PrimalNote, position: DOMRect | undefined, openCustomZapModal: () => void, openReactionModal: () => void) => void,
openContextMenu: (note: PrimalNote | PrimalArticle, position: DOMRect | undefined, openCustomZapModal: () => void, openReactionModal: () => void) => void,
closeContextMenu: () => void,
openLnbcModal: (lnbc: string, onPay: () => void) => void,
closeLnbcModal: () => void,
@ -81,6 +86,8 @@ export type AppContextStore = {
openConfirmModal: (confirmInfo: ConfirmInfo) => void,
closeConfirmModal: () => void,
getCashuMint: (url: string) => CashuMint | undefined,
openAuthorSubscribeModal: (author: PrimalUser | undefined, subscribeTo: (tier: Tier, cost: TierCost) => void) => void,
closeAuthorSubscribeModal: () => void,
},
}
@ -106,6 +113,8 @@ const initialData: Omit<AppContextStore, 'actions'> = {
showConfirmModal: false,
confirmInfo: undefined,
cashuMints: new Map(),
subscribeToAuthor: undefined,
subscribeToTier: () => {},
};
export const AppContext = createContext<AppContextStore>();
@ -155,7 +164,7 @@ export const AppProvider = (props: { children: JSXElement }) => {
};
const openContextMenu = (
note: PrimalNote,
note: PrimalNote | PrimalArticle,
position: DOMRect | undefined,
openCustomZap: () => void,
openReactions: () => void,
@ -221,6 +230,17 @@ export const AppProvider = (props: { children: JSXElement }) => {
return store.cashuMints.get(formatted);
};
const openAuthorSubscribeModal = (author: PrimalUser | undefined, subscribeTo: (tier: Tier, cost: TierCost) => void) => {
if (!author) return;
updateStore('subscribeToAuthor', () => ({ ...author }));
updateStore('subscribeToTier', () => subscribeTo);
};
const closeAuthorSubscribeModal = () => {
updateStore('subscribeToAuthor', () => undefined);
};
// EFFECTS --------------------------------------
onMount(() => {
@ -273,6 +293,8 @@ export const AppProvider = (props: { children: JSXElement }) => {
openCashuModal,
closeCashuModal,
getCashuMint,
openAuthorSubscribeModal,
closeAuthorSubscribeModal,
}
});

View File

@ -29,6 +29,7 @@ import {
NostrUserContent,
NostrUserZaps,
NoteActions,
PrimalArticle,
PrimalNote,
PrimalUser,
PrimalZap,
@ -52,6 +53,7 @@ import { setLinkPreviews } from "../lib/notes";
import { subscribeTo } from "../sockets";
import { parseBolt11 } from "../utils";
import { readRecomendedUsers, saveRecomendedUsers } from "../lib/localStore";
import { fetchUserArticles } from "../handleNotes";
export type UserStats = {
pubkey: string,
@ -63,6 +65,7 @@ export type UserStats = {
total_zap_count: number,
total_satszapped: number,
relay_count: number,
long_form_note_count?: number,
};
export type ProfileContextStore = {
@ -71,6 +74,7 @@ export type ProfileContextStore = {
userStats: UserStats,
fetchedUserStats: boolean,
knownProfiles: VanityProfiles,
articles: PrimalArticle[],
notes: PrimalNote[],
replies: PrimalNote[],
zaps: PrimalZap[],
@ -91,6 +95,7 @@ export type ProfileContextStore = {
repliesPage: FeedPage,
reposts: Record<string, string> | undefined,
lastNote: PrimalNote | undefined,
lastArticle: PrimalArticle | undefined,
lastReply: PrimalNote | undefined,
following: string[],
sidebar: FeedPage & { notes: PrimalNote[] },
@ -118,6 +123,9 @@ export type ProfileContextStore = {
fetchNextRepliesPage: () => void,
fetchNotes: (noteId: string | undefined, until?: number) => void,
fetchNextPage: () => void,
fetchArticles: (noteId: string | undefined, until?: number) => void,
fetchNextArticlesPage: () => void,
clearArticles: () => void,
updatePage: (content: NostrEventContent) => void,
savePage: (page: FeedPage) => void,
updateRepliesPage: (content: NostrEventContent) => void,
@ -156,6 +164,7 @@ export const initialData = {
userStats: { ...emptyStats },
fetchedUserStats: false,
knownProfiles: { names: {} },
articles: [],
notes: [],
replies: [],
isFetching: false,
@ -170,6 +179,7 @@ export const initialData = {
zappers: {},
zapListOffset: 0,
lastNote: undefined,
lastArticle: undefined,
lastReply: undefined,
lastZap: undefined,
following: [],
@ -465,6 +475,20 @@ export const ProfileProvider = (props: { children: ContextChildren }) => {
updateStore('isFetchingReplies', () => false);
};
const fetchArticles = async (pubkey: string | undefined, until = 0, limit = 20) => {
if (!pubkey) {
return;
}
updateStore('isFetching', () => true);
let articles = await fetchUserArticles(account?.publicKey, pubkey, 'authored', `profile_articles_${APP_ID}`, until, limit);
articles = articles.filter(a => a.id !== store.lastArticle?.id);
updateStore('articles', (arts) => [ ...arts, ...articles]);
updateStore('isFetching', () => false);
}
const fetchNotes = (pubkey: string | undefined, until = 0, limit = 20) => {
if (!pubkey) {
return;
@ -499,6 +523,11 @@ export const ProfileProvider = (props: { children: ContextChildren }) => {
}));
};
const clearArticles = () => {
updateStore('articles', () => []);
updateStore('lastArticle', () => undefined);
};
const clearReplies = () => {
updateStore('repliesPage', () => ({ messages: [], users: {}, postStats: {}, noteActions: {} }));
updateStore('replies', () => []);
@ -545,6 +574,28 @@ export const ProfileProvider = (props: { children: ContextChildren }) => {
}
};
const fetchNextArticlesPage = () => {
const lastArticle = store.articles[store.articles.length - 1];
if (!lastArticle) {
return;
}
updateStore('lastArticle', () => ({ ...lastArticle }));
const criteria = paginationPlan('latest');
const noteData: Record<string, any> = lastArticle.repost ?
lastArticle.repost.note :
lastArticle.msg;
const until = noteData[criteria];
if (until > 0 && store.profileKey) {
fetchArticles(store.profileKey, until);
}
};
const fetchNextRepliesPage = () => {
const lastReply = store.replies[store.replies.length - 1];
@ -1357,6 +1408,9 @@ export const ProfileProvider = (props: { children: ContextChildren }) => {
clearNotes,
fetchNotes,
fetchNextPage,
fetchArticles,
fetchNextArticlesPage,
clearArticles,
updatePage,
savePage,
saveReplies,

View File

@ -2,11 +2,11 @@ import { nip19 } from "nostr-tools";
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 { Kind, minKnownProfiles } from "../constants";
import { getArticlesFeed, getEvents, getExploreFeed, getFeed, getFutureArticlesFeed, getFutureExploreFeed, getFutureFeed } from "../lib/feed";
import { fetchStoredFeed, saveStoredFeed } from "../lib/localStore";
import { setLinkPreviews } from "../lib/notes";
import { getScoredUsers, searchContent } from "../lib/search";
import { getRecomendedArticleIds, getScoredUsers, searchContent } from "../lib/search";
import { isConnected, refreshSocketListeners, removeSocketListeners, socket } from "../sockets";
import { sortingPlan, convertToNotes, parseEmptyReposts, paginationPlan, isInTags, isRepostInCollection, convertToArticles, isLFRepostInCollection } from "../stores/note";
import {
@ -41,6 +41,7 @@ type ReadsContextStore = {
lastNote: PrimalArticle | undefined,
reposts: Record<string, string> | undefined,
mentionedNotes: Record<string, NostrNoteContent>,
recomendedReads: string[],
future: {
notes: PrimalArticle[],
page: FeedPage,
@ -119,6 +120,7 @@ const initialHomeData = {
isFetching: false,
query: undefined,
},
recomendedReads: [],
};
export const ReadsContext = createContext<ReadsContextStore>();
@ -201,13 +203,13 @@ export const ReadsProvider = (props: { children: ContextChildren }) => {
};
const doSidebarSearch = (query: string) => {
const subid = `reads_sidebar_${APP_ID}`;
const subid = `reads_recomended_${APP_ID}`;
updateStore('sidebar', 'isFetching', () => true);
updateStore('sidebar', 'notes', () => []);
updateStore('sidebar', 'page', { messages: [], users: {}, postStats: {}, mentions: {}, noteActions: {} });
updateStore('sidebar', 'page', { messages: [], users: {}, postStats: {}, mentions: {}, noteActions: {}, topZaps: {} });
getScoredUsers(account?.publicKey, query, 10, subid);
getRecomendedArticleIds(subid);
}
const clearFuture = () => {
@ -307,7 +309,8 @@ export const ReadsProvider = (props: { children: ContextChildren }) => {
};
const fetchNotes = (topic: string, subId: string, until = 0, includeReplies?: boolean) => {
const [scope, timeframe] = topic.split(';');
const t = topic === 'none' ? '' : topic;//account?.publicKey || '532d830dffe09c13e75e8b145c825718fc12b0003f61d61e9077721c7fff93cb';
const [scope, timeframe] = t.split(';');
updateStore('isFetching', true);
updateStore('page', () => ({ messages: [], users: {}, postStats: {} }));
@ -329,7 +332,7 @@ export const ReadsProvider = (props: { children: ContextChildren }) => {
return;
}
getArticlesFeed(account?.publicKey, topic, `reads_feed_${subId}`, until, 20);
getArticlesFeed(account?.publicKey, t, `reads_feed_${subId}`, until, 20);
};
const clearNotes = () => {
@ -391,7 +394,7 @@ export const ReadsProvider = (props: { children: ContextChildren }) => {
const selectFeed = (feed: PrimalFeed | undefined) => {
if (feed?.hex !== undefined && (feed.hex !== currentFeed?.hex || feed.includeReplies !== currentFeed?.includeReplies)) {
currentFeed = { ...feed };
saveStoredFeed(account?.publicKey, currentFeed);
// saveStoredFeed(account?.publicKey, currentFeed);
updateStore('selectedFeed', reconcile({...feed}));
clearNotes();
@ -489,7 +492,6 @@ export const ReadsProvider = (props: { children: ContextChildren }) => {
if (content.kind === Kind.NoteStats) {
const statistic = content as NostrStatsContent;
const stat = JSON.parse(statistic.content);
console.log('READS STATS: ', stat)
if (scope) {
updateStore(scope, 'page', 'postStats',
@ -524,7 +526,6 @@ export const ReadsProvider = (props: { children: ContextChildren }) => {
const noteActionContent = content as NostrNoteActionsContent;
const noteActions = JSON.parse(noteActionContent.content) as NoteActions;
console.log('READS ACTIONS: ', content)
if (scope) {
updateStore(scope, 'page', 'noteActions',
(actions) => ({ ...actions, [noteActions.event_id]: { ...noteActions } })
@ -646,6 +647,27 @@ export const ReadsProvider = (props: { children: ContextChildren }) => {
const [type, subId, content] = message;
if (subId === `reads_recomended_${APP_ID}`) {
if (type === 'EOSE') {
// saveSidebarPage(store.sidebar.page);
return;
}
if (!content) {
return;
}
if (type === 'EVENT') {
const recomended = JSON.parse(content?.content || '{}');
const ids = recomended.reads.reduce((acc: string[], r: string[]) => r[0] ? [ ...acc, r[0] ] : acc, []);
updateStore('recomendedReads', () => [ ...ids ])
return;
}
}
if (subId === `reads_sidebar_${APP_ID}`) {
if (type === 'EOSE') {
saveSidebarPage(store.sidebar.page);
@ -771,9 +793,9 @@ export const ReadsProvider = (props: { children: ContextChildren }) => {
});
createEffect(() => {
if (account?.isKeyLookupDone && settings?.defaultFeed) {
const storedFeed = fetchStoredFeed(account.publicKey);
selectFeed(storedFeed || settings?.defaultFeed);
if (account?.isKeyLookupDone && account.publicKey) {
selectFeed({ hex: account.publicKey, name: 'My Reads'});
}
});

View File

@ -1,20 +1,21 @@
import { nip19 } from "nostr-tools";
import { Kind } from "./constants";
import { getEvents } from "./lib/feed";
import { setLinkPreviews } from "./lib/notes";
import { getEvents, getUserArticleFeed } from "./lib/feed";
import { decodeIdentifier, hexToNpub } from "./lib/keys";
import { getParametrizedEvents, setLinkPreviews } from "./lib/notes";
import { getUserProfileInfo } from "./lib/profile";
import { updateStore, store } from "./services/StoreService";
import { subscribeTo } from "./sockets";
import { convertToNotes } from "./stores/note";
import { convertToArticles, convertToNotes } from "./stores/note";
import { convertToUser } from "./stores/profile";
import { account } from "./translations";
import { FeedPage, NostrEventContent, NostrEventType, NostrMentionContent, NostrNoteActionsContent, NostrNoteContent, NostrStatsContent, NostrUserContent, NoteActions, PrimalNote, TopZap } from "./types/primal";
import { EventCoordinate, FeedPage, NostrEventContent, NostrEventType, NostrMentionContent, NostrNoteActionsContent, NostrNoteContent, NostrStatsContent, NostrUserContent, NoteActions, PrimalArticle, PrimalNote, PrimalUser, TopZap } from "./types/primal";
import { parseBolt11 } from "./utils";
export const fetchNotes = (pubkey: string | undefined, noteIds: string[], subId: string) => {
return new Promise<PrimalNote[]>((resolve, reject) => {
if (!pubkey) reject('Missing pubkey');
let note: PrimalNote;
let page: FeedPage = {
users: {},
messages: [],
@ -182,3 +183,601 @@ export const fetchNotes = (pubkey: string | undefined, noteIds: string[], subId:
};
});
};
export const fetchArticles = (noteIds: string[], subId: string) => {
return new Promise<PrimalArticle[]>((resolve, reject) => {
let page: FeedPage = {
users: {},
messages: [],
postStats: {},
mentions: {},
noteActions: {},
relayHints: {},
topZaps: {},
since: 0,
until: 0,
wordCount: {},
}
const events = noteIds.reduce<EventCoordinate[]>((acc, id) => {
const d = decodeIdentifier(id);
if (!d.data || d.type !== 'naddr') return acc;
const { pubkey, identifier, kind } = d.data;
return [
...acc,
{ identifier, pubkey, kind },
]
}, []);
let lastNote: PrimalArticle | undefined;
const unsub = subscribeTo(subId, (type, _, content) => {
if (type === 'EOSE') {
unsub();
const notes = convertToArticles(page, page.topZaps);
resolve(notes);
return;
}
if (type === 'EVENT') {
if (!content) return;
updatePage(content);
}
});
getParametrizedEvents(events, subId);
const updatePage = (content: NostrEventContent) => {
if (content.kind === Kind.Metadata) {
const user = content as NostrUserContent;
page.users[user.pubkey] = { ...user };
return;
}
if ([Kind.LongForm, Kind.Repost].includes(content.kind)) {
const message = content as NostrNoteContent;
if (lastNote?.noteId !== nip19.noteEncode(message.id)) {
page.messages.push({...message});
}
return;
}
if (content.kind === Kind.NoteStats) {
const statistic = content as NostrStatsContent;
const stat = JSON.parse(statistic.content);
page.postStats[stat.event_id] = { ...stat };
return;
}
if (content.kind === Kind.Mentions) {
const mentionContent = content as NostrMentionContent;
const mention = JSON.parse(mentionContent.content);
if (!page.mentions) {
page.mentions = {};
}
page.mentions[mention.id] = { ...mention };
return;
}
if (content.kind === Kind.NoteActions) {
const noteActionContent = content as NostrNoteActionsContent;
const noteActions = JSON.parse(noteActionContent.content) as NoteActions;
page.noteActions[noteActions.event_id] = { ...noteActions };
return;
}
if (content.kind === Kind.LinkMetadata) {
const metadata = JSON.parse(content.content);
const data = metadata.resources[0];
if (!data) {
return;
}
const preview = {
url: data.url,
title: data.md_title,
description: data.md_description,
mediaType: data.mimetype,
contentType: data.mimetype,
images: [data.md_image],
favicons: [data.icon_url],
};
setLinkPreviews(() => ({ [data.url]: preview }));
return;
}
if (content.kind === Kind.RelayHint) {
const hints = JSON.parse(content.content);
page.relayHints = { ...page.relayHints, ...hints };
return;
}
if (content?.kind === Kind.Zap) {
const zapTag = content.tags.find(t => t[0] === 'description');
if (!zapTag) return;
const zapInfo = JSON.parse(zapTag[1] || '{}');
let amount = '0';
let bolt11Tag = content?.tags?.find(t => t[0] === 'bolt11');
if (bolt11Tag) {
try {
amount = `${parseBolt11(bolt11Tag[1]) || 0}`;
} catch (e) {
const amountTag = zapInfo.tags.find((t: string[]) => t[0] === 'amount');
amount = amountTag ? amountTag[1] : '0';
}
}
const eventId = (zapInfo.tags.find((t: string[]) => t[0] === 'e') || [])[1];
const zap: TopZap = {
id: zapInfo.id,
amount: parseInt(amount || '0'),
pubkey: zapInfo.pubkey,
message: zapInfo.content,
eventId,
};
if (page.topZaps[eventId] === undefined) {
page.topZaps[eventId] = [{ ...zap }];
return;
}
if (page.topZaps[eventId].find(i => i.id === zap.id)) {
return;
}
const newZaps = [ ...page.topZaps[eventId], { ...zap }].sort((a, b) => b.amount - a.amount);
page.topZaps[eventId] = [ ...newZaps ];
return;
}
if (content.kind === Kind.WordCount) {
const count = JSON.parse(content.content) as { event_id: string, words: number };
if (!page.wordCount) {
page.wordCount = {};
}
page.wordCount[count.event_id] = count.words
return;
}
if (content.kind === Kind.NoteQuoteStats) {
const quoteStats = JSON.parse(content.content);
// updateStore('quoteCount', () => quoteStats.count || 0);
return;
}
};
});
};
export const fetchArticleThread = (pubkey: string | undefined, noteIds: string, subId: string) => {
return new Promise<PrimalArticle[]>((resolve, reject) => {
if (!pubkey) reject('Missing pubkey');
let page: FeedPage = {
users: {},
messages: [],
postStats: {},
mentions: {},
noteActions: {},
relayHints: {},
topZaps: {},
since: 0,
until: 0,
wordCount: {},
}
let primaryArticle: PrimalArticle | undefined;
let lastNote: PrimalArticle | undefined;
const unsub = subscribeTo(subId, (type, _, content) => {
if (type === 'EOSE') {
unsub();
const notes = convertToArticles(page, page.topZaps);
resolve(notes);
return;
}
if (type === 'EVENT') {
if (!content) return;
updatePage(content);
}
});
getEvents(pubkey, [...noteIds], subId, true);
const updatePage = (content: NostrEventContent) => {
if (content.kind === Kind.Metadata) {
const user = content as NostrUserContent;
page.users[user.pubkey] = { ...user };
return;
}
if ([Kind.LongForm, Kind.Repost].includes(content.kind)) {
const message = content as NostrNoteContent;
if (lastNote?.noteId !== nip19.noteEncode(message.id)) {
page.messages.push({...message});
}
return;
}
if (content.kind === Kind.NoteStats) {
const statistic = content as NostrStatsContent;
const stat = JSON.parse(statistic.content);
page.postStats[stat.event_id] = { ...stat };
return;
}
if (content.kind === Kind.Mentions) {
const mentionContent = content as NostrMentionContent;
const mention = JSON.parse(mentionContent.content);
if (!page.mentions) {
page.mentions = {};
}
page.mentions[mention.id] = { ...mention };
return;
}
if (content.kind === Kind.NoteActions) {
const noteActionContent = content as NostrNoteActionsContent;
const noteActions = JSON.parse(noteActionContent.content) as NoteActions;
page.noteActions[noteActions.event_id] = { ...noteActions };
return;
}
if (content.kind === Kind.LinkMetadata) {
const metadata = JSON.parse(content.content);
const data = metadata.resources[0];
if (!data) {
return;
}
const preview = {
url: data.url,
title: data.md_title,
description: data.md_description,
mediaType: data.mimetype,
contentType: data.mimetype,
images: [data.md_image],
favicons: [data.icon_url],
};
setLinkPreviews(() => ({ [data.url]: preview }));
return;
}
if (content.kind === Kind.RelayHint) {
const hints = JSON.parse(content.content);
page.relayHints = { ...page.relayHints, ...hints };
return;
}
if (content?.kind === Kind.Zap) {
const zapTag = content.tags.find(t => t[0] === 'description');
if (!zapTag) return;
const zapInfo = JSON.parse(zapTag[1] || '{}');
let amount = '0';
let bolt11Tag = content?.tags?.find(t => t[0] === 'bolt11');
if (bolt11Tag) {
try {
amount = `${parseBolt11(bolt11Tag[1]) || 0}`;
} catch (e) {
const amountTag = zapInfo.tags.find((t: string[]) => t[0] === 'amount');
amount = amountTag ? amountTag[1] : '0';
}
}
const eventId = (zapInfo.tags.find((t: string[]) => t[0] === 'e') || [])[1];
const zap: TopZap = {
id: zapInfo.id,
amount: parseInt(amount || '0'),
pubkey: zapInfo.pubkey,
message: zapInfo.content,
eventId,
};
if (page.topZaps[eventId] === undefined) {
page.topZaps[eventId] = [{ ...zap }];
return;
}
if (page.topZaps[eventId].find(i => i.id === zap.id)) {
return;
}
const newZaps = [ ...page.topZaps[eventId], { ...zap }].sort((a, b) => b.amount - a.amount);
page.topZaps[eventId] = [ ...newZaps ];
return;
}
if (content.kind === Kind.NoteQuoteStats) {
const quoteStats = JSON.parse(content.content);
// updateStore('quoteCount', () => quoteStats.count || 0);
return;
}
};
});
};
export const fetchUserArticles = (userPubkey: string | undefined, pubkey: string | undefined, type: 'authored' | 'replies' | 'bookmarks', subId: string, until = 0, limit = 10) => {
return new Promise<PrimalArticle[]>((resolve, reject) => {
if (!pubkey) reject('Missing pubkey');
let page: FeedPage = {
users: {},
messages: [],
postStats: {},
mentions: {},
noteActions: {},
relayHints: {},
topZaps: {},
since: 0,
until: 0,
wordCount: {},
}
let lastNote: PrimalArticle | undefined;
const unsub = subscribeTo(subId, (type, _, content) => {
if (type === 'EOSE') {
unsub();
const notes = convertToArticles(page, page.topZaps);
resolve(notes);
return;
}
if (type === 'EVENT') {
if (!content) return;
updatePage(content);
}
});
getUserArticleFeed(userPubkey, pubkey, subId, type, until, limit);
const updatePage = (content: NostrEventContent) => {
if (content.kind === Kind.Metadata) {
const user = content as NostrUserContent;
page.users[user.pubkey] = { ...user };
return;
}
if ([Kind.LongForm, Kind.Repost].includes(content.kind)) {
const message = content as NostrNoteContent;
if (lastNote?.noteId !== nip19.noteEncode(message.id)) {
page.messages.push({...message});
}
return;
}
if (content.kind === Kind.NoteStats) {
const statistic = content as NostrStatsContent;
const stat = JSON.parse(statistic.content);
page.postStats[stat.event_id] = { ...stat };
return;
}
if (content.kind === Kind.Mentions) {
const mentionContent = content as NostrMentionContent;
const mention = JSON.parse(mentionContent.content);
if (!page.mentions) {
page.mentions = {};
}
page.mentions[mention.id] = { ...mention };
return;
}
if (content.kind === Kind.NoteActions) {
const noteActionContent = content as NostrNoteActionsContent;
const noteActions = JSON.parse(noteActionContent.content) as NoteActions;
page.noteActions[noteActions.event_id] = { ...noteActions };
return;
}
if (content.kind === Kind.LinkMetadata) {
const metadata = JSON.parse(content.content);
const data = metadata.resources[0];
if (!data) {
return;
}
const preview = {
url: data.url,
title: data.md_title,
description: data.md_description,
mediaType: data.mimetype,
contentType: data.mimetype,
images: [data.md_image],
favicons: [data.icon_url],
};
setLinkPreviews(() => ({ [data.url]: preview }));
return;
}
if (content.kind === Kind.RelayHint) {
const hints = JSON.parse(content.content);
page.relayHints = { ...page.relayHints, ...hints };
return;
}
if (content?.kind === Kind.Zap) {
const zapTag = content.tags.find(t => t[0] === 'description');
if (!zapTag) return;
const zapInfo = JSON.parse(zapTag[1] || '{}');
let amount = '0';
let bolt11Tag = content?.tags?.find(t => t[0] === 'bolt11');
if (bolt11Tag) {
try {
amount = `${parseBolt11(bolt11Tag[1]) || 0}`;
} catch (e) {
const amountTag = zapInfo.tags.find((t: string[]) => t[0] === 'amount');
amount = amountTag ? amountTag[1] : '0';
}
}
const eventId = (zapInfo.tags.find((t: string[]) => t[0] === 'e') || [])[1];
const zap: TopZap = {
id: zapInfo.id,
amount: parseInt(amount || '0'),
pubkey: zapInfo.pubkey,
message: zapInfo.content,
eventId,
};
if (page.topZaps[eventId] === undefined) {
page.topZaps[eventId] = [{ ...zap }];
return;
}
if (page.topZaps[eventId].find(i => i.id === zap.id)) {
return;
}
const newZaps = [ ...page.topZaps[eventId], { ...zap }].sort((a, b) => b.amount - a.amount);
page.topZaps[eventId] = [ ...newZaps ];
return;
}
if (content.kind === Kind.WordCount) {
const count = JSON.parse(content.content) as { event_id: string, words: number };
if (!page.wordCount) {
page.wordCount = {};
}
page.wordCount[count.event_id] = count.words
return;
}
if (content.kind === Kind.NoteQuoteStats) {
const quoteStats = JSON.parse(content.content);
// updateStore('quoteCount', () => quoteStats.count || 0);
return;
}
};
});
};
export const fetchUserProfile = (userPubkey: string | undefined, pubkey: string | undefined, subId: string) => {
return new Promise<PrimalUser>((resolve, reject) => {
if (!pubkey) reject('Missing pubkey');
let user: PrimalUser | undefined;
const unsub = subscribeTo(subId, (type, _, content) => {
if (type === 'EOSE') {
unsub();
user ? resolve(user) : reject('user not found');
return;
}
if (type === 'EVENT') {
if (!content) return;
updatePage(content);
}
});
getUserProfileInfo(pubkey, userPubkey, subId);
const updatePage = (content: NostrEventContent) => {
if (content?.kind === Kind.Metadata) {
let userData = JSON.parse(content.content);
if (!userData.displayName || typeof userData.displayName === 'string' && userData.displayName.trim().length === 0) {
userData.displayName = userData.display_name;
}
userData.pubkey = content.pubkey;
userData.npub = hexToNpub(content.pubkey);
userData.created_at = content.created_at;
user = { ...userData };
return;
}
};
});
}

View File

@ -62,10 +62,10 @@
--warning-color: #FA3C3C;
--success-color: #66E205;
--left-col-w: 188px;
--left-col-w: 182px;
--center-col-w: 640px;
--right-col-w: 348px;
--full-site-w: 1177px;
--full-site-w: 1172px;
--header-height: 84px;
background-color: var(--background-site);
@ -144,7 +144,9 @@ body::after{
url(./assets/icons/nav/settings.svg)
url(./assets/icons/nav/settings_selected.svg)
url(./assets/icons/nav/long.svg)
url(./assets/icons/nav/long_selected.svg);
url(./assets/icons/nav/long_selected.svg)
url(./assets/images/reads_image_dark.png)
url(./assets/images/reads_image_light.png);
}
.reply_icon {

View File

@ -27,11 +27,12 @@ export const getFeed = (user_pubkey: string | undefined, pubkey: string | undef
return;
}
const start = until === 0 ? 'since' : 'until';
const time = until === 0 ? Math.ceil((new Date()).getTime()/1_000 ): until;
let payload = { limit, [start]: until, pubkey };
let payload = { limit, until: time, pubkey };
if (user_pubkey) {
// @ts-ignore dynamic property
payload.user_pubkey = user_pubkey;
}
if (include_replies) {
@ -47,15 +48,21 @@ 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) => {
if (!pubkey) {
return;
}
// if (!pubkey) {
// return;
// }
const start = until === 0 ? 'since' : 'until';
let payload = { limit, [start]: until, pubkey };
let payload = { limit, [start]: until };
if (pubkey && pubkey?.length > 0) {
// @ts-ignore
payload.pubkey = pubkey;
}
if (user_pubkey) {
// @ts-ignore
payload.user_pubkey = user_pubkey;
}
@ -134,6 +141,34 @@ export const getUserFeed = (user_pubkey: string | undefined, pubkey: string | un
{cache: ["feed", payload]},
]));
}
export const getUserArticleFeed = (user_pubkey: string | undefined, pubkey: string | undefined, subid: string, notes: 'authored' | 'replies' | 'bookmarks', until = 0, limit = 20, offset = 0) => {
if (!pubkey) {
return;
}
let payload: {
pubkey: string,
limit: number,
notes: 'authored' | 'replies' | 'bookmarks',
user_pubkey?: string,
until?: number,
offset?: number,
} = { pubkey, limit, notes } ;
if (user_pubkey) {
payload.user_pubkey = user_pubkey;
}
if (until > 0) payload.until = until;
if (offset > 0) payload.offset = offset;
sendMessage(JSON.stringify([
"REQ",
subid,
{cache: ["long_form_content_feed", payload]},
]));
}
export const getFutureUserFeed = (
user_pubkey: string | undefined,
@ -320,3 +355,37 @@ export const getMostZapped4h = (
]},
]));
};
export const getReadsTopics = (
subid: string,
) => {
sendMessage(JSON.stringify([
"REQ",
subid,
{cache: ["get_reads_topics"]},
]));
};
export const getFeaturedAuthors = (
subid: string,
) => {
sendMessage(JSON.stringify([
"REQ",
subid,
{cache: ["get_featured_authors"]},
]));
};
export const getAuthorSubscriptionTiers = (
pubkey: string | undefined,
subid: string,
) => {
if (!pubkey) return;
sendMessage(JSON.stringify([
"REQ",
subid,
{cache: ["creator_paid_tiers", { pubkey }]},
]));
};

View File

@ -36,3 +36,39 @@ export const getMembershipStatus = async (pubkey: string | undefined, subId: str
return false;
}
}
export const getExchangeRate = async (pubkey: string | undefined, subId: string, currency: string, socket: WebSocket) => {
if (!pubkey) {
return;
}
const content = JSON.stringify(
["exchange_rate", { target_currency: currency }],
);
const event = {
content,
kind: Kind.WALLET_OPERATION,
created_at: Math.ceil((new Date()).getTime() / 1000),
tags: [],
};
const signedEvent = await signEvent(event);
const message = JSON.stringify([
"REQ",
subId,
{cache: ["wallet", { operation_event: signedEvent }]},
]);
if (socket) {
const e = new CustomEvent('send', { detail: { message, ws: socket }});
socket.send(message);
socket.dispatchEvent(e);
} else {
throw('no_socket');
}
}

View File

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

View File

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

View File

@ -1,7 +1,9 @@
import { bech32 } from "@scure/base";
// @ts-ignore Bad types in nostr-tools
import { nip57, Relay, utils } from "nostr-tools";
import { PrimalArticle, PrimalNote, PrimalUser } from "../types/primal";
import { Tier } from "../components/SubscribeToAuthorModal/SubscribeToAuthorModal";
import { Kind } from "../constants";
import { NostrRelaySignedEvent, PrimalArticle, PrimalNote, PrimalUser } from "../types/primal";
import { logError } from "./logger";
import { enableWebLn, sendPayment, signEvent } from "./nostrAPI";
@ -19,8 +21,8 @@ export const zapNote = async (note: PrimalNote, sender: string | undefined, amou
const sats = Math.round(amount * 1000);
let payload = {
profile: note.post.pubkey,
event: note.msg.id,
profile: note.pubkey,
event: note.id,
amount: sats,
relays: relays.map(r => r.url)
};
@ -55,7 +57,7 @@ export const zapArticle = async (note: PrimalArticle, sender: string | undefined
return false;
}
const callback = await getZapEndpoint(note.author);
const callback = await getZapEndpoint(note.user);
if (!callback) {
return false;
@ -138,6 +140,67 @@ export const zapProfile = async (profile: PrimalUser, sender: string | undefined
}
}
export const zapSubscription = async (subEvent: NostrRelaySignedEvent, recipient: PrimalUser, sender: string | undefined, relays: Relay[], exchangeRate?: Record<string, Record<string, number>>) => {
if (!sender || !recipient) {
return false;
}
const callback = await getZapEndpoint(recipient);
if (!callback) {
return false;
}
const costTag = subEvent.tags.find(t => t [0] === 'amount');
if (!costTag) return false;
let sats = 0;
if (costTag[2] === 'sats') {
sats = parseInt(costTag[1]) * 1_000;
}
if (costTag[2] === 'msat') {
sats = parseInt(costTag[1]);
}
if (costTag[2] === 'USD' && exchangeRate && exchangeRate['USD']) {
let usd = parseFloat(costTag[1]);
sats = Math.ceil(exchangeRate['USD'].sats * usd * 1_000);
}
let payload = {
profile: recipient.pubkey,
event: subEvent.id,
amount: sats,
relays: relays.map(r => r.url)
};
if (subEvent.content.length > 0) {
// @ts-ignore
payload.comment = comment;
}
const zapReq = nip57.makeZapRequest(payload);
try {
const signedEvent = await signEvent(zapReq);
const event = encodeURIComponent(JSON.stringify(signedEvent));
const r2 = await (await fetch(`${callback}?amount=${sats}&nostr=${event}`)).json();
const pr = r2.pr;
await enableWebLn();
await sendPayment(pr);
return true;
} catch (reason) {
console.error('Failed to zap: ', reason);
return false;
}
}
export const getZapEndpoint = async (user: PrimalUser): Promise<string | null> => {
try {
let lnurl: string = ''

View File

@ -4,6 +4,13 @@
min-height: 100vh;
}
.readsFeed {
position: relative;
border: none;
min-height: 100vh;
border-top: 1px solid var(--devider);
}
.paginate {
color: var(--text-tertiary-2);
position: absolute;

View File

@ -5,23 +5,77 @@
padding: 20px;
border-bottom: 1px solid var(--devider);
a {
text-decoration: none;
}
.author {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 6px;
.userName {
color: var(--text-primary);
.userInfo {
display: flex;
flex-direction: column;
.userName {
display: flex;
color: var(--text-primary);
font-size: 14px;
font-weight: 700;
}
.nip05 {
color: var(--text-tertiary);
font-size: 14px;
font-weight: 400;
line-height: 14px;
}
}
}
}
.topBar {
display: flex;
justify-content: space-between;
align-items: center;
min-height: 18px;
margin-top: 18px;
padding-inline: 20px;
.left {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 4px;
max-width: 80%;
.time {
color: var(--text-tertiary);
font-size: 14px;
font-weight: 700;
}
.client {
&::before {
content: '';
}
color: var(--text-tertiary);
font-size: 14px;
font-weight: 700;
max-width: 80%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.time {
color: var(--text-tertiary);
font-size: 14px;
font-weight: 700;
.right {
display: flex;
justify-content: flex-end;
align-items: center;
}
}
@ -30,14 +84,14 @@
flex-direction: column;
gap: 20px;
position: relative;
margin-bottom: 48px;
margin-bottom: 22px;
margin-inline: 20px;
.title {
color: var(--text-primary);
font-size: 32px;
font-size: 36px;
font-weight: 700;
line-height: 40px;
line-height: 44px;
}
.summary {
@ -53,10 +107,9 @@
.text {
color: var(--text-primary);
font-family: Lora;
font-size: 15px;
font-size: 16px;
font-weight: 400;
line-height: 22px;
line-height: 24px;
}
}
@ -102,7 +155,8 @@
padding: 6px 10px;
border-radius: 12px;
width: fit-content;
margin: 4px;
margin-block: 4px;
margin-right: 6px;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -100,7 +100,7 @@
}
.notificationTabContent {
width: 602px;
width: 100%;
}
.notificationTabIndicator {

View File

@ -83,7 +83,14 @@ const Notifications: Component = () => {
const [relatedNotes, setRelatedNotes] = createStore<NotificationStore>({
notes: [],
users: [],
page: { messages: [], users: {}, postStats: {}, mentions: {}, noteActions: {} },
page: {
messages: [],
users: {},
postStats: {},
mentions: {},
noteActions: {},
topZaps: {},
},
reposts: {},
})
@ -91,7 +98,15 @@ const Notifications: Component = () => {
notes: [],
users: {},
userStats: {},
page: { messages: [], users: {}, postStats: {}, notifications: [], mentions: {}, noteActions: {} },
page: {
messages: [],
users: {},
postStats: {},
notifications: [],
mentions: {},
noteActions: {},
topZaps: {},
},
reposts: {},
notifications: [],
})

View File

@ -19,13 +19,13 @@ import { useProfileContext } from '../contexts/ProfileContext';
import { useAccountContext } from '../contexts/AccountContext';
import Wormhole from '../components/Wormhole/Wormhole';
import { useIntl } from '@cookbook/solid-intl';
import { sanitize } from '../lib/notes';
import { sanitize, sendEvent } from '../lib/notes';
import { shortDate } from '../lib/dates';
import styles from './Profile.module.scss';
import StickySidebar from '../components/StickySidebar/StickySidebar';
import ProfileSidebar from '../components/ProfileSidebar/ProfileSidebar';
import { MenuItem, VanityProfiles, ZapOption } from '../types/primal';
import { MenuItem, PrimalUser, VanityProfiles, ZapOption } from '../types/primal';
import PageTitle from '../components/PageTitle/PageTitle';
import FollowButton from '../components/FollowButton/FollowButton';
import Search from '../components/Search/Search';
@ -44,6 +44,13 @@ import NoteImage from '../components/NoteImage/NoteImage';
import ProfileQrCodeModal from '../components/ProfileQrCodeModal/ProfileQrCodeModal';
import { CustomZapInfo, useAppContext } from '../contexts/AppContext';
import ProfileAbout from '../components/ProfileAbout/ProfileAbout';
import ButtonPrimary from '../components/Buttons/ButtonPrimary';
import { Tier, TierCost } from '../components/SubscribeToAuthorModal/SubscribeToAuthorModal';
import { Kind } from '../constants';
import { getAuthorSubscriptionTiers } from '../lib/feed';
import { zapSubscription } from '../lib/zap';
import { updateStore, store } from '../services/StoreService';
import { subsTo } from '../sockets';
const Profile: Component = () => {
@ -66,6 +73,8 @@ const Profile: Component = () => {
const [confirmMuteUser, setConfirmMuteUser] = createSignal(false);
const [openQr, setOpenQr] = createSignal(false);
const [hasTiers, setHasTiers] = createSignal(false);
const lightbox = new PhotoSwipeLightbox({
gallery: '#central_header',
children: 'a.profile_image',
@ -118,11 +127,13 @@ const Profile: Component = () => {
const setProfile = (hex: string | undefined) => {
profile?.actions.setProfileKey(hex);
profile?.actions.clearArticles();
profile?.actions.clearNotes();
profile?.actions.clearReplies();
profile?.actions.clearContacts();
profile?.actions.clearZaps();
profile?.actions.clearFilterReason();
setHasTiers(() => false);
}
let keyIsDone = false
@ -503,6 +514,94 @@ const Profile: Component = () => {
},
});
createEffect(() => {
if (profile?.userProfile) {
getTiers(profile.userProfile);
}
});
const getTiers = (author: PrimalUser) => {
if (!author) return;
const subId = `article_tiers_${APP_ID}`;
const unsub = subsTo(subId, {
onEvent: (_, content) => {
if (content.kind === Kind.TierList) {
return;
}
if (content.kind === Kind.Tier) {
setHasTiers(() => true);
return;
}
},
onEose: () => {
unsub();
},
})
getAuthorSubscriptionTiers(author.pubkey, subId);
}
const doSubscription = async (tier: Tier, cost: TierCost, exchangeRate?: Record<string, Record<string, number>>) => {
const a = profile?.userProfile;
if (!a || !account || !cost) return;
const subEvent = {
kind: Kind.Subscribe,
content: '',
created_at: Math.floor((new Date()).getTime() / 1_000),
tags: [
['p', a.pubkey],
['e', tier.id],
['amount', cost.amount, cost.unit, cost.cadence],
['event', JSON.stringify(tier.event)],
// Copy any zap splits
...(tier.event.tags?.filter(t => t[0] === 'zap') || []),
],
}
const { success, note } = await sendEvent(subEvent, account.relays, account.relaySettings);
if (success && note) {
const isZapped = await zapSubscription(note, a, account.publicKey, account.relays, exchangeRate);
if (!isZapped) {
unsubscribe(note.id);
}
}
}
const unsubscribe = async (eventId: string) => {
const a = profile?.userProfile;;
if (!a || !account) return;
const unsubEvent = {
kind: Kind.Unsubscribe,
content: '',
created_at: Math.floor((new Date()).getTime() / 1_000),
tags: [
['p', a.pubkey],
['e', eventId],
],
};
await sendEvent(unsubEvent, account.relays, account.relaySettings);
}
const openSubscribe = () => {
app?.actions.openAuthorSubscribeModal(profile?.userProfile, doSubscription);
};
return (
<>
<PageTitle title={
@ -599,6 +698,14 @@ const Profile: Component = () => {
<FollowButton person={profile?.userProfile} large={true} />
</Show>
<Show when={hasTiers()}>
<ButtonPrimary
onClick={openSubscribe}
>
subscribe
</ButtonPrimary>
</Show>
<Show when={isCurrentUser()}>
<div class={styles.editProfileButton}>
<ButtonSecondary

View File

@ -35,6 +35,9 @@ import { useAppContext } from '../contexts/AppContext';
import { useReadsContext } from '../contexts/ReadsContext';
import ArticlePreview from '../components/ArticlePreview/ArticlePreview';
import PageCaption from '../components/PageCaption/PageCaption';
import ReadsSidebar from '../components/HomeSidebar/ReadsSidebar';
import ReedSelect from '../components/FeedSelect/ReedSelect';
import ReadsHeader from '../components/HomeHeader/ReadsHeader';
const Home: Component = () => {
@ -94,7 +97,7 @@ const Home: Component = () => {
}
if (newPostAuthors.length < 3) {
const users = context?.future.notes.map(note => note.author) || [];
const users = context?.future.notes.map(note => note.user) || [];
const uniqueUsers = users.reduce<PrimalUser[]>((acc, user) => {
const isDuplicate = acc.find(u => u && u.pubkey === user.pubkey);
@ -125,6 +128,10 @@ const Home: Component = () => {
setNewPostAuthors(() => []);
}
onMount(() => {
context?.actions.doSidebarSearch('')
})
return (
<div class={styles.homeContent}>
<PageTitle title={intl.formatMessage(branding)} />
@ -134,39 +141,48 @@ const Home: Component = () => {
<Search />
</Wormhole>
<PageCaption title={intl.formatMessage(reads.pageTitle)} />
<PageCaption title={intl.formatMessage(reads.pageTitle)}>
<ReadsHeader
hasNewPosts={() => {}}
loadNewContent={() => {}}
newPostCount={() => {}}
newPostAuthors={[]}
/>
</PageCaption>
<StickySidebar>
<HomeSidebar />
<ReadsSidebar />
</StickySidebar>
<Show
when={context?.notes && context.notes.length > 0}
>
<div class={styles.feed}>
<For each={context?.notes} >
{note => <ArticlePreview article={note} />}
</For>
</div>
</Show>
<div class={styles.readsFeed}>
<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}/>
<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>
</div>
)
}

View File

@ -62,6 +62,7 @@
--icon-network-popular: url('./assets/icons/network_popular.svg');
--icon-network-trending: url('./assets/icons/network_trending.svg');
--reads-placeholder-image: url('./assets/images/reads_image_dark.png');
select {
background-color: var(--background-site);
@ -133,6 +134,7 @@
--icon-network-popular: url('./assets/icons/network_popular.svg');
--icon-network-trending: url('./assets/icons/network_trending.svg');
--reads-placeholder-image: url('./assets/images/reads_image_dark.png');
select {
background-color: var(--background-site);
@ -203,6 +205,7 @@
--icon-network-popular: url('./assets/icons/network_popular.svg');
--icon-network-trending: url('./assets/icons/network_trending.svg');
--reads-placeholder-image: url('./assets/images/reads_image_light.png');
select {
background-color: var(--background-site);
@ -274,6 +277,7 @@
--icon-network-popular: url('./assets/icons/network_popular.svg');
--icon-network-trending: url('./assets/icons/network_trending.svg');
--reads-placeholder-image: url('./assets/images/reads_image_light.png');
select {
background-color: var(--background-site);

View File

@ -366,8 +366,11 @@ export const convertToNotes: ConvertToNotes = (page, topZaps) => {
replyTo: replyTo && replyTo[1],
tags: msg.tags,
id: msg.id,
noteId: nip19.noteEncode(msg.id),
pubkey: msg.pubkey,
topZaps: [ ...tz ],
content: sanitize(msg.content),
relayHints: page.relayHints,
};
});
}
@ -466,10 +469,11 @@ export const convertToArticles: ConvertToArticles = (page, topZaps) => {
image: '',
tags: [],
published: msg.created_at || 0,
content: msg.content,
author: convertToUser(user),
content: sanitize(msg.content),
user: user ? convertToUser(user) : emptyUser(msg.pubkey),
topZaps: [...tz],
naddr: nip19.naddrEncode({ identifier, pubkey, kind }),
noteId: nip19.naddrEncode({ identifier, pubkey, kind }),
msg,
mentionedNotes,
mentionedUsers,
@ -483,6 +487,7 @@ export const convertToArticles: ConvertToArticles = (page, topZaps) => {
score: stat?.score || 0,
score24h: stat?.score24h || 0,
satszapped: stat?.satszapped || 0,
relayHints: page.relayHints,
};
msg.tags.forEach(tag => {
@ -502,6 +507,9 @@ export const convertToArticles: ConvertToArticles = (page, topZaps) => {
case 'published':
article.published = parseInt(tag[1]);
break;
case 'client':
article.client = tag[1];
break;
default:
break;
}

View File

@ -1193,6 +1193,11 @@ export const profile = {
defaultMessage: 'Total',
description: 'Label for total sats profile stat',
},
articles: {
id: 'profile.stats.articles',
defaultMessage: 'Reads',
description: 'Label for reads profile stat',
},
notes: {
id: 'profile.stats.notes',
defaultMessage: 'Notes',

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

@ -242,6 +242,37 @@ export type NostrWordCount = {
tags?: string[][],
};
export type NostrTierList = {
kind: Kind.TierList,
content: string,
created_at?: number,
tags?: string[][],
};
export type NostrTier = {
kind: Kind.Tier,
content: string,
created_at?: number,
id: string,
tags?: string[][],
};
export type NostrSubscribe = {
kind: Kind.Subscribe,
content: string,
created_at?: number,
id: string,
tags?: string[][],
};
export type NostrUnsubscribe = {
kind: Kind.Unsubscribe,
content: string,
created_at?: number,
id: string,
tags?: string[][],
};
export type NostrEventContent =
NostrNoteContent |
NostrUserContent |
@ -274,6 +305,10 @@ export type NostrEventContent =
NostrRelayHint |
NostrZapInfo |
NostrQuoteStatsInfo |
NostrTierList |
NostrTier |
NostrSubscribe |
NostrUnsubscribe |
NostrWordCount;
export type NostrEvent = [
@ -498,8 +533,11 @@ export type PrimalNote = {
replyTo?: string,
id: string,
pubkey: string,
noteId: string,
tags: string[][],
topZaps: TopZap[],
content: string,
relayHints?: Record<string, string>,
};
@ -510,7 +548,7 @@ export type PrimalArticle = {
tags: string[],
published: number,
content: string,
author: PrimalUser,
user: PrimalUser,
topZaps: TopZap[],
repost?: PrimalRepost,
mentionedNotes?: Record<string, PrimalNote>,
@ -519,6 +557,7 @@ export type PrimalArticle = {
id: string,
pubkey: string,
naddr: string,
noteId: string,
msg: NostrNoteContent,
wordCount: number,
noteActions: NoteActions,
@ -530,6 +569,8 @@ export type PrimalArticle = {
score: number,
score24h: number,
satszapped: number,
client?: string,
relayHints?: Record<string, string>,
};
export type PrimalFeed = {
@ -800,3 +841,5 @@ export type PageRange = {
until: number,
order_by: string,
};
export type EventCoordinate = { pubkey: string, identifier: string, kind: number };