Compare commits

...

2 Commits

Author SHA1 Message Date
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
15 changed files with 1030 additions and 17 deletions

View File

@ -0,0 +1,65 @@
.authorSubscribeCard {
display: flex;
flex-direction: column;
gap: 8px;
border-radius: 8px;
background: var(--background-header-input);
width: 300px;
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: flex-end;
align-items: center;
gap: 6px;
>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,132 @@
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 { 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 { Tier } 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) => {
const a = author();
if (!a || !account) return;
const subEvent = {
kind: Kind.Subscribe,
content: '',
created_at: Math.floor((new Date()).getTime() / 1_000),
tags: [
['p', a.pubkey],
['e', tier.id],
['amount', tier.costs[0].amount, tier.costs[0].unit, tier.costs[0].duration],
['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) {
await zapSubscription(note, a, account.publicKey, account.relays);
}
}
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()} />
<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

@ -77,6 +77,8 @@
.readsSidebar {
margin-left: -8px;
.section {
margin-bottom: 28px;

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,41 @@ 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);
// const author = '88cc134b1a65f54ef48acc1df3665063d3ea45f04eab8af4646e561c5ae99079';
// 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 +175,23 @@ 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 />
}
>
<div class={styles.section}>
<AuthorSubscribe pubkey={featuredAuthor()} />
</div>
</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={app?.subscribeToTier}
/>
</div>
</Show>
</div>

View File

@ -0,0 +1,221 @@
.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;
width: 400px;
&.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,200 @@
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: (tier: Tier) => 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;
// const costForTier = (tier: Tier) => {
// const costs = tier.costs.filter(c => payUnits.includes(c.unit));
// costs.reduce((acc, c) => {
// return
// }, [])
// }
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={() => store.selectedTier && props.onSubscribe(store.selectedTier)}
>
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

@ -9,6 +9,9 @@ import {
} from "solid-js";
import { PrimalArticle, PrimalNote, PrimalUser, ZapOption } from "../types/primal";
import { CashuMint } from "@cashu/cashu-ts";
import { Tier } from "../components/SubscribeToAuthorModal/SubscribeToAuthorModal";
import { Kind } from "../constants";
import { sendEvent } from "../lib/notes";
export type ReactionStats = {
@ -66,6 +69,8 @@ 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,
@ -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) => 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>();
@ -221,6 +230,17 @@ export const AppProvider = (props: { children: JSXElement }) => {
return store.cashuMints.get(formatted);
};
const openAuthorSubscribeModal = (author: PrimalUser | undefined, subscribeTo: (tier: Tier) => 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

@ -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 }]},
]));
};

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";
@ -138,6 +140,62 @@ export const zapProfile = async (profile: PrimalUser, sender: string | undefined
}
}
export const zapSubscription = async (subEvent: NostrRelaySignedEvent, recipient: PrimalUser, sender: string | undefined, relays: Relay[]) => {
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]);
}
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

