Basic subscribe flow

This commit is contained in:
Bojan Mojsilovic 2024-06-06 13:05:49 +02:00
parent 388c2e689d
commit fcb3926e67
11 changed files with 750 additions and 10 deletions

View File

@ -0,0 +1,64 @@
.authorSubscribeCard {
display: flex;
flex-direction: column;
gap: 8px;
border-radius: 8px;
background: var(--background-header-input);
width: 268px;
padding: 16px;
.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;
}
}
}
.userPitch {
color: var(--text-primary);
font-size: 15px;
font-weight: 400;
line-height: 22px;
}
.actions {
display: flex;
justify-content: space-between;
align-items: center;
>button {
color: var(--text-primary);
text-align: center;
font-size: 14px;
font-weight: 600;
line-height: 16px;
padding-inline: 16px;
}
}
}

View File

@ -0,0 +1,99 @@
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 { 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 { 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 { 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 './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 openSubscribe = () => {
app?.actions.openAuthorSubscribeModal(author());
};
return (
<div class={styles.featuredAuthor}>
<Show
when={!isFetching()}
fallback={<Loader />}
>
<div class={styles.authorSubscribeCard}>
<div class={styles.userInfo}>
<Avatar user={author()} />
<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 onClick={() => navigate(`/p/${author()?.npub}`)}>
view profile
</ButtonSecondary>
<ButtonPrimary onClick={openSubscribe}>
subscribe
</ButtonPrimary>
</div>
</div>
</Show>
</div>
);
}
export default hookForDev(AuthoreSubscribe);

View File

@ -18,11 +18,12 @@ import { useReadsContext } from '../../contexts/ReadsContext';
import { createStore } from 'solid-js/store';
import { APP_ID } from '../../App';
import { subsTo } from '../../sockets';
import { getArticleThread, getReadsTopics } from '../../lib/feed';
import { 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 = [
{
@ -73,11 +74,11 @@ const ReadsSidebar: Component< { id?: string } > = (props) => {
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);
@ -99,13 +100,38 @@ const ReadsSidebar: Component< { id?: string } > = (props) => {
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);
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()
getTopics();
getFeaturedAuthor();
}
});
@ -146,7 +172,21 @@ const ReadsSidebar: Component< { id?: string } > = (props) => {
<div id={props.id} class={styles.readsSidebar}>
<Show when={account?.isKeyLookupDone}>
<div class={styles.headingPicks}>
Top Picks
Featured Author
</div>
<Show
when={!isFetchingAuthors()}
fallback={
<Loader />
}
>
<AuthorSubscribe pubkey={featuredAuthor()} />
</Show>
<div class={styles.headingPicks}>
Featured Reads
</div>
<Show

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={() => {}}
/>
</div>
</Show>
</div>

View File

@ -0,0 +1,220 @@
.subscribeToAuthor {
position: fixed;
min-width: 472px;
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);
}
}
}
.body {
.tiers {
display: flex;
gap: 12px;
.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;
&.selected {
border: 1px solid var(--accent);
}
.title: {
color: var(--text-primary);
font-size: 20px;
font-weight: 600;
line-height: 20px;
}
.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;
}
}
.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;
}
}
}
}
}
}
}
}
.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;
}

View File

