feat: Experimental version based on ngine (WIP)
This commit is contained in:
parent
bc8e118429
commit
3dd31135e1
14
TODO.md
14
TODO.md
@ -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
4211
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
42
src/components/Login/Login.css
Normal file
42
src/components/Login/Login.css
Normal 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;
|
||||
}
|
82
src/components/Login/Login.tsx
Normal file
82
src/components/Login/Login.tsx
Normal 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;
|
@ -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 (
|
||||
<>
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -61,6 +61,10 @@ const SlideView = ({ settings, images, setViewMode }: SlideViewProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const swipeHandlers = useSwipeable({
|
||||
onSwipedLeft: () => nextImage(),
|
||||
onSwipedRight: () => previousImage(),
|
||||
|
@ -39,7 +39,7 @@ export const buildFilter = (tags: string[], npubs: string[], withReposts = false
|
||||
}
|
||||
}
|
||||
|
||||
console.log('filter', filter);
|
||||
// console.log('filter', filter);
|
||||
return filter;
|
||||
};
|
||||
|
||||
|
@ -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
384
src/ngine/context.tsx
Normal 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
23
src/ngine/filter.ts
Normal 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
48
src/ngine/format.ts
Normal 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`;
|
||||
}
|
||||
}
|
20
src/ngine/hooks/useAddress.tsx
Normal file
20
src/ngine/hooks/useAddress.tsx
Normal 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;
|
||||
}
|
10
src/ngine/hooks/useAddresses.tsx
Normal file
10
src/ngine/hooks/useAddresses.tsx
Normal 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);
|
||||
}
|
11
src/ngine/hooks/useCopy.ts
Normal file
11
src/ngine/hooks/useCopy.ts
Normal 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;
|
||||
}
|
45
src/ngine/hooks/useEvent.ts
Normal file
45
src/ngine/hooks/useEvent.ts
Normal 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;
|
||||
}
|
44
src/ngine/hooks/useEvents.ts
Normal file
44
src/ngine/hooks/useEvents.ts
Normal 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 };
|
||||
}
|
41
src/ngine/hooks/useLatestEvent.tsx
Normal file
41
src/ngine/hooks/useLatestEvent.tsx
Normal 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;
|
||||
}
|
22
src/ngine/hooks/useProfile.ts
Normal file
22
src/ngine/hooks/useProfile.ts
Normal 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;
|
||||
}
|
31
src/ngine/hooks/useProfiles.ts
Normal file
31
src/ngine/hooks/useProfiles.ts
Normal 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;
|
||||
}
|
45
src/ngine/hooks/useRates.ts
Normal file
45
src/ngine/hooks/useRates.ts
Normal 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;
|
||||
}
|
83
src/ngine/hooks/useReactions.ts
Normal file
83
src/ngine/hooks/useReactions.ts
Normal 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
158
src/ngine/lnurl.ts
Normal 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
11
src/ngine/money.ts
Normal 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
10
src/ngine/nostr/kinds.tsx
Normal 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
172
src/ngine/nostr/nip57.ts
Normal 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
68
src/ngine/state.ts
Normal 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
5
src/ngine/tags.ts
Normal 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
3
src/ngine/time.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export function unixNow() {
|
||||
return Math.round(Date.now() / 1000);
|
||||
}
|
83
src/ngine/types.ts
Normal file
83
src/ngine/types.ts
Normal 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
63
src/ngine/utils.ts
Normal 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
71
src/utils/nip05.ts
Normal 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;
|
||||
}
|
@ -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)) {
|
||||
|
@ -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');
|
||||
|
@ -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,
|
||||
|
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue
Block a user