feat: Added likes and experimental zaps

This commit is contained in:
florian 2023-08-06 10:51:57 +02:00
parent fad5a94597
commit 0a3572afe0
13 changed files with 252 additions and 29 deletions

19
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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)}
<button onClick={onLogin}>Login</button>
{disclaimerAccepted ? (
<SlideShow />
) : (

View File

@ -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%;

View File

@ -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<ZapState>('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 (
<div className="details">
<CloseButton onClick={() => setActiveImageIdx(undefined)}></CloseButton>
@ -35,6 +118,16 @@ const DetailsView = ({ images, activeImageIdx, setActiveImageIdx }: DetailsViewP
></DetailsAuthor>
<div>{currentImage?.content}</div>
{state.userNPub && (
<>
<div className="heart" onClick={() => currentImage && heartClick(currentImage)}>
<IconHeart filled={selfLiked}></IconHeart>
</div>
<div className={`zap ${zapState}`} onClick={() => currentImage && zapClick(currentImage)}>
<IconBolt></IconBolt>
</div>
</>
)}
<div>
{uniq(currentImage?.tags).map(t => (
<>

View File

@ -0,0 +1,7 @@
const IconBolt = () => (
<svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 448 512">
<path d="M349.4 44.6c5.9-13.7 1.5-29.7-10.6-38.5s-28.6-8-39.9 1.8l-256 224c-10 8.8-13.6 22.9-8.9 35.3S50.7 288 64 288H175.5L98.6 467.4c-5.9 13.7-1.5 29.7 10.6 38.5s28.6 8 39.9-1.8l256-224c10-8.8 13.6-22.9 8.9-35.3s-16.6-20.7-30-20.7H272.5L349.4 44.6z" />
</svg>
);
export default IconBolt;

View File

@ -0,0 +1,16 @@
const IconHeart = ({ filled }: { filled: boolean }) => {
if (filled)
return (
<svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512">
<path d="M47.6 300.4L228.3 469.1c7.5 7 17.4 10.9 27.7 10.9s20.2-3.9 27.7-10.9L464.4 300.4c30.4-28.3 47.6-68 47.6-109.5v-5.8c0-69.9-50.5-129.5-119.4-141C347 36.5 300.6 51.4 268 84L256 96 244 84c-32.6-32.6-79-47.5-124.6-39.9C50.5 55.6 0 115.2 0 185.1v5.8c0 41.5 17.2 81.2 47.6 109.5z" />
</svg>
);
else
return (
<svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512">
<path d="M225.8 468.2l-2.5-2.3L48.1 303.2C17.4 274.7 0 234.7 0 192.8v-3.3c0-70.4 50-130.8 119.2-144C158.6 37.9 198.9 47 231 69.6c9 6.4 17.4 13.8 25 22.3c4.2-4.8 8.7-9.2 13.5-13.3c3.7-3.2 7.5-6.2 11.5-9c0 0 0 0 0 0C313.1 47 353.4 37.9 392.8 45.4C462 58.6 512 119.1 512 189.5v3.3c0 41.9-17.4 81.9-48.1 110.4L288.7 465.9l-2.5 2.3c-8.2 7.6-19 11.9-30.2 11.9s-22-4.2-30.2-11.9zM239.1 145c-.4-.3-.7-.7-1-1.1l-17.8-20c0 0-.1-.1-.1-.1c0 0 0 0 0 0c-23.1-25.9-58-37.7-92-31.2C81.6 101.5 48 142.1 48 189.5v3.3c0 28.5 11.9 55.8 32.8 75.2L256 430.7 431.2 268c20.9-19.4 32.8-46.7 32.8-75.2v-3.3c0-47.3-33.6-88-80.1-96.9c-34-6.5-69 5.4-92 31.2c0 0 0 0-.1 .1s0 0-.1 .1l-17.8 20c-.3 .4-.7 .7-1 1.1c-4.5 4.5-10.6 7-16.9 7s-12.4-2.5-16.9-7z" />
</svg>
);
};
export default IconHeart;

View File

@ -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<NostrEvent[]>([]);
const [posts, setPosts] = useState<NDKEvent[]>([]);
const images = useRef<NostrImage[]>([]);
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()) || [],
}));
}),

View File

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

View File

@ -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 = {

View File

@ -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(
<NDKProvider relayUrls={defaultRelays}>
<RouterProvider router={router} />
<GlobalState>
<RouterProvider router={router} />
</GlobalState>
</NDKProvider>
);

29
src/utils/globalState.tsx Normal file
View File

@ -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<Partial<GlobalState>>];
export const GlobalStateContext = createContext<GlobalStateType>([initialState, () => {}]);
const GlobalState = ({ children }: { children: React.ReactElement }) => {
const [state, setState] = useReducer(
(state: GlobalState, newState: Partial<GlobalState>) => ({
...state,
...newState,
}),
initialState
);
return <GlobalStateContext.Provider value={[state, setState]}>{children}</GlobalStateContext.Provider>;
};
export default GlobalState;
export const useGlobalState = () => useContext(GlobalStateContext);

1
src/webln-types.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="@webbtc/webln-types" />