@ -6,13 +6,13 @@ import { APP_ID } from "../App";
import { Kind } from "../constants";
import { useAccountContext } from "../contexts/AccountContext";
import { decodeIdentifier } from "../lib/keys";
import { getParametrizedEvent, setLinkPreviews } from "../lib/notes";
import { subscribeTo } from "../sockets";
import { getParametrizedEvent, sendEvent, setLinkPreviews } from "../lib/notes";
import { subscribeTo, subsTo } from "../sockets";
import { SolidMarkdown } from "solid-markdown";
import styles from './Longform.module.scss';
import Loader from "../components/Loader/Loader";
import { FeedPage, NostrEventContent, NostrMentionContent, NostrNoteActionsContent, NostrNoteContent, NostrStatsContent, NostrUserContent, NoteActions, PrimalArticle, PrimalNote, PrimalUser, SendNoteResult, TopZap, ZapOption } from "../types/primal";
import { FeedPage, NostrEventContent, NostrMentionContent, NostrNoteActionsContent, NostrNoteContent, NostrStatsContent, NostrTier, NostrUserContent, NoteActions, PrimalArticle, PrimalNote, PrimalUser, SendNoteResult, TopZap, ZapOption } from "../types/primal";
import { getUserProfileInfo, getUserProfiles } from "../lib/profile";
import { convertToUser, nip05Verification, userName } from "../stores/profile";
import Avatar from "../components/Avatar/Avatar";
@ -28,7 +28,7 @@ import NoteTopZaps from "../components/Note/NoteTopZaps";
import { parseBolt11, uuidv4 } from "../utils";
import Note, { NoteReactionsState } from "../components/Note/Note";
import NoteFooter from "../components/Note/NoteFooter/NoteFooter";
import { getArticleThread, getThread } from "../lib/feed";
import { getArticleThread, getAuthorSubscriptionTiers, getThread } from "../lib/feed";
import PhotoSwipeLightbox from "photoswipe/lightbox";
import NoteImage from "../components/NoteImage/NoteImage";
import { nip19 } from "nostr-tools";
@ -48,6 +48,9 @@ import ArticleSidebar from "../components/HomeSidebar/ArticleSidebar";
import ReplyToNote from "../components/ReplyToNote/ReplyToNote";
import { sanitize } from "dompurify";
import { fetchNotes } from "../handleNotes";
import { Tier } from "../components/SubscribeToAuthorModal/SubscribeToAuthorModal";
import ButtonPrimary from "../components/Buttons/ButtonPrimary";
import { zapSubscription } from "../lib/zap";
export type LongFormData = {
title: string,
@ -69,6 +72,7 @@ export type LongformThreadStore = {
users: PrimalUser[],
isFetching: boolean,
lastReply: PrimalNote | undefined,
hasTiers: boolean,
}
const emptyArticle = {
@ -99,6 +103,7 @@ const emptyStore: LongformThreadStore = {
users: [],
isFetching: false,
lastReply: undefined,
hasTiers: false,
}
const test = `
@ -354,6 +359,68 @@ const Longform: Component< { naddr: string } > = (props) => {
fetchArticle();
});
createEffect(() => {
if (store.article?.user) {
getTiers(store.article.user);
}
});
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) {
updateStore('hasTiers', () => true);
return;
}
},
onEose: () => {
unsub();
},
})
getAuthorSubscriptionTiers(author.pubkey, subId)
}
const doSubscription = async (tier: Tier) => {
const a = store.article?.user;
if (!a || !account) return;
const subEvent = {
kind: Kind.Subscribe,
content: '',
created_at: Math.floor((new Date()).getTime() / 1_000),
tags: [
['p', a.pubkey],
['e', tier.id],
['amount', tier.costs[0].amount, tier.costs[0].unit, tier.costs[0].duration],
['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) {
await zapSubscription(note, a, account.publicKey, account.relays);
}
}
const openSubscribe = () => {
app?.actions.openAuthorSubscribeModal(store.article?.user, doSubscription);
};
const onConfirmZap = (zapOption: ZapOption) => {
app?.actions.closeCustomZapModal();
batch(() => {
@ -823,6 +890,14 @@ const Longform: Component< { naddr: string } > = (props) => {
</Show>
</div>
</A>
<Show when={store.hasTiers}>
<ButtonPrimary
onClick={openSubscribe}
>
subscribe
</ButtonPrimary>
</Show>
</div>
<div class={styles.topBar}>

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 } 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',
@ -124,6 +133,7 @@ const Profile: Component = () => {
profile?.actions.clearContacts();
profile?.actions.clearZaps();
profile?.actions.clearFilterReason();
setHasTiers(() => false);
}
let keyIsDone = false
@ -504,6 +514,69 @@ 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) => {
const a = profile?.userProfile;
if (!a || !account) return;
const subEvent = {
kind: Kind.Subscribe,
content: '',
created_at: Math.floor((new Date()).getTime() / 1_000),
tags: [
['p', a.pubkey],
['e', tier.id],
['amount', tier.costs[0].amount, tier.costs[0].unit, tier.costs[0].duration],
['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) {
await zapSubscription(note, a, account.publicKey, account.relays);
}
}
const openSubscribe = () => {
app?.actions.openAuthorSubscribeModal(profile?.userProfile, doSubscription);
};
return (
<>
<PageTitle title={
@ -600,6 +673,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

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