feat: Experimental version based on ngine (WIP)

This commit is contained in:
florian 2024-02-22 23:10:22 +01:00
parent bc8e118429
commit 3dd31135e1
40 changed files with 1705 additions and 4682 deletions

14
TODO.md
View File

@ -1,5 +1,19 @@
# TODO
# delete the events when the filter changes
- Improve Login dialog
- Fix key listeners when text input is opened
- Fix/Test zaps
- Reimplement Likes/Zaps based in ngine code
- Investigate profile caching
- fix build errors
- build masonary view for desktop
- move settings dialog to main start screen
- replace search icon with nav back button
- record demo explaination video
- hashtag view (single hasttag), header
- masonry, mit subtitles (user displayname, tags (most imporant), desc?, date) (ggf. nur desktop)
- NIP 46

4211
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -14,25 +14,26 @@
"dependencies": {
"@nostr-dev-kit/ndk": "^2.4.2",
"@nostr-dev-kit/ndk-cache-dexie": "^2.2.6",
"@nostr-dev-kit/ndk-react": "^0.1.1",
"@tanstack/react-query": "^5.22.2",
"jotai": "^2.6.5",
"light-bolt11-decoder": "^3.0.0",
"lodash": "^4.17.21",
"nostr-login": "^1.0.12",
"nostr-tools": "^2.2.0",
"nostr-tools": "^2.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-helmet": "^6.1.0",
"react-lazy-load": "^4.0.1",
"react-router-dom": "^6.22.0",
"react-router-dom": "^6.22.1",
"react-swipeable": "^7.0.1"
},
"devDependencies": {
"@types/lodash": "^4.14.202",
"@types/react": "^18.2.55",
"@types/react": "^18.2.57",
"@types/react-dom": "^18.2.19",
"@types/react-helmet": "^6.1.11",
"@types/react-swipeable": "^5.2.0",
"@typescript-eslint/eslint-plugin": "^7.0.1",
"@typescript-eslint/parser": "^7.0.1",
"@typescript-eslint/eslint-plugin": "^7.0.2",
"@typescript-eslint/parser": "^7.0.2",
"@vitejs/plugin-react": "^4.2.1",
"@webbtc/webln-types": "^3.0.0",
"eslint": "^8.56.0",
@ -40,7 +41,7 @@
"eslint-plugin-react-refresh": "^0.4.5",
"prettier": "^3.2.5",
"typescript": "^5.3.3",
"vite": "^5.1.3",
"vite": "^5.1.4",
"vite-bundle-visualizer": "^1.0.1"
}
}

View File

@ -1,35 +0,0 @@
import useNav from '../../utils/useNav';
import { createImgProxyUrl } from '../nostrImageDownload';
import './DetailsView.css';
import { NDKUserProfile } from '@nostr-dev-kit/ndk';
type DetailsAuthorProps = {
profile?: NDKUserProfile;
npub?: string;
setActiveImageIdx: (idx: number | undefined) => void;
};
const DetailsAuthor = ({ profile, npub, setActiveImageIdx }: DetailsAuthorProps) => {
const { nav, currentSettings } = useNav();
return (
<div
className="details-author"
onClick={() => {
setActiveImageIdx(undefined);
npub && nav({ ...currentSettings, tags: [], npubs: [npub] });
}}
>
<div
className="author-image"
style={{
backgroundImage: profile?.image ? `url(${createImgProxyUrl(profile?.image, 80, 80)})` : 'none',
}}
></div>
<div className="author-name">{profile?.displayName || profile?.name}</div>
</div>
);
};
export default DetailsAuthor;

View File

@ -1,202 +0,0 @@
.details {
position: absolute;
z-index: 500;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(10px);
padding: 2em;
box-sizing: border-box;
display: flex;
align-items: center;
width: 100vw;
height: 100dvh;
justify-content: center;
}
.details-contents {
display: grid;
grid-template-columns: max(60vw, 40vw) auto;
gap: 24px;
justify-items: center;
}
.details-contents .tag {
display: inline;
padding: 0.2em 0.6em;
margin-right: 0.2em;
border-radius: 24px;
background-color: #444;
color: white;
line-height: 2.2em;
font-size: 1rem;
cursor: pointer;
overflow: visible;
}
.details-contents .tag:hover {
background-color: #555;
}
.details-contents .heart svg {
padding: 0.5em;
width: 1.5em;
height: 1.5em;
cursor: pointer;
}
.details-actions > div {
display: inline;
}
.details-contents .detail-image {
object-fit: contain;
max-width: 100%;
max-height: 90vh;
border-radius: 12px;
background-color: #444;
}
.detail-description {
position: relative;
display: flex;
box-sizing: border-box;
flex-direction: column;
gap: 12px;
max-width: 30em;
width: 25em;
overflow-y: auto;
overflow-x: hidden;
max-height: 90vh;
padding-bottom: 1em;
}
.details-author {
display: flex;
flex-direction: row;
gap: 12px;
align-items: flex-start;
cursor: pointer;
}
.details-actions .more {
position: relative;
}
.details-actions .more svg {
padding: 0.5em;
width: 1.5em;
height: 1.5em;
cursor: pointer;
color: white;
}
.details-actions .more .more-menu {
display: none;
flex-direction: column;
position: absolute;
top: 20px;
left: 0px;
background-color: #111;
padding: 1em;
border-radius: 12px;
box-shadow: 5px 5px 5px rgba(0, 0, 0, 0.5);
gap: 6px;
}
.details-actions .more .more-menu.show {
display: flex;
}
.details-actions .more .more-action {
cursor: pointer;
display: flex;
align-items: center;
color: white;
line-height: 2em;
border-radius: 6px;
padding-right: 0.5em;
text-wrap: nowrap;
}
.details-actions .more .more-action:hover {
background-color: #333;
}
.details-actions .more .more-action:hover {
background-color: #333;
}
@media screen and (min-width: 769px) {
.details-contents {
background-image: none !important;
}
}
@media screen and (max-width: 768px) {
.details {
overflow-y: scroll;
align-items: normal;
overflow-x: hidden;
padding: 0;
overscroll-behavior: none;
}
.details-author {
position: absolute;
top: -104px;
align-items: center;
}
.details-author .author-image {
margin: 0px;
}
.details-author .author-name {
display: none;
}
.details-actions {
position: fixed;
right: 2em;
bottom: 2em;
display: flex;
flex-direction: column-reverse;
gap: 0.5em;
}
.details-contents {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
background-size: cover;
background-repeat: no-repeat;
background-position: center;
}
.details-contents .detail-image {
border-radius: 0px;
height: 100dvh;
width: 100vw;
backdrop-filter: blur(20px) brightness(0.5);
-webkit-backdrop-filter: blur(20px) brightness(0.5);
background-color: transparent;
max-height: none;
}
.detail-description {
max-width: 100%;
width: 100%;
overflow: visible;
padding-left: 2em;
padding-right: 2em;
padding-bottom: 3em;
}
.detail-description .details-text {
max-width: 90vw;
overflow: hidden;
text-overflow: ellipsis;
}
.details-actions .more .more-menu {
bottom: 50px;
right: 0px;
top: auto;
left: auto;
text-wrap: nowrap;
display: none;
}
.details-actions .more .more-menu.show {
display: block;
}
}

View File