@ -0,0 +1,189 @@
import { useIntl } from '@cookbook/solid-intl';
// @ts-ignore
import { decode } from 'light-bolt11-decoder';
import { Component, createEffect, For, Show } from 'solid-js';
import { createStore, reconcile } from 'solid-js/store';
import { emptyInvoice, Kind } from '../../constants';
import { date, dateFuture } from '../../lib/dates';
import { hookForDev } from '../../lib/devTools';
import { humanizeNumber } from '../../lib/stats';
import { cashuInvoice } from '../../translations';
import { LnbcInvoice, NostrTier, PrimalUser } from '../../types/primal';
import ButtonPrimary from '../Buttons/ButtonPrimary';
import Modal from '../Modal/Modal';
import QrCode from '../QrCode/QrCode';
import { getDecodedToken, Token } from "@cashu/cashu-ts";
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 } from '../../sockets';
import { getAuthorSubscriptionTiers } from '../../lib/feed';
import ButtonSecondary from '../Buttons/ButtonSecondary';
export type Tier = {
title: string,
content: string,
id: string,
perks: string[],
costs: { amount: string, unit: string, duration: string}[],
client: string,
event: NostrTier,
};
export type TierStore = {
tiers: Tier[],
selectedTier: Tier | undefined,
}
export const payUnits = ['sats', 'msats', ''];
const SubscribeToAuthorModal: Component<{
id?: string,
author: PrimalUser | undefined,
onClose: () => void,
onSubscribe: () => void,
}> = (props) => {
const [store, updateStore] = createStore<TierStore>({
tiers: [],
selectedTier: undefined,
})
createEffect(() => {
const author = props.author;
if (author) {
getTiers(author);
}
});
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;
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: t.tags?.filter((t: string[]) => t[0] === 'amount').map((t: string[]) => ({ amount: t[1], unit: t[2], duration: t[3]})) || [],
client: (t.tags?.find((t: string[]) => t[0] === 'client') || [])[1] || t.content || '',
event: t,
}
tiers.push(tier)
return;
}
},
onEose: () => {
unsub();
updateStore('tiers', () => [...tiers]);
updateStore('selectedTier', () => ( tiers.length > 0 ? { ...tiers[0]} : undefined))
},
})
getAuthorSubscriptionTiers(author.pubkey, subId)
}
const selectTier = (tier: Tier) => {
updateStore('selectedTier', () => ({ ...tier }));
}
const isSelectedTier = (tier: Tier) => tier.id === store.selectedTier?.id;
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.body}>
<div class={styles.tiers}>
<For each={store.tiers}>
{tier => (
<button
class={`${styles.tier} ${isSelectedTier(tier) ? styles.selected : ''}`}
onClick={() => selectTier(tier)}
>
<div class={styles.title}>{tier.title}</div>
<div class={styles.cost}>
<div class={styles.amount}>
{tier.costs[0].amount} {tier.costs[0].unit}
</div>
<div class={styles.duration}>
{tier.costs[0].duration}
</div>
</div>
<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>
</div>
</div>
<div class={styles.footer}>
<div class={styles.payAction}>
<ButtonSecondary
light={true}
onClick={props.onClose}
>
cancel
</ButtonSecondary>
</div>
<div class={styles.payAction}>
<ButtonPrimary onClick={props.onSubscribe}>
subscribe
</ButtonPrimary>
</div>
</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

@ -66,6 +66,7 @@ export type AppContextStore = {
showConfirmModal: boolean,
confirmInfo: ConfirmInfo | undefined,
cashuMints: Map<string, CashuMint>,
subscribeToAuthor: PrimalUser | undefined,
actions: {
openReactionModal: (noteId: string, stats: ReactionStats) => void,
closeReactionModal: () => void,
@ -81,6 +82,8 @@ export type AppContextStore = {
openConfirmModal: (confirmInfo: ConfirmInfo) => void,
closeConfirmModal: () => void,
getCashuMint: (url: string) => CashuMint | undefined,
openAuthorSubscribeModal: (author: PrimalUser | undefined) => void,
closeAuthorSubscribeModal: () => void,
},
}
@ -106,6 +109,7 @@ const initialData: Omit<AppContextStore, 'actions'> = {
showConfirmModal: false,
confirmInfo: undefined,
cashuMints: new Map(),
subscribeToAuthor: undefined,
};
export const AppContext = createContext<AppContextStore>();
@ -221,6 +225,16 @@ export const AppProvider = (props: { children: JSXElement }) => {
return store.cashuMints.get(formatted);
};
const openAuthorSubscribeModal = (author: PrimalUser | undefined) => {
console.log('OPEN: ', author)
author && updateStore('subscribeToAuthor', () => ({ ...author }));
};
const closeAuthorSubscribeModal = () => {
updateStore('subscribeToAuthor', () => undefined);
};
// EFFECTS --------------------------------------
onMount(() => {
@ -273,6 +287,8 @@ export const AppProvider = (props: { children: JSXElement }) => {
openCashuModal,
closeCashuModal,
getCashuMint,
openAuthorSubscribeModal,
closeAuthorSubscribeModal,
}
});

View File

@ -1,13 +1,15 @@
import { nip19 } from "nostr-tools";
import { Kind } from "./constants";
import { getEvents, getUserArticleFeed } from "./lib/feed";
import { decodeIdentifier } from "./lib/keys";
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 { convertToArticles, convertToNotes } from "./stores/note";
import { convertToUser } from "./stores/profile";
import { account } from "./translations";
import { EventCoordinate, FeedPage, NostrEventContent, NostrEventType, NostrMentionContent, NostrNoteActionsContent, NostrNoteContent, NostrStatsContent, NostrUserContent, NoteActions, PrimalArticle, 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) => {
@ -740,3 +742,43 @@ export const fetchUserArticles = (userPubkey: string | undefined, pubkey: string
};
});
};
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

@ -365,3 +365,27 @@ export const getReadsTopics = (
{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 }]},
]));
};

35
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 = [