diff --git a/package-lock.json b/package-lock.json index 45001b4..b308b56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "nostr-slideshow", "version": "0.0.0", "dependencies": { - "@nostr-dev-kit/ndk": "^0.8.1", + "@nostr-dev-kit/ndk": "^0.8.3", "@nostr-dev-kit/ndk-react": "^0.1.1", "lodash": "^4.17.21", "nostr-tools": "^1.14.0", @@ -27,6 +27,7 @@ "@typescript-eslint/eslint-plugin": "^6.2.1", "@typescript-eslint/parser": "^6.2.1", "@vitejs/plugin-react": "^4.0.4", + "@webbtc/webln-types": "^1.0.13", "eslint": "^8.46.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.3", @@ -969,9 +970,9 @@ } }, "node_modules/@nostr-dev-kit/ndk": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-0.8.1.tgz", - "integrity": "sha512-uI41sCs+7CxtKGIKXQGZjdwvksfeCwd83bB2yrJCePx4oIkEMMH1gVRYsNfQIMFBPejnU2bfBqcO2zEP9RzIFg==", + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@nostr-dev-kit/ndk/-/ndk-0.8.3.tgz", + "integrity": "sha512-njdcTN0+/TW0xOqd+eQco1HN735f2uxv1wXbFUxKlkxd9ApnAlondqMZB69byHMVJuwA/iNbMZKdiPrAEGGJ3w==", "dependencies": { "@noble/hashes": "^1.3.1", "@noble/secp256k1": "^2.0.0", @@ -1564,6 +1565,16 @@ "vite": "^4.2.0" } }, + "node_modules/@webbtc/webln-types": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@webbtc/webln-types/-/webln-types-1.0.13.tgz", + "integrity": "sha512-SBhqy1scA9xYUBq9GqwFFq0YpTDRUt1AHM0a8f+nJtMLNghouYSJrjj83Ax2l0btGHng8pRt8gytga6k5VaMFw==", + "dev": true, + "funding": { + "type": "lightning", + "url": "hello@getalby.com" + } + }, "node_modules/acorn": { "version": "8.10.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", diff --git a/package.json b/package.json index 29e5543..dc7cac8 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "analyze": "vite-bundle-visualizer" }, "dependencies": { - "@nostr-dev-kit/ndk": "^0.8.1", + "@nostr-dev-kit/ndk": "^0.8.3", "@nostr-dev-kit/ndk-react": "^0.1.1", "lodash": "^4.17.21", "nostr-tools": "^1.14.0", @@ -31,6 +31,7 @@ "@typescript-eslint/eslint-plugin": "^6.2.1", "@typescript-eslint/parser": "^6.2.1", "@vitejs/plugin-react": "^4.0.4", + "@webbtc/webln-types": "^1.0.13", "eslint": "^8.46.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.3", diff --git a/src/App.tsx b/src/App.tsx index ed04b17..87f21c5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,10 +5,15 @@ import useDisclaimerState from './utils/useDisclaimerState'; import useNav from './utils/useNav'; import { useEffect } from 'react'; import { defaultHashTags } from './components/env'; +import { useNDK } from '@nostr-dev-kit/ndk-react'; +import { useGlobalState } from './utils/globalState'; const App = () => { const { disclaimerAccepted, setDisclaimerAccepted } = useDisclaimerState(); const { nav, currentSettings } = useNav(); + const { loginWithNip07, ndk } = useNDK(); + + const [state, setState] = useGlobalState(); useEffect(() => { if (currentSettings.npubs.length == 0 && currentSettings.tags.length == 0) { @@ -16,8 +21,16 @@ const App = () => { } }, []); + const onLogin = async () => { + const result = await loginWithNip07(); + console.log(result); + result && setState({ userNPub: result.npub }); + }; return ( <> + {JSON.stringify(ndk?.signer)} + {JSON.stringify(state)} + {disclaimerAccepted ? ( ) : ( diff --git a/src/components/GridView/DetailsView.css b/src/components/GridView/DetailsView.css index d17d5a0..d302c50 100644 --- a/src/components/GridView/DetailsView.css +++ b/src/components/GridView/DetailsView.css @@ -1,3 +1,30 @@ + +@keyframes bump { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.2); + } + 100% { + transform: scale(1); + } +} + + +@keyframes rotate { + 0% { + transform: scaleX(1); + } + 50% { + transform: scaleX(-1); + } + 100% { + transform: scaleX(1); + } +} + + .details { /* position: fixed; @@ -56,6 +83,34 @@ background-color: #555; } +.details-contents .heart svg { + width: 1.5em; + height: 1.5em; + cursor: pointer; + fill: #ff0000; +} + +.details-contents .zap svg { + width: 1.5em; + height: 1.5em; + cursor: pointer; + fill: rgb(228, 185, 104); +} + +.details-contents .zap.zapped svg { + fill: orange; + animation: bump 1s ease-in-out +} + +.details-contents .zap.error { + fill: red; +} + +.details-contents .zap.zapping svg { + animation: rotate 2s ease-in-out; + animation-iteration-count: infinite; +} + .details-contents .detail-image { object-fit: contain; max-width: 100%; diff --git a/src/components/GridView/DetailsView.tsx b/src/components/GridView/DetailsView.tsx index db098da..545df8e 100644 --- a/src/components/GridView/DetailsView.tsx +++ b/src/components/GridView/DetailsView.tsx @@ -2,10 +2,15 @@ import { NostrImage } from '../nostrImageDownload'; import './DetailsView.css'; import { useNDK } from '@nostr-dev-kit/ndk-react'; import DetailsAuthor from './DetailsAuthor'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import uniq from 'lodash/uniq'; import useNav from '../../utils/useNav'; import CloseButton from '../CloseButton/CloseButton'; +import IconHeart from '../Icons/IconHeart'; +import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk'; +import { Kind, nip19 } from 'nostr-tools'; +import { useGlobalState } from '../../utils/globalState'; +import IconBolt from '../Icons/IconBolt'; type DetailsViewProps = { images: NostrImage[]; @@ -13,8 +18,14 @@ type DetailsViewProps = { setActiveImageIdx: (idx: number | undefined) => void; }; +type ZapState = 'none' | 'zapped' | 'zapping' | 'error'; + const DetailsView = ({ images, activeImageIdx, setActiveImageIdx }: DetailsViewProps) => { - const { getProfile } = useNDK(); + const { getProfile, ndk } = useNDK(); + const [selfLiked, setSelfLiked] = useState(false); + const [zapState, setZapState] = useState('none'); + + const [state, setState] = useGlobalState(); const currentImage = useMemo( () => (activeImageIdx !== undefined ? images[activeImageIdx] : undefined), [images, activeImageIdx] @@ -22,6 +33,78 @@ const DetailsView = ({ images, activeImageIdx, setActiveImageIdx }: DetailsViewP const activeProfile = currentImage?.author !== undefined ? getProfile(currentImage?.author) : undefined; const { nav, currentSettings } = useNav(); + useEffect(() => { + setSelfLiked(false); + setZapState("none"); + + if (!currentImage?.noteId || !state.userNPub) return; + + const filter: NDKFilter = { kinds: [Kind.Reaction], '#e': [currentImage?.noteId] }; + + filter.authors = [nip19.decode(state.userNPub).data as string]; + + currentImage?.noteId && + ndk?.fetchEvents(filter).then(events => { + setSelfLiked(events.size > 0); + }); + }, [currentImage?.event.id]); + + const heartClick = async (currentImage: NostrImage) => { + console.log('heartClick'); + if (!state.userNPub) return; + + const ev = new NDKEvent(ndk, { + kind: Kind.Reaction, + pubkey: nip19.decode(state.userNPub).data as string, + created_at: Math.floor(new Date().getTime() / 1000), + content: '+', + tags: [ + ['e', currentImage.noteId], + ['p', currentImage.authorId], + ], + }); + console.log(ev); + await ev.publish(); + setSelfLiked(true); + }; + + const zapClick = async (currentImage: NostrImage) => { + setZapState('zapping'); + console.log('zapClick'); + if (!state.userNPub) return; + + if (!window.webln) { + console.error('No webln found'); + setZapState('error'); + return; + } + console.log('zapClick2'); + + const ev = await ndk?.fetchEvent(currentImage.noteId); + + if (!ev) { + console.error('No event found for noteId: ' + currentImage.noteId); + setZapState('error'); + return; + } + + console.log(ev); + const invoice = await ev.zap(21000, 'Nice!'); + console.log('zapClick3'); + + console.log(invoice); + if (!invoice) { + console.error('No invoice found'); + setZapState('error'); + return; + } + await window.webln.enable(); + await window.webln.sendPayment(invoice); + + setZapState('zapped'); + + }; + return (
setActiveImageIdx(undefined)}> @@ -35,6 +118,16 @@ const DetailsView = ({ images, activeImageIdx, setActiveImageIdx }: DetailsViewP >
{currentImage?.content}
+ {state.userNPub && ( + <> +
currentImage && heartClick(currentImage)}> + +
+
currentImage && zapClick(currentImage)}> + +
+ + )}
{uniq(currentImage?.tags).map(t => ( <> diff --git a/src/components/Icons/IconBolt.tsx b/src/components/Icons/IconBolt.tsx new file mode 100644 index 0000000..8f9b5cc --- /dev/null +++ b/src/components/Icons/IconBolt.tsx @@ -0,0 +1,7 @@ +const IconBolt = () => ( + + + +); + +export default IconBolt; diff --git a/src/components/Icons/IconHeart.tsx b/src/components/Icons/IconHeart.tsx new file mode 100644 index 0000000..2f504da --- /dev/null +++ b/src/components/Icons/IconHeart.tsx @@ -0,0 +1,16 @@ +const IconHeart = ({ filled }: { filled: boolean }) => { + if (filled) + return ( + + + + ); + else + return ( + + + + ); +}; + +export default IconHeart; \ No newline at end of file diff --git a/src/components/SlideShow.tsx b/src/components/SlideShow.tsx index c9358e2..a28c319 100644 --- a/src/components/SlideShow.tsx +++ b/src/components/SlideShow.tsx @@ -2,7 +2,6 @@ import { useNDK } from '@nostr-dev-kit/ndk-react'; import './SlideShow.css'; import React, { useEffect, useRef, useState } from 'react'; import { - NostrEvent, NostrImage, buildFilter, extractImageUrls, @@ -24,6 +23,7 @@ import IconSettings from './Icons/IconSettings'; import IconPlay from './Icons/IconPlay'; import IconGrid from './Icons/IconGrid'; import useNav from '../utils/useNav'; +import { NDKEvent } from '@nostr-dev-kit/ndk'; /* FEATURES: @@ -53,7 +53,7 @@ FEATURES: const SlideShow = () => { const { ndk } = useNDK(); - const [posts, setPosts] = useState([]); + const [posts, setPosts] = useState([]); const images = useRef([]); const fetchTimeoutHandle = useRef(0); const [showGrid, setShowGrid] = useState(false); @@ -68,9 +68,9 @@ const SlideShow = () => { const postSubscription = ndk.subscribe(buildFilter(settings.tags, settings.npubs, settings.showReposts)); - postSubscription.on('event', (event: NostrEvent) => { + postSubscription.on('event', (event: NDKEvent) => { setPosts(oldPosts => { - event.isReply = isReply(event); + //event.isReply = isReply(event); if (event.kind === 1063) { const urlTag = event?.tags?.find(t => t[0]=='url') @@ -84,7 +84,7 @@ const SlideShow = () => { const repostedEvent = JSON.parse(event.content); if (repostedEvent) { event = repostedEvent; - event.isRepost = true; + //event.isRepost = true; } } catch (e) { // ingore, the content is no valid json @@ -93,7 +93,7 @@ const SlideShow = () => { if ( !blockedPublicKeys.includes(event.pubkey.toLowerCase()) && // remove blocked authors - (settings.showReplies || !event.isReply) && + (settings.showReplies || !isReply(event)) && oldPosts.findIndex(p => p.id === event.id) === -1 && // not duplicate (settings.showAdult || !isAdultRelated(event)) ) { @@ -128,12 +128,14 @@ const SlideShow = () => { return extractImageUrls(p.content) .filter(url => isImage(url) || isVideo(url)) .map(url => ({ + event: p, url, author: nip19.npubEncode(p.pubkey), + authorId: p.pubkey, content: prepareContent(p.content), type: isVideo(url) ? 'video' : 'image', timestamp: p.created_at, - noteId: p.id ? nip19.noteEncode(p.id) : '', + noteId: p.id || '', tags: p.tags?.filter((t: string[]) => t[0] === 't').map((t: string[]) => t[1].toLowerCase()) || [], })); }), diff --git a/src/components/env.ts b/src/components/env.ts index e7d49f4..2f21c26 100644 --- a/src/components/env.ts +++ b/src/components/env.ts @@ -146,6 +146,7 @@ export const adultNPubs = [ 'npub1z0xv9t5w6evrcg860kmgqq5tfj55mz84ta40uszjnfp9uhw2clkq63yrak', // ??? 'npub1hpxzg0p4hrmfvqmrusa4lkyx0ay53k2gwkjr50qe2cedj3vkufhs030ff0', // Spankingbot 'npub1p4j4zfxvdgjrs26wx5dh9uvsvqfv8xa7ew89vv60nxang8cn0sxshyj28r', // Porn search bot + 'npub1tsrs6ptjnq5hluxawfme5sfxalfscapequm3ej0yfw65scwu8lys8q7y7l', // 💜 🔞EUPHORIA 🔞💜 ]; export const adultPublicKeys = adultNPubs.map(npub => (nip19.decode(npub).data as string).toLowerCase()); @@ -171,6 +172,5 @@ export const defaultRelays = [ 'wss://nostr.wine', // "wss://nostr1.current.fyi/", 'wss://purplepag.es/', // needed for user profiles - 'wss://n-word.sharivegas.com/', // needed for mostr.pub profiles //"wss://feeds.nostr.band/pics", ]; diff --git a/src/components/nostrImageDownload.ts b/src/components/nostrImageDownload.ts index 3d47e0f..6fb16c1 100644 --- a/src/components/nostrImageDownload.ts +++ b/src/components/nostrImageDownload.ts @@ -1,28 +1,20 @@ -import { NDKFilter, NDKKind, NDKTag } from '@nostr-dev-kit/ndk'; +import { NDKEvent, NDKFilter, NDKTag } from '@nostr-dev-kit/ndk'; import { Kind, nip19 } from 'nostr-tools'; import { adultContentTags, adultPublicKeys } from './env'; export type NostrImage = { url: string; author: string; + authorId: string; // PubKey tags: string[]; content?: string; timestamp?: number; noteId: string; type: 'image' | 'video'; + event: NDKEvent; }; -export interface NostrEvent { - created_at: number; - content: string; - tags?: NDKTag[]; - kind?: NDKKind | number; - pubkey: string; - id?: string; - sig?: string; - isRepost: boolean; - isReply: boolean; -} + export const buildFilter = (tags: string[], npubs: string[], withReposts = false) => { const filter: NDKFilter = { diff --git a/src/main.tsx b/src/main.tsx index 3180adc..101dfea 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,8 +3,9 @@ import ReactDOM from 'react-dom/client'; import { NDKProvider } from '@nostr-dev-kit/ndk-react'; import App from './App'; import './index.css'; -import {defaultRelays} from './components/env' +import { defaultRelays } from './components/env'; import { createBrowserRouter, RouterProvider } from 'react-router-dom'; +import GlobalState from './utils/globalState'; const router = createBrowserRouter([ { @@ -35,6 +36,8 @@ const router = createBrowserRouter([ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - + + + ); diff --git a/src/utils/globalState.tsx b/src/utils/globalState.tsx new file mode 100644 index 0000000..aa4e9db --- /dev/null +++ b/src/utils/globalState.tsx @@ -0,0 +1,29 @@ +import React, { createContext, useContext, useReducer } from 'react'; + +// Interface for our state +interface GlobalState { + userNPub?: string; +} +const initialState: GlobalState = { + userNPub: undefined, +}; + +type GlobalStateType = [GlobalState, React.Dispatch>]; + +export const GlobalStateContext = createContext([initialState, () => {}]); + +const GlobalState = ({ children }: { children: React.ReactElement }) => { + const [state, setState] = useReducer( + (state: GlobalState, newState: Partial) => ({ + ...state, + ...newState, + }), + initialState + ); + + return {children}; +}; + +export default GlobalState; + +export const useGlobalState = () => useContext(GlobalStateContext); diff --git a/src/webln-types.d.ts b/src/webln-types.d.ts new file mode 100644 index 0000000..6c1c34b --- /dev/null +++ b/src/webln-types.d.ts @@ -0,0 +1 @@ +///