@ -1,169 +0,0 @@
import { NostrImage, createImgProxyUrl, isVideo } from '../nostrImageDownload';
import './DetailsView.css';
import { useNDK } from '@nostr-dev-kit/ndk-react';
import DetailsAuthor from './DetailsAuthor';
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 { nip19 } from 'nostr-tools';
import { useGlobalState } from '../../utils/globalState';
import IconBolt from '../Icons/IconBolt';
import useWindowSize from '../../utils/useWindowSize';
import IconLink from '../Icons/IconLink';
import IconDots from '../Icons/IconDots';
import useZapsAndReations from '@/utils/useZapAndReaction';
type DetailsViewProps = {
images: NostrImage[];
currentImage: number | undefined;
setCurrentImage: React.Dispatch<React.SetStateAction<number | undefined>>;
};
const DetailsView = ({ images, currentImage, setCurrentImage }: DetailsViewProps) => {
const { getProfile } = useNDK();
const [state, setState] = useGlobalState();
const [showMoreMenu, setShowMoreMenu] = useState(false);
const size = useWindowSize();
const currentImageData = useMemo(
() => (currentImage !== undefined ? images[currentImage] : undefined),
[images, currentImage]
);
const nextImageData = useMemo(
() => (currentImage !== undefined ? images[currentImage + 1] : undefined),
[images, currentImage]
);
const { zapClick, heartClick, zapState, heartState } = useZapsAndReations(currentImageData, state.userNPub);
useEffect(() => {
setState({ ...state, showNavButtons: false });
return () => setState({ ...state, showNavButtons: true });
}, []);
const activeProfile = currentImageData?.author !== undefined ? getProfile(currentImageData?.author) : undefined;
const { nav, currentSettings } = useNav();
const imageWidth = useMemo(() => (size.width && size.width > 1600 ? 1600 : 800), [size.width]);
const nextImageProxyUrl = nextImageData?.url && createImgProxyUrl(nextImageData?.url, imageWidth, -1);
const currentImageProxyUrl = currentImageData?.url && createImgProxyUrl(currentImageData?.url, imageWidth, -1);
if (!currentImageData) return null;
// TODO unmute video through icon
return (
<>
<CloseButton onClick={() => setCurrentImage(undefined)}></CloseButton>
<div className="details" onClick={() => setShowMoreMenu(false)}>
{nextImageData && !isVideo(nextImageData.url) && (
<img src={nextImageProxyUrl} loading="eager" style={{ display: 'none' }} />
)}
{nextImageData && isVideo(nextImageData.url) && (
<video src={nextImageData?.url} preload="true" style={{ display: 'none' }} />
)}
<div
className="details-contents"
style={{ backgroundImage: `url(${!isVideo(currentImageData.url) ? currentImageProxyUrl : ''})` }}
>
{isVideo(currentImageData.url) ? (
<video className="detail-image" src={currentImageData.url} autoPlay loop muted playsInline></video>
) : (
<img className="detail-image" src={currentImageProxyUrl} loading="eager"></img>
)}
<div className="detail-description">
<DetailsAuthor
profile={activeProfile}
npub={currentImageData?.author}
setActiveImageIdx={setCurrentImage}
></DetailsAuthor>
{currentImageData?.content && <div className="details-text">{currentImageData?.content}</div>}
<div className="details-actions">
{state.userNPub && (
<>
<div className={`heart ${heartState}`} onClick={() => currentImage && heartClick(currentImageData)}>
<IconHeart></IconHeart>
</div>
{(activeProfile?.lud06 || activeProfile?.lud16) && (
<div className={`zap ${zapState}`} onClick={() => currentImage && zapClick(currentImageData)}>
<IconBolt></IconBolt>
</div>
)}
</>
)}
{nextImageData?.noteId && (
<a
className="link"
target="_blank"
href={`https://nostrapp.link/#${nip19.noteEncode(currentImageData?.noteId)}`}
>
<IconLink></IconLink>
</a>
)}
{
<div
className="more"
onClick={e => {
e.stopPropagation();
setShowMoreMenu(s => !s);
}}
>
<IconDots></IconDots>
<div className={`more-menu ${showMoreMenu ? 'show' : ''}`}>
<a
className="more-action"
target="_blank"
href={`https://nostrapp.link/#${nip19.noteEncode(currentImageData?.noteId)}?select=true`}
>
<IconLink></IconLink>Open note with...
</a>
<a
className="more-action"
target="_blank"
href={`https://nostrapp.link/#${currentImageData?.author}`}
>
<IconLink></IconLink>Open author profile
</a>
{/*
<a className="more-action">
<IconLink></IconLink>Repost
</a>
<a className="more-action">
<IconLink></IconLink>Follow author
</a>
*/}
</div>
</div>
}
</div>
{currentImageData.tags.length > 0 && (
<div>
{uniq(currentImageData?.tags).map(t => (
<>
<span
className="tag"
onClick={() => {
setCurrentImage(undefined);
nav({ ...currentSettings, tags: [t], npubs: [] });
}}
>
{t}
</span>{' '}
</>
))}
</div>
)}
</div>
</div>
</div>
</>
);
};
export default DetailsView;

View File

@ -0,0 +1,42 @@
.login-dialog {
display: flex;
flex-direction: column;
padding: 2em;
border-radius: 20px;
background-color: #111;
z-index: 200;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
gap: 24px;
}
.login-dialog input[type='text'] {
min-width: 20em;
display: block;
width: 100%;
background-color: #111;
border-radius: 8px;
border: 1px solid #222;
height: 3em;
font-family: unset;
font-size: unset;
padding: 0.1em;
padding-left: 0.5em;
box-sizing: border-box;
}
.login-dialog .login-address {
display: flex;
flex-direction: row;
gap: 8px;
}
.login-dialog .login-extension {
align-self: center;
}
.login-dialog .login-extension button {
height: 3em;
}

View File

@ -0,0 +1,82 @@
import { useState } from 'react';
import './Login.css';
import { useBunkerLogin, useExtensionLogin } from '../../ngine/context';
import { useGlobalState } from '../../utils/globalState';
type LoginProps = {
onClose: () => void;
};
const Login = ({ onClose }: LoginProps) => {
const [address, setAddress] = useState('');
const [_, setState] = useGlobalState();
const bunkerLogin = useBunkerLogin();
const extensionLogin = useExtensionLogin();
/*
const onLogin = async () => {
const user = await bunkerLogin('florian@nsec.app'); ///bunker://b7c6f6915cfa9a62fff6a1f02604de88c23c6c6c6d1b8f62c7cc10749f307e81?relay=wss://relay.nsec.app'); //florian@nsec.app
//const user = await extensionLogin();
if (user) {
console.log(user.npub);
setState({ userNPub: user.npub, profile: user.profile });
}
else {
console.error('Error loging in');
}
/*
setAutoLogin(true);
const result = await nip);
if (!result) {
console.error('Login failed.');
return;
}
setState({ userNPub: result.npub });
};
*/
const loginWithAddress = async () => {
const user = await bunkerLogin(address);
if (user) {
setState({ userNPub: user.npub, profile: user.profile });
onClose();
} else {
console.error('Error loging in');
}
};
const loginWithExtension = async () => {
const user = await extensionLogin();
if (user) {
setState({ userNPub: user.npub, profile: user.profile });
onClose();
} else {
console.error('Error loging in');
}
};
return (
<div className="login-dialog">
<h2>Login</h2>
<div className="login-address">
<input
type="text"
placeholder="Nostr Address / Bunker URL"
value={address}
onChange={e => setAddress(e.target.value)}
></input>
<button onClick={() => loginWithAddress()}>Login</button>
</div>
<div className="login-extension">
<button onClick={() => loginWithExtension()}>Login with extension</button>
</div>
</div>
);
};
export default Login;

View File

@ -4,10 +4,10 @@ import useNav from '../utils/useNav';
import CloseButton from './CloseButton/CloseButton';
import TagEditor, { Tag } from './TagEditor';
import { defaultHashTags } from './env';
import { useNDK } from '@nostr-dev-kit/ndk-react';
import { createImgProxyUrl } from './nostrImageDownload';
import { useGlobalState } from '../utils/globalState';
import { ViewMode } from './SlideShow';
import useProfile from '../ngine/hooks/useProfile';
type SettingsProps = {
onClose: () => void;
@ -18,7 +18,6 @@ type Mode = 'all' | 'tags' | 'user';
const SettingsDialog = ({ onClose, setViewMode }: SettingsProps) => {
const { nav, currentSettings } = useNav();
const { getProfile } = useNDK();
const [state, setState] = useGlobalState();
const [showAdult, setShowAdult] = useState(currentSettings.showAdult || false);
const [showReplies, setShowReplies] = useState(currentSettings.showReplies || false);
@ -66,7 +65,7 @@ const SettingsDialog = ({ onClose, setViewMode }: SettingsProps) => {
onClose();
};
const activeProfile = npubs.length > 0 ? getProfile(npubs[0]) : undefined;
const activeProfile = useProfile(npubs[0]);
return (
<>

View File

@ -1,4 +1,3 @@
import { useNDK } from '@nostr-dev-kit/ndk-react';
import './SlideShow.css';
import React, { useEffect, useRef, useState } from 'react';
import {
@ -6,21 +5,20 @@ import {
buildFilter,
extractImageUrls,
isImage,
isAdultRelated,
isReply,
isVideo,
prepareContent,
Post,
createImgProxyUrl,
isReply,
isAdultRelated,
} from './nostrImageDownload';
import { blockedPublicKeys, adultContentTags, adultNPubs, mixedAdultNPubs } from './env';
import { adultContentTags, adultNPubs, blockedPublicKeys, mixedAdultNPubs } from './env';
import Settings from './Settings';
import SlideView from './SlideView';
import { nip19 } from 'nostr-tools';
import uniqBy from 'lodash/uniqBy';
import AdultContentInfo from './AdultContentInfo';
import useNav from '../utils/useNav';
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { useGlobalState } from '../utils/globalState';
import useAutoLogin from '../utils/useAutoLogin';
import IconUser from './Icons/IconUser';
@ -33,6 +31,9 @@ import IconHeart from './Icons/IconHeart';
import IconBolt from './Icons/IconBolt';
import IconSearch from './Icons/IconSearch';
import GridView from './GridView';
import useEvents from '../ngine/hooks/useEvents';
import { NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk';
import Login from './Login/Login';
// type AlbyNostr = typeof window.nostr & { enabled: boolean };
@ -72,30 +73,65 @@ FEATURES:
export type ViewMode = 'grid' | 'slideshow' | 'scroll';
const SlideShow = () => {
const { ndk, loginWithNip07, getProfile } = useNDK();
const [posts, setPosts] = useState<Post[]>([]);
const images = useRef<NostrImage[]>([]);
const fetchTimeoutHandle = useRef(0);
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [showSettings, setShowSettings] = useState(false);
const [showLogin, setShowLogin] = useState(false);
const { currentSettings: settings } = useNav();
const [state, setState] = useGlobalState();
const { autoLogin, setAutoLogin } = useAutoLogin();
const currentSubId = useRef('1');
const { setAutoLogin } = useAutoLogin();
const [imageIdx, setImageIdx] = useState<number | undefined>();
const { zapClick, heartClick, zapState, heartState } = useZapsAndReations(state.activeImage, state.userNPub);
const { events } = useEvents(buildFilter(settings.tags, settings.npubs, settings.showReposts), {
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL,
});
useEffect(() => {
setPosts(
events
.filter(
event =>
!blockedPublicKeys.includes(event.pubkey.toLowerCase()) && // remove blocked authors
(settings.showReplies || !isReply(event)) &&
(settings.showAdult || !isAdultRelated(event, settings.tags.length > 0))
)
.map(event => {
// Hack: Write URL in the content for file events
if (event.kind === 1063) {
const urlTag = event?.tags?.find(t => t[0] == 'url');
if (urlTag) {
event.content = urlTag[1];
}
}
// Convert reposts to the original event
if (event.kind === 6 && event.content) {
try {
const repostedEvent = JSON.parse(event.content);
if (repostedEvent) {
event = repostedEvent;
//event.isRepost = true;
}
} catch (e) {
// ingore, the content is no valid json
}
}
return { event };
})
);
}, [events]);
/*
useEffect(() => {
const fetch = () => {
if (!ndk) {
console.error('NDK not available.');
return;
}
currentSubId.current = `${Math.floor(Math.random() * 10000000)}`;
const postSubscription = ndk.subscribe(buildFilter(settings.tags, settings.npubs, settings.showReposts), {
const postSubscription = ndk.subscribe(), {
subId: currentSubId.current,
});
@ -155,6 +191,13 @@ const SlideShow = () => {
fetch();
}
}, [settings, ndk]);
*/
useEffect(() => {
// reset all
setPosts([]);
images.current = [];
}, [settings]);
useEffect(() => {
images.current = uniqBy(
@ -203,19 +246,13 @@ const SlideShow = () => {
};
useEffect(() => {
setTimeout(() => {
if (autoLogin && window.nostr) {
// auto login when alby is available
onLogin();
}
}, 100);
document.body.addEventListener('keydown', onKeyDown);
return () => {
document.body.removeEventListener('keydown', onKeyDown);
};
}, []);
/*
useEffect(() => {
if (state.userNPub) {
setState({ profile: getProfile(state.userNPub) });
@ -223,7 +260,7 @@ const SlideShow = () => {
setState({ profile: undefined });
}
}, [state.userNPub, getProfile, setState]);
*/
const fullScreen = document.fullscreenElement !== null;
const showAdultContentWarning =
@ -236,29 +273,18 @@ const SlideShow = () => {
return <AdultContentInfo></AdultContentInfo>;
}
const onLogin = async () => {
setAutoLogin(true);
const result = await loginWithNip07();
if (!result) {
console.error('Login failed.');
return;
}
setState({ userNPub: result.npub });
const toggleViewMode = () => {
setViewMode(view => (view == 'grid' ? 'scroll' : 'grid'));
};
const onLogout = () => {
setAutoLogin(false);
setState({ userNPub: undefined, profile: undefined });
};
const toggleViewMode = () => {
setViewMode(view => (view == 'grid' ? 'scroll' : 'grid'));
};
return (
<>
{showSettings && <Settings onClose={() => setShowSettings(false)} setViewMode={setViewMode}></Settings>}
{showLogin && <Login onClose={() => setShowLogin(false)}/>}
<div className="top-controls">
{state.userNPub && state.profile ? (
@ -266,7 +292,7 @@ const SlideShow = () => {
<img className="profile" onClick={onLogout} src={createImgProxyUrl(state.profile.image, 80, 80)} />
)
) : (
<button onClick={onLogin} className="login">
<button onClick={() => setShowLogin(true)} className="login">
<IconUser></IconUser>
</button>
)}

View File

@ -61,6 +61,10 @@ const SlideView = ({ settings, images, setViewMode }: SlideViewProps) => {
}
};
const swipeHandlers = useSwipeable({
onSwipedLeft: () => nextImage(),
onSwipedRight: () => previousImage(),

View File

@ -39,7 +39,7 @@ export const buildFilter = (tags: string[], npubs: string[], withReposts = false
}
}
console.log('filter', filter);
// console.log('filter', filter);
return filter;
};

View File

@ -1,9 +1,21 @@
import App from './App';
import './index.css';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { NDKProvider } from '@nostr-dev-kit/ndk-react';
import { defaultRelays } from './components/env';
import Home from './components/Home';
import { NgineProvider } from './ngine/context';
import NDK from '@nostr-dev-kit/ndk';
import { useEffect } from 'react';
import NDKCacheAdapterDexie from '@nostr-dev-kit/ndk-cache-dexie';
const cacheAdapterDexie = new NDKCacheAdapterDexie({ dbName: "slidestr" });
const ndk = new NDK({
explicitRelayUrls: defaultRelays,
outboxRelayUrls: ["wss://purplepag.es"],
enableOutboxModel: true,
//signer: new NDKNip07Signer(),
cacheAdapter: cacheAdapterDexie as any // types don't in the current version
});
const MainInner = () => {
//const [state] = useGlobalState();
@ -39,11 +51,14 @@ const MainInner = () => {
},
]);
return (
<NDKProvider relayUrls={defaultRelays}>
useEffect(() => {
ndk.connect();
}, []);
return (<NgineProvider ndk={ndk}>
<RouterProvider router={router} />
</NDKProvider>
);
</NgineProvider>)
};
export default MainInner;

384
src/ngine/context.tsx Normal file
View File

@ -0,0 +1,384 @@
import { useEffect, createContext, useContext, ReactNode } from 'react';
import { useAtom, Provider } from 'jotai';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import NDK, {
NDKKind,
NDKNip07Signer,
NDKNip46Signer,
NDKPrivateKeySigner,
NDKUser,
NostrEvent,
NDKEvent,
NDKSigner,
NDKSubscriptionCacheUsage,
} from '@nostr-dev-kit/ndk';
import useRates from './hooks/useRates';
import useLatestEvent from './hooks/useLatestEvent';
import { sessionAtom, relayListAtom, followsAtom, ratesAtom } from './state';
import { Links } from './types';
import { getNip05For } from '../utils/nip05';
const queryClient = new QueryClient();
interface NgineContextProps {
ndk: NDK;
nip07Login: () => Promise<NDKUser | undefined>;
nip46Login: (url: string) => Promise<NDKUser | undefined>;
nsecLogin: (nsec: string) => Promise<NDKUser>;
npubLogin: (npub: string) => Promise<NDKUser>;
sign: (ev: Omit<NostrEvent, 'pubkey'>, signer?: NDKSigner) => Promise<NDKEvent | undefined>;
logOut: () => void;
links?: Links;
}
const NgineContext = createContext<NgineContextProps>({
ndk: new NDK({ explicitRelayUrls: [] }),
nip07Login: () => {
return Promise.reject();
},
nip46Login: () => {
return Promise.reject();
},
nsecLogin: () => {
return Promise.reject();
},
npubLogin: () => {
return Promise.reject();
},
sign: () => {
return Promise.reject();
},
logOut: () => {},
links: {},
});
interface NgineProviderProps {
ndk: NDK;
links?: Links;
children: ReactNode;
enableFiatRates?: boolean;
locale?: string;
}
function SessionProvider({ pubkey, children }: { pubkey: string; children: ReactNode }) {
const [contactList, setContacts] = useAtom(followsAtom);
const [relayList, setRelayList] = useAtom(relayListAtom);
// Contacts
const contacts = useLatestEvent(
{
kinds: [NDKKind.Contacts],
authors: [pubkey],
},
{
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL,
closeOnEose: false,
}
);
useEffect(() => {
if (contacts) {
const lastSeen = contactList?.created_at ?? 0;
const createdAt = contacts.created_at ?? 0;
if (createdAt > lastSeen) {
setContacts(contacts.rawEvent());
}
}
}, [contacts]);
// Relays
const relays = useLatestEvent(
{
kinds: [NDKKind.RelayList],
authors: [pubkey],
},
{
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL,
closeOnEose: false,
}
);
useEffect(() => {
if (relays) {
const lastSeen = relayList?.created_at ?? 0;
const createdAt = relays.created_at ?? 0;
if (createdAt > lastSeen) {
setRelayList(relays.rawEvent());
}
}
}, [relays]);
return children;
}
export const NgineProvider = ({ ndk, links, children, enableFiatRates = false }: NgineProviderProps) => {
const [session, setSession] = useAtom(sessionAtom);
const [, setFollows] = useAtom(followsAtom);
const [, setRelays] = useAtom(relayListAtom);
const [, setRates] = useAtom(ratesAtom);
const rates = useRates(!enableFiatRates);
useEffect(() => {
setRates(rates);
}, [rates]);
useEffect(() => {
if (session?.method === 'nip07') {
const signer = new NDKNip07Signer();
ndk.signer = signer;
} else if (session?.method === 'nsec') {
const signer = new NDKPrivateKeySigner(session.privkey);
ndk.signer = signer;
} else if (session?.method === 'nip46' && session.bunker) {
const { privkey, relays } = session.bunker;
const localSigner = new NDKPrivateKeySigner(privkey);
const bunkerNDK = new NDK({ explicitRelayUrls: relays });
bunkerNDK.connect().then(() => {
const signer = new NDKNip46Signer(bunkerNDK, session.pubkey, localSigner);
signer.on('authUrl', url => {
window.open(url, 'auth', 'width=600,height=600');
});
ndk.signer = signer;
});
}
// todo: nip05
}, [session]);
async function nip07Login() {
const signer = new NDKNip07Signer();
const user = await signer.blockUntilReady();
if (user) {
ndk.signer = signer;
user.ndk = ndk;
setSession({
method: 'nip07',
pubkey: user.pubkey,
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const profile = await user.fetchProfile({
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST,
});
}
return user;
}
async function getNostrConnectSettings(url: string) {
if (url.includes('bunker://')) {
const asURL = new URL(url);
const relays = asURL.searchParams.getAll('relay');
const pubkey = asURL.pathname.replace(/^\/\//, '');
return { relays, pubkey };
} else {
console.log(url);
//const user = await NDKUser.fromNip05(url, ndk, true); // TODO needs PR FIX in NDK
const user = await getNip05For(url);
if (user) {
const pubkey = user.pubkey;
const relays = user.nip46 && user.nip46.length > 0 ? user.nip46 : ['wss://relay.nsecbunker.com'];
return {
pubkey,
relays,
};
}
}
}
async function nip46Login(url: string) {
const settings = await getNostrConnectSettings(url);
if (settings) {
console.log(settings);
const { pubkey, relays } = settings;
const bunkerNDK = new NDK({
explicitRelayUrls: relays,
});
await bunkerNDK.connect();
const localSigner = NDKPrivateKeySigner.generate();
console.log('localSigner', localSigner);
const signer = new NDKNip46Signer(bunkerNDK, pubkey, localSigner);
console.log('signer', signer);
signer.on('authUrl', url => {
window.open(url, 'auth', 'width=600,height=600');
});
const user = await signer.blockUntilReady();
if (user) {
ndk.signer = signer;
user.ndk = ndk;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const profile = await user.fetchProfile({
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST,
});
setSession({
method: 'nip46',
pubkey,
bunker: {
privkey: localSigner.privateKey as string,
relays,
},
});
}
return user;
}
}
async function npubLogin(pubkey: string) {
const user = ndk.getUser({ hexpubkey: pubkey });
setSession({
method: 'npub',
pubkey: pubkey,
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const profile = await user.fetchProfile({
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST,
});
return user;
}
async function nsecLogin(privkey: string) {
const signer = new NDKPrivateKeySigner(privkey);
const user = await signer.blockUntilReady();
if (user) {
ndk.signer = signer;
setSession({
method: 'nsec',
pubkey: user.pubkey,
privkey,
});
}
return user;
}
async function sign(ev: Omit<NostrEvent, 'pubkey'>, signer?: NDKSigner) {
if (signer) {
const user = await signer.user();
const ndkEvent = new NDKEvent(ndk, { ...ev, pubkey: user.pubkey });
await ndkEvent.sign(signer);
return ndkEvent;
} else if (session?.pubkey && session?.method !== 'npub') {
const ndkEvent = new NDKEvent(ndk, { ...ev, pubkey: session.pubkey });
await ndkEvent.sign();
return ndkEvent;
} else {
console.log('Could not sign event', ev);
}
}
function logOut() {
ndk.signer = undefined;
setSession(null);
setFollows(null);
setRelays(null);
}
return (
<NgineContext.Provider
value={{
ndk,
nip07Login,
nip46Login,
nsecLogin,
npubLogin,
sign,
logOut,
links,
}}
>
<QueryClientProvider client={queryClient}>
<Provider>
{session ? <SessionProvider pubkey={session.pubkey}>{children}</SessionProvider> : children}
</Provider>
</QueryClientProvider>
</NgineContext.Provider>
);
};
export const useExtensionLogin = () => {
const context = useContext(NgineContext);
if (context === undefined) {
throw new Error('Ngine context not found');
}
return context.nip07Login;
};
export const usePubkeyLogin = () => {
const context = useContext(NgineContext);
if (context === undefined) {
throw new Error('Ngine context not found');
}
return context.npubLogin;
};
export const useBunkerLogin = () => {
const context = useContext(NgineContext);
if (context === undefined) {
throw new Error('Ngine context not found');
}
return context.nip46Login;
};
export const useNsecLogin = () => {
const context = useContext(NgineContext);
if (context === undefined) {
throw new Error('Ngine context not found');
}
return context.nsecLogin;
};
export const useSign = () => {
const context = useContext(NgineContext);
if (context === undefined) {
throw new Error('Ngine context not found');
}
return context.sign;
};
export const useNDK = () => {
const context = useContext(NgineContext);
if (context === undefined) {
throw new Error('Ngine context not found');
}
return context.ndk;
};
export const useSigner = () => {
const context = useContext(NgineContext);
if (context === undefined) {
throw new Error('Ngine context not found');
}
return context.ndk.signer;
};
type LinkType = keyof Links;
export const useLink = (type: LinkType, value: string): string | null => {
const context = useContext(NgineContext);
if (context === undefined) {
throw new Error('Ngine context not found');
}
if (context.links && context.links[type]) {
// @ts-expect-error maybe not defined
return context.links[type](value);
}
return null;
};
export const useLinks = (): Links | undefined => {
const context = useContext(NgineContext);
if (context === undefined) {
throw new Error('Ngine context not found');
}
return context.links;
};
export const useLogOut = () => {
const context = useContext(NgineContext);
if (context === undefined) {
throw new Error('Ngine context not found');
}
return context.logOut;
};

23
src/ngine/filter.ts Normal file
View File

@ -0,0 +1,23 @@
import { NDKKind, NDKFilter } from "@nostr-dev-kit/ndk";
export function addressesToFilter(addresses: string[]): NDKFilter {
const filter = addresses.reduce(
(acc, a) => {
const [k, pubkey, d] = a.split(":");
acc.kinds.add(Number(k));
acc.authors.add(pubkey);
acc["#d"].add(d);
return acc;
},
{
kinds: new Set<NDKKind>(),
authors: new Set<string>(),
"#d": new Set<string>(),
},
);
return {
kinds: [...filter.kinds],
authors: [...filter.authors],
"#d": [...filter["#d"]],
};
}

48
src/ngine/format.ts Normal file
View File

@ -0,0 +1,48 @@
import type { Currency, Rates } from "./types";
export function formatSats(n: number) {
const intl = new Intl.NumberFormat("en", {
minimumFractionDigits: 0,
maximumFractionDigits: n < 1e8 ? 2 : 8,
});
if (n === 1) {
return `1`;
} else if (n < 2e3) {
return `${n}`;
} else if (n < 1e6) {
return `${intl.format(n / 1e3)}K`;
} else if (n < 1e9) {
return `${intl.format(n / 1e6)}M`;
} else {
return `${intl.format(n / 1e8)}BTC`;
}
}
export function formatSatAmount(n: number, currency: Currency, rates: Rates) {
const intl = new Intl.NumberFormat("en", {
style: "currency",
currency,
maximumFractionDigits: 2,
});
const amount = (n / 1e8) * rates.ask;
return intl.format(amount);
}
export function formatRelativeTime(timestamp: number) {
const now = Math.floor(Date.now() / 1000);
const elapsed = now - timestamp;
if (elapsed < 60) {
return `${elapsed} second${elapsed !== 1 ? "s" : ""} ago`;
} else if (elapsed < 3600) {
const minutes = Math.floor(elapsed / 60);
return `${minutes} minute${minutes !== 1 ? "s" : ""} ago`;
} else if (elapsed < 86400) {
const hours = Math.floor(elapsed / 3600);
return `${hours} hour${hours !== 1 ? "s" : ""} ago`;
} else {
const days = Math.floor(elapsed / 86400);
return `${days} day${days !== 1 ? "s" : ""} ago`;
}
}

View File

@ -0,0 +1,20 @@
import { useMemo } from "react";
import useEvent from "./useEvent";
export default function useAddress(address: string, relays?: string[]) {
const { kind, pubkey, identifier } = useMemo(() => {
const [k, pubkey, identifier] = address.split(":");
return { kind: Number(k), pubkey, identifier };
}, [address]);
const event = useEvent(
{
kinds: [kind],
authors: [pubkey],
"#d": [identifier],
},
{},
relays,
);
return event;
}

View File

@ -0,0 +1,10 @@
import useEvents, { SubscriptionOptions } from "./useEvents";
import { addressesToFilter } from "../filter";
export default function useAddresses(
addresses: string[],
opts?: SubscriptionOptions,
relays?: string[],
) {
return useEvents(addressesToFilter(addresses), opts, relays);
}

View File

@ -0,0 +1,11 @@
export default function useCopy() {
const copy = async (text: string) => {
if (typeof navigator === "undefined") {
return;
}
await navigator.clipboard.writeText(text);
};
return copy;
}

View File

@ -0,0 +1,45 @@
import { useMemo } from "react";
import {
NDKEvent,
NDKFilter,
NDKRelaySet,
NDKSubscriptionCacheUsage,
} from "@nostr-dev-kit/ndk";
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { useNDK } from "../context";
import { hashSha256 } from "../utils";
import { SubscriptionOptions } from "./useEvents";
export default function useEvent(
filter: NDKFilter,
opts?: SubscriptionOptions,
relays?: string[],
) {
const ndk = useNDK();
const id = useMemo(() => {
return hashSha256(filter);
}, [filter]);
const query: UseQueryResult<NDKEvent, any> = useQuery({
queryKey: ["use-event", id],
queryFn: () => {
const relaySet =
relays?.length ?? 0 > 0
? NDKRelaySet.fromRelayUrls(relays as string[], ndk)
: undefined;
return ndk.fetchEvent(
filter,
{
groupable: true,
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST,
...(opts ? opts : {}),
},
relaySet,
);
},
});
return query.data;
}

View File

@ -0,0 +1,44 @@
import { useState, useEffect, useMemo } from 'react';
import { NDKEvent, NDKFilter, NDKRelaySet, NDKSubscriptionOptions } from '@nostr-dev-kit/ndk';
import uniqBy from 'lodash/uniqBy';
import { useNDK } from '../context';
import { hashSha256 } from '../utils';
export interface SubscriptionOptions extends NDKSubscriptionOptions {
disable?: boolean;
}
export default function useEvents(filter: NDKFilter | NDKFilter[], opts?: SubscriptionOptions, relays?: string[]) {
const ndk = useNDK();
const [eose, setEose] = useState(false);
const [events, setEvents] = useState<NDKEvent[]>([]);
const id = useMemo(() => {
return hashSha256(filter);
}, [filter]);
useEffect(() => {
if (filter && !opts?.disable) {
console.log('useEvents: new Subscription');
setEvents([]);
const relaySet = relays?.length ?? 0 > 0 ? NDKRelaySet.fromRelayUrls(relays as string[], ndk) : undefined;
const sub = ndk.subscribe(filter, opts, relaySet);
sub.on('event', (ev: NDKEvent) => {
setEvents(evs => {
const newEvents = evs.concat([ev]).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
return uniqBy(newEvents, (e: NDKEvent) => e.tagId());
});
});
sub.on('eose', () => {
setEose(true);
});
return () => {
sub.stop();
};
}
}, [id, opts?.disable]);
return { id, eose, events };
}

View File

@ -0,0 +1,41 @@
import { useState, useEffect } from "react";
import {
NDKEvent,
NDKFilter,
NDKRelaySet,
} from "@nostr-dev-kit/ndk";
import { useNDK } from "../context";
import { SubscriptionOptions } from "./useEvents";
export default function useLatestEvent(
filter: NDKFilter | NDKFilter[],
opts?: SubscriptionOptions,
relays?: string[],
) {
const ndk = useNDK();
const [event, setEvent] = useState<NDKEvent | undefined>();
useEffect(() => {
if (!opts?.disable) {
const relaySet =
relays?.length ?? 0 > 0
? NDKRelaySet.fromRelayUrls(relays as string[], ndk)
: undefined;
const sub = ndk.subscribe(filter, opts, relaySet);
sub.on("event", (ev: NDKEvent) => {
const lastSeen = event?.created_at ?? 0;
const createdAt = ev?.created_at ?? 0;
if (createdAt > lastSeen) {
setEvent(ev);
}
});
return () => {
sub.stop();
};
}
}, [opts?.disable]);
return event;
}

View File

@ -0,0 +1,22 @@
import { NDKUserProfile, NDKSubscriptionCacheUsage } from "@nostr-dev-kit/ndk";
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { useNDK } from "../context";
export default function useProfile(
pubkey: string,
cacheUsage = NDKSubscriptionCacheUsage.CACHE_FIRST,
) {
const ndk = useNDK();
const query: UseQueryResult<NDKUserProfile, Error> = useQuery({
queryKey: ["profile", pubkey],
queryFn: () => {
const user = ndk.getUser({ hexpubkey: pubkey });
return user.fetchProfile({
cacheUsage,
});
},
});
return query.data;
}

View File

@ -0,0 +1,31 @@
import { useState, useEffect } from "react";
import {
NDKUserProfile,
NDKSubscriptionCacheUsage,
NDKKind,
} from "@nostr-dev-kit/ndk";
import { useNDK } from "../context";
export default function useProfiles(pubkeys: string[]) {
const ndk = useNDK();
const [profiles, setProfiles] = useState<NDKUserProfile[]>([]);
useEffect(() => {
ndk
.fetchEvents(
{
kinds: [NDKKind.Metadata],
authors: pubkeys,
},
{
cacheUsage: NDKSubscriptionCacheUsage.CACHE_FIRST,
},
)
.then((profileSet) => {
return setProfiles([...profileSet].map((ev) => JSON.parse(ev.content)));
});
}, [pubkeys]);
return profiles;
}

View File

@ -0,0 +1,45 @@
import { useMemo } from "react";
import { NDKKind, NDKEvent } from "@nostr-dev-kit/ndk";
import useLatestEvent from "./useLatestEvent";
import type { RateSymbol, Rates, FiatCurrency } from "../money";
const SNORT_PUBKEY =
"84de35e2584d2b144aae823c9ed0b0f3deda09648530b93d1a2a146d1dea9864";
export default function useRates(isDisabled = false): Rates[] {
const event = useLatestEvent(
{
kinds: [1009 as NDKKind],
authors: [SNORT_PUBKEY],
},
{
disable: isDisabled,
groupable: false,
closeOnEose: false,
},
["wss://relay.snort.social"],
);
function eventToRates(ev: NDKEvent): Rates[] {
const tags = ev.getMatchingTags("d");
return tags.map((tag) => {
const symbol = tag[1];
return {
time: ev.created_at ?? 0,
ask: Number(tag[2]) ?? 0,
bid: Number(tag[3]) ?? 0,
low: Number(tag[4]) ?? 0,
high: Number(tag[5]) ?? 0,
currency: symbol.replace("BTC", "") as FiatCurrency,
symbol: symbol as RateSymbol,
};
});
}
const rates = useMemo(() => {
if (event) {
return eventToRates(event);
}
return [];
}, [event]);
return rates;
}

View File

@ -0,0 +1,83 @@
import { useMemo } from "react";
import {
NDKEvent,
NDKFilter,
NDKKind,
NDKSubscriptionCacheUsage,
} from "@nostr-dev-kit/ndk";
import useEvents from "./useEvents";
import { zapsSummary, ZapRequest } from "../nostr/nip57";
import { ReactionKind } from "../types";
export type ReactionEvents = {
events: NDKEvent[];
zaps: {
zapRequests: ZapRequest[];
total: number;
};
reactions: NDKEvent[];
replies: NDKEvent[];
reposts: NDKEvent[];
bookmarks: NDKEvent[];
};
export default function useReactions(
event: NDKEvent,
kinds: ReactionKind[],
live = true,
): ReactionEvents {
const filter = useMemo(() => {
return {
kinds,
...event.filter(),
} as NDKFilter;
}, [event, kinds]);
const { events } = useEvents(filter, {
disable: !live,
closeOnEose: false,
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL,
});
const zaps = useMemo(
() => events.filter((e) => e.kind === NDKKind.Zap),
[events],
);
const { zapRequests, total } = useMemo(() => zapsSummary(zaps), [zaps]);
const reactions = useMemo(
() => events.filter((e) => e.kind === NDKKind.Reaction),
[events],
);
const replies = useMemo(
() => events.filter((e) => e.kind === NDKKind.Text),
[events],
);
const reposts = useMemo(
() =>
events.filter(
(e) => e.kind === NDKKind.Repost || e.kind === NDKKind.GenericRepost,
),
[events],
);
const bookmarks = useMemo(
() =>
events.filter(
(e) =>
e.kind === NDKKind.BookmarkList ||
e.kind === NDKKind.CategorizedBookmarkList ||
e.kind === NDKKind.RelayList ||
e.kind === NDKKind.EmojiList,
),
[events],
);
return {
events,
zaps: {
zapRequests,
total,
},
reactions,
replies,
reposts,
bookmarks,
};
}

158
src/ngine/lnurl.ts Normal file
View File

@ -0,0 +1,158 @@
import { useState, useEffect } from "react";
import { NostrEvent } from "@nostr-dev-kit/ndk";
import { useQuery, useQueries } from "@tanstack/react-query";
import { bech32 } from "bech32";
import { NDKUserProfile } from "@nostr-dev-kit/ndk";
const BECH32_MAX_BYTES = 42000;
interface LNURLService {
nostrPubkey?: string;
minSendable: number;
maxSendable: number;
metadata: string;
callback: string;
commentAllowed?: number;
}
export function useLnurl(profile: NDKUserProfile | undefined) {
const key = profile?.lud16 ?? "none";
const query = useQuery({
queryKey: ["lnurl", key],
queryFn: async () => {
if (key === "none") {
return null;
}
return loadService(key);
},
retry: false,
refetchOnMount: false,
});
return query;
}
export function useLnurlVerify(lnurlVerifyUrl?: string) {
const [isPaid, setIsPaid] = useState(false);
useEffect(() => {
let pollingInterval: number | undefined;
const pollLnurlPayment = async () => {
try {
if (lnurlVerifyUrl) {
const response = await fetch(lnurlVerifyUrl);
const data = await response.json();
if (data.settled) {
setIsPaid(true);
clearInterval(pollingInterval);
}
}
} catch (error) {
console.error("Error polling LNURL:", error);
}
};
if (lnurlVerifyUrl) {
pollingInterval = setInterval(pollLnurlPayment, 1000);
return () => clearInterval(pollingInterval);
}
return () => {};
}, [lnurlVerifyUrl]);
return isPaid;
}
export function useLnurls(profiles: NDKUserProfile[]) {
const queries = profiles.map((profile) => {
return {
queryKey: ["lnurl", profile.lud16],
queryFn: async () => {
if (profile.lud16) {
return loadService(profile.lud16);
}
},
};
});
return useQueries({ queries });
}
function bech32ToText(str: string) {
const decoded = bech32.decode(str, BECH32_MAX_BYTES);
const buf = bech32.fromWords(decoded.words);
return new TextDecoder().decode(Uint8Array.from(buf));
}
async function fetchJson<T>(url: string) {
const rsp = await fetch(url);
if (rsp.ok) {
const data: T = await rsp.json();
return data;
}
return null;
}
export async function loadService(
service?: string,
): Promise<LNURLService | null> {
if (service) {
const isServiceUrl = service.toLowerCase().startsWith("lnurl");
if (isServiceUrl) {
const serviceUrl = bech32ToText(service);
return await fetchJson(serviceUrl);
} else {
const ns = service.split("@");
return await fetchJson(`https://${ns[1]}/.well-known/lnurlp/${ns[0]}`);
}
}
return null;
}
export async function loadInvoice(
payService: LNURLService,
amount: number,
comment?: string,
nostr?: NostrEvent,
) {
if (!amount || !payService) return null;
const callback = new URL(payService.callback);
const query = new Map<string, string>();
if (callback.search.length > 0) {
callback.search
.slice(1)
.split("&")
.forEach((a) => {
const pSplit = a.split("=");
query.set(pSplit[0], pSplit[1]);
});
}
query.set("amount", Math.floor(amount * 1000).toString());
if (comment && payService?.commentAllowed) {
query.set("comment", comment);
}
if (payService.nostrPubkey && nostr) {
query.set("nostr", JSON.stringify(nostr));
}
const baseUrl = `${callback.protocol}//${callback.host}${callback.pathname}`;
// @ts-ignore
const queryJoined = [...query.entries()]
.map((v) => `${v[0]}=${encodeURIComponent(v[1])}`)
.join("&");
try {
const rsp = await fetch(`${baseUrl}?${queryJoined}`);
if (rsp.ok) {
const data = await rsp.json();
if (data.status === "ERROR") {
throw new Error(data.reason);
} else {
return data;
}
}
} catch (e) {
console.error(e);
}
}

11
src/ngine/money.ts Normal file
View File

@ -0,0 +1,11 @@
import { Rates } from "./types";
export function convertSatsToFiat(amt: string, rates: Rates): string {
const inBtc = Number(amt) / 1e8;
return String((rates.ask * inBtc).toFixed(2));
}
export function convertFiatToSats(amt: string, rates: Rates): string {
const inFiat = Number(amt);
return String(((inFiat / rates.ask) * 1e8).toFixed(0));
}

10
src/ngine/nostr/kinds.tsx Normal file
View File

@ -0,0 +1,10 @@
import { NDKKind } from "@nostr-dev-kit/ndk";
export const REPOSTS = [NDKKind.Repost, NDKKind.GenericRepost];
export const BOOKMARKS = [
NDKKind.BookmarkList,
NDKKind.CategorizedBookmarkList,
NDKKind.RelayList,
NDKKind.EmojiList,
];

172
src/ngine/nostr/nip57.ts Normal file
View File

@ -0,0 +1,172 @@
import { useMemo } from "react";
import { decode } from "light-bolt11-decoder";
import { NDKKind } from "@nostr-dev-kit/ndk";
import type { NDKEvent, NostrEvent } from "@nostr-dev-kit/ndk";
import { unixNow } from "../time";
export function makeZapRequest({
p,
pubkey,
amount,
relays,
event,
comment,
}: {
p: string;
pubkey: string;
amount: number;
relays: string[];
event?: NDKEvent;
comment?: string;
}): NostrEvent {
const msats = amount * 1000;
return {
pubkey,
kind: NDKKind.ZapRequest,
created_at: unixNow(),
content: comment || "",
tags: [
["p", p],
...[event ? event.tagReference() : []],
["amount", String(msats)],
["relays", ...relays],
],
};
}
export function getZapRequest(zap: NDKEvent): NostrEvent | undefined {
let zapRequest = zap.tagValue("description");
if (zapRequest) {
try {
if (zapRequest.startsWith("%")) {
zapRequest = decodeURIComponent(zapRequest);
}
return JSON.parse(zapRequest);
} catch (e) {
console.warn("Invalid zap", zapRequest);
}
}
}
export function getZapAmount(zap: NDKEvent): number {
try {
const invoice = zap.tagValue("bolt11");
if (invoice) {
const decoded = decode(invoice);
const amount = decoded.sections.find(({ name }) => name === "amount");
return amount ? Number(amount.value) / 1000 : 0;
}
return 0;
} catch (error) {
console.error(error);
return 0;
}
}
export interface ZapRequest extends NostrEvent {
created_at: number;
amount: number;
e?: string;
p: string;
a?: string;
relays: string[];
}
export interface ZapsSummary {
zapRequests: ZapRequest[];
total: number;
}
export function parseZap(z: NDKEvent): ZapRequest | null {
const zr = getZapRequest(z);
if (!zr) {
return null;
}
const eTag = zr ? zr.tags.find((t) => t[0] === "e") : null;
const e = eTag ? eTag[1] : undefined;
const pTag = zr ? zr.tags.find((t) => t[0] === "p") : null;
const p = pTag ? pTag[1] : z.pubkey;
const aTag = zr ? zr.tags.find((t) => t[0] === "a") : null;
const a = aTag ? aTag[1] : undefined;
const relaysTag = zr ? zr.tags.find((t) => t[0] === "relays") || [] : [];
return {
...getZapRequest(z),
amount: getZapAmount(z),
e,
p,
a,
relays: relaysTag.slice(1),
} as ZapRequest;
}
export function zapsSummary(zaps: NDKEvent[]): ZapsSummary {
const zapRequests = zaps
.map(parseZap)
.filter((z) => z !== null)
// @ts-ignore
.sort((a, b) => b.amount - a.amount) as ZapRequest[];
const total = zapRequests.reduce((acc, { amount }) => {
return acc + amount;
}, 0);
return { zapRequests, total };
}
export interface ZapSplit {
pubkey: string;
percentage: number;
}
export function getZapSplits(ev: NDKEvent): ZapSplit[] {
const zapTags = ev.getMatchingTags("zap");
return zapTagsToSplits(zapTags);
}
export function zapTagsToSplits(zapTags: string[][]): ZapSplit[] {
const totalWeight = zapTags.reduce((acc, t) => {
return acc + Number(t[3] ?? "");
}, 0);
return zapTags.map((t) => {
const [, pubkey, , weight] = t;
const percentage = (Number(weight) / totalWeight) * 100;
return { pubkey, percentage };
});
}
interface Rank {
pubkey: string;
amount: number;
}
export function useRanking(zaps: NDKEvent[]): Rank[] {
const { zapRequests } = useMemo(() => zapsSummary(zaps), [zaps]);
const byAmount = useMemo(() => {
return zapRequests.reduce(
(result, element) => {
const pubkey = element.pubkey;
if (!result[pubkey]) {
result[pubkey] = 0;
}
result[pubkey] += element.amount;
return result;
},
{} as Record<string, number>,
);
}, [zapRequests]);
const ranking = useMemo(() => {
return Object.entries(byAmount)
.sort((a, b) => {
return b[1] - a[1];
})
.map((e) => {
return { pubkey: e[0], amount: e[1] };
});
}, [byAmount]);
return ranking;
}

68
src/ngine/state.ts Normal file
View File

@ -0,0 +1,68 @@
import { atom, useAtomValue } from "jotai";
import { atomWithStorage } from "jotai/utils";
import type { NostrEvent } from "@nostr-dev-kit/ndk";
import type { Relay, Rates, Session, Currency } from "./types";
export const sessionAtom = atomWithStorage<Session | null>(
"ngine.session",
null,
);
export const relayListAtom = atomWithStorage<NostrEvent | null>(
"ngine.10002",
null,
);
export const relaysAtom = atom<Relay[]>((get) => {
const relayList = get(relayListAtom);
return (
relayList?.tags
.filter((t) => t[0] === "r")
.map((t) => {
const url = t[1].replace(/\/$/, "");
const read = t.length === 2 || t[2] === "read";
const write = t.length === 2 || t[2] === "write";
return { url, read, write };
}) || []
);
});
export const followsAtom = atom<NostrEvent | null>(null);
export const contactsAtom = atom<string[]>((get) => {
const follows = get(followsAtom);
return follows?.tags.filter((t) => t[0] === "p").map((t) => t[1]) ?? [];
});
export const currencyAtom = atomWithStorage<Currency>("ngine.currency", "BTC");
export const ratesAtom = atomWithStorage<Rates[]>("ngine.rates", []);
export function useExchangeRate(currency: Currency): Rates | undefined {
const rates = useAtomValue(ratesAtom);
if (currency === "BTC") {
return;
}
return rates.find((r) => r.currency === currency);
}
export function useCurrency() {
return useAtomValue(currencyAtom);
}
export function useRates() {
const currency = useCurrency();
return useExchangeRate(currency);
}
export function useRelaySettings() {
return useAtomValue(relaysAtom);
}
export function useRelays() {
const relays = useAtomValue(relaysAtom);
return relays.map((r) => r.url);
}
export function useSession() {
return useAtomValue(sessionAtom);
}
export function useContacts() {
return useAtomValue(contactsAtom);
}

5
src/ngine/tags.ts Normal file
View File

@ -0,0 +1,5 @@
import { NDKEvent } from "@nostr-dev-kit/ndk";
export function tagValues(ev: NDKEvent, tag: string): string[] {
return ev.tags.filter((t) => t[0] === tag).map((t) => t[1]);
}

3
src/ngine/time.ts Normal file
View File

@ -0,0 +1,3 @@
export function unixNow() {
return Math.round(Date.now() / 1000);
}

83
src/ngine/types.ts Normal file
View File

@ -0,0 +1,83 @@
import type { ReactNode } from "react";
import type { NDKKind, NDKEvent } from "@nostr-dev-kit/ndk";
// Reactions
export type ReactionKind =
| NDKKind.Zap
| NDKKind.Text
| NDKKind.Reaction
| NDKKind.Repost
| NDKKind.GenericRepost
| NDKKind.BookmarkList
| NDKKind.CategorizedBookmarkList
| NDKKind.RelayList
| NDKKind.EmojiList;
// Relays
export interface Relay {
url: string;
read: boolean;
write: boolean;
}
// Links
export interface Links {
npub?: (npub: string) => string;
nrelay?: (nrelay: string) => string;
nprofile?: (nprofile: string) => string;
nevent?: (nevent: string) => string;
naddr?: (naddr: string) => string;
t?: (t: string) => string;
}
// Sessions
// todo: nip05 with nip46
export type LoginMethod = "nip07" | "nip46" | "npub" | "nsec";
export interface Session {
method: LoginMethod;
pubkey: string;
privkey?: string;
bunker?: {
privkey: string;
relays: string[];
};
}
// Components
export type Fragment = string | ReactNode;
export type EventComponent = (props: EventProps) => ReactNode;
export type Components = Record<number, EventComponent>;
export interface EventProps {
event: NDKEvent;
components?: Components;
reactionKinds?: ReactionKind[];
}
// Nostr
export type Tag = string[];
export type Tags = Tag[];
// Money
export type RateSymbol = "BTCUSD" | "BTCEUR";
export type FiatCurrency = "USD" | "EUR";
export type Currency = "BTC" | "USD" | "EUR";
export interface Rates {
time: number;
ask: number;
bid: number;
low: number;
high: number;
currency: FiatCurrency;
symbol: RateSymbol;
}

63
src/ngine/utils.ts Normal file
View File

@ -0,0 +1,63 @@
import { sha256 } from "@noble/hashes/sha256";
interface HasPubkey {
pubkey: string;
}
export function dedupeByPubkey<T extends HasPubkey>(evs: T[]): T[] {
return evs.reduce(
(acc, ev) => {
if (acc.seen.has(ev.pubkey)) {
return acc;
}
acc.seen.add(ev.pubkey);
acc.result.push(ev);
return acc;
},
{
seen: new Set([]) as Set<string>,
result: [] as T[],
},
).result;
}
export function dedupe<T>(evs: T[]): T[] {
return evs.reduce(
(acc, ev) => {
if (acc.seen.has(ev)) {
return acc;
}
acc.seen.add(ev);
acc.result.push(ev);
return acc;
},
{
seen: new Set([]) as Set<T>,
result: [] as T[],
},
).result;
}
export function parseJSON<T>(raw: string, def: T) {
try {
return JSON.parse(raw);
} catch (e) {
return def;
}
}
interface MyObject {
[key: string]: any;
}
export function hashSha256(obj: MyObject): string {
const jsonString = JSON.stringify(obj);
const hashBuffer = sha256(new TextEncoder().encode(jsonString));
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
return hashHex;
}

71
src/utils/nip05.ts Normal file
View File

@ -0,0 +1,71 @@
export const NIP05_REGEX = /^(?:([\w.+-]+)@)?([\w.-]+)$/;
export async function getNip05For(
fullname: string,
_fetch: typeof fetch = fetch,
fetchOpts: RequestInit = {}
) {
const match = fullname.match(NIP05_REGEX);
if (!match) return null;
const [_, name = "_", domain] = match;
const res = await _fetch(
`https://${domain}/.well-known/nostr.json?name=${name}`,
fetchOpts
);
const { names, relays, nip46 } = parseNIP05Result(await res.json());
const pubkey = names[name];
return pubkey
? {
pubkey,
relays: relays?.[pubkey],
nip46: nip46?.[pubkey],
}
: null;
}
export interface NIP05Result {
names: {
[name: string]: string;
};
relays?: { [pubkey: string]: string[] };
nip46?: { [pubkey: string]: string[] };
}
function parseNIP05Result(json: any): NIP05Result {
const result: NIP05Result = {
names: {},
};
for (const [name, pubkey] of Object.entries(json.names)) {
if (typeof name === "string" && typeof pubkey === "string") {
result.names[name] = pubkey;
}
}
if (json.relays) {
result.relays = {};
for (const [pubkey, relays] of Object.entries(json.relays)) {
if (typeof pubkey === "string" && Array.isArray(relays)) {
result.relays[pubkey] = relays.filter(
(relay: unknown) => typeof relay === "string"
);
}
}
}
if (json.nip46) {
result.nip46 = {};
for (const [pubkey, nip46] of Object.entries(json.nip46)) {
if (typeof pubkey === "string" && Array.isArray(nip46)) {
result.nip46[pubkey] = nip46.filter((relay: unknown) => typeof relay === "string");
}
}
}
return result;
}

View File

@ -1,16 +1,22 @@
import { appName } from '../components/env';
import { useNDK } from '@nostr-dev-kit/ndk-react';
import { useEffect, useState } from 'react';
import { Settings } from './useNav';
import { NostrImage } from '@/components/nostrImageDownload';
import { NostrImage } from '../components/nostrImageDownload';
import useProfileNgine from '../ngine/hooks/useProfile';
import { nip19 } from 'nostr-tools';
import { NDKSubscriptionCacheUsage } from '@nostr-dev-kit/ndk';
// TODO maybe remove profile and only build title here?? useTitle?
const useProfile = (settings: Settings, activeImage?: NostrImage) => {
const { getProfile } = useNDK();
const [title, setTitle] = useState(appName);
const profileNpub = settings.npubs.length == 1 ? settings.npubs[0] : activeImage && activeImage?.author;
const activeProfile = profileNpub && getProfile(profileNpub);
const pubKeyHex = profileNpub ? (nip19.decode(profileNpub).data as string) : '';
const activeProfile = useProfileNgine(pubKeyHex, NDKSubscriptionCacheUsage.ONLY_RELAY);
// console.log({profileNpub, pubKeyHex, activeProfile})
useEffect(() => {
if (settings.npubs.length > 0 && activeProfile && (activeProfile.displayName || activeProfile.name)) {

View File

@ -1,6 +1,6 @@
import { useNDK } from '../ngine/context';
import { NostrImage } from '../components/nostrImageDownload';
import { NDKEvent, NDKFilter } from '@nostr-dev-kit/ndk';
import { useNDK } from '@nostr-dev-kit/ndk-react';
import { nip19 } from 'nostr-tools';
import { useEffect, useState } from 'react';
@ -8,7 +8,7 @@ export type HeartState = 'none' | 'liked' | 'liking';
export type ZapState = 'none' | 'zapped' | 'zapping' | 'error';
const useZapsAndReations = (currentImageData?: NostrImage, userNPub?: string) => {
const { ndk } = useNDK();
const ndk = useNDK();
const [zapState, setZapState] = useState<ZapState>('none');
const [heartState, setHeartState] = useState<HeartState>('none');

View File

@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"target": "esnext",
"lib": ["dom", "dom.iterable", "ESNext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@ -20,4 +20,4 @@
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
}

File diff suppressed because one or more lines are too long