feat: Added likes, zaps and login
This commit is contained in:
parent
1cf45ee7d0
commit
de6febd957
13
src/App.tsx
13
src/App.tsx
@ -5,15 +5,10 @@ import useDisclaimerState from './utils/useDisclaimerState';
|
||||
import useNav from './utils/useNav';
|
||||
import { useEffect } from 'react';
|
||||
import { defaultHashTags } from './components/env';
|
||||
import { useNDK } from '@nostr-dev-kit/ndk-react';
|
||||
import { useGlobalState } from './utils/globalState';
|
||||
|
||||
const App = () => {
|
||||
const { disclaimerAccepted, setDisclaimerAccepted } = useDisclaimerState();
|
||||
const { nav, currentSettings } = useNav();
|
||||
const { loginWithNip07, ndk } = useNDK();
|
||||
|
||||
const [state, setState] = useGlobalState();
|
||||
|
||||
useEffect(() => {
|
||||
if (currentSettings.npubs.length == 0 && currentSettings.tags.length == 0) {
|
||||
@ -21,16 +16,8 @@ const App = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onLogin = async () => {
|
||||
const result = await loginWithNip07();
|
||||
console.log(result);
|
||||
result && setState({ userNPub: result.npub });
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{JSON.stringify(ndk?.signer)}
|
||||
{state.userNPub }
|
||||
<button onClick={onLogin}>Login</button>
|
||||
{disclaimerAccepted ? (
|
||||
<SlideShow />
|
||||
) : (
|
||||
|
@ -11,7 +11,7 @@ const AdultContentInfo = () => {
|
||||
};
|
||||
const goBack = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
nav({...currentSettings, npubs: [], tags: [], showAdult: false });
|
||||
nav({ ...currentSettings, npubs: [], tags: [], showAdult: false });
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -23,9 +23,9 @@ const Disclaimer = ({ disclaimerAccepted, setDisclaimerAccepted }: DisclaimerPro
|
||||
<br />
|
||||
The content presented on this site is <b>entirely user-generated</b> and remains <b>unmoderated</b>. Images and
|
||||
videos are sourced from the NOSTR platform and are not hosted on this site. Content filtering efforts are made
|
||||
to avoid adult content, but we cannot guarantee complete safety. Please use discretion and be
|
||||
responsible while engaging with the material on this platform. By using this site, you agree not to hold the
|
||||
site owners, operators, and affiliates liable for any content-related experiences.
|
||||
to avoid adult content, but we cannot guarantee complete safety. Please use discretion and be responsible while
|
||||
engaging with the material on this platform. By using this site, you agree not to hold the site owners,
|
||||
operators, and affiliates liable for any content-related experiences.
|
||||
</div>
|
||||
<div className="disclaimer-footer">
|
||||
<button type="submit" className="btn btn-primary" onClick={onSubmit}>
|
||||
|
@ -1,4 +1,3 @@
|
||||
|
||||
@keyframes bump {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
@ -11,7 +10,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: scaleX(1);
|
||||
@ -24,7 +22,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.details {
|
||||
/*
|
||||
position: fixed;
|
||||
@ -78,35 +75,53 @@
|
||||
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;
|
||||
fill: #ff0000;
|
||||
}
|
||||
|
||||
.details-contents .zap svg {
|
||||
.details-actions > div {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.details-actions .heart.liking svg {
|
||||
animation: rotate 2s ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
.details-actions .heart.liked svg {
|
||||
animation: bump 1s ease-in-out;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.details-actions .zap svg {
|
||||
padding: 0.5em;
|
||||
width: 1.5em;
|
||||
height: 1.5em;
|
||||
cursor: pointer;
|
||||
fill: rgb(228, 185, 104);
|
||||
}
|
||||
|
||||
.details-contents .zap.zapped svg {
|
||||
.details-actions .zap.zapped svg {
|
||||
fill: orange;
|
||||
animation: bump 1s ease-in-out
|
||||
animation: bump 1s ease-in-out;
|
||||
}
|
||||
|
||||
.details-contents .zap.error {
|
||||
.details-actions .zap.error {
|
||||
fill: red;
|
||||
}
|
||||
|
||||
.details-contents .zap.zapping svg {
|
||||
.details-actions .zap.zapping svg {
|
||||
animation: rotate 2s ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { NostrImage } from '../nostrImageDownload';
|
||||
import { NostrImage, createImgProxyUrl } from '../nostrImageDownload';
|
||||
import './DetailsView.css';
|
||||
import { useNDK } from '@nostr-dev-kit/ndk-react';
|
||||
import DetailsAuthor from './DetailsAuthor';
|
||||
@ -19,12 +19,12 @@ type DetailsViewProps = {
|
||||
};
|
||||
|
||||
type ZapState = 'none' | 'zapped' | 'zapping' | 'error';
|
||||
type HeartState = 'none' | 'liked' | 'liking';
|
||||
|
||||
const DetailsView = ({ images, activeImageIdx, setActiveImageIdx }: DetailsViewProps) => {
|
||||
const { getProfile, ndk } = useNDK();
|
||||
const [selfLiked, setSelfLiked] = useState(false);
|
||||
const [zapState, setZapState] = useState<ZapState>('none');
|
||||
|
||||
const [heartState, setHeartState] = useState<HeartState>('none');
|
||||
const [state, setState] = useGlobalState();
|
||||
const currentImage = useMemo(
|
||||
() => (activeImageIdx !== undefined ? images[activeImageIdx] : undefined),
|
||||
@ -33,23 +33,35 @@ const DetailsView = ({ images, activeImageIdx, setActiveImageIdx }: DetailsViewP
|
||||
const activeProfile = currentImage?.author !== undefined ? getProfile(currentImage?.author) : undefined;
|
||||
const { nav, currentSettings } = useNav();
|
||||
|
||||
const fetchLikeAndZaps = async (noteIds: string[], selfNPub: string) => {
|
||||
const filter: NDKFilter = { kinds: [Kind.Reaction], '#e': noteIds };
|
||||
|
||||
filter.authors = [nip19.decode(selfNPub).data as string];
|
||||
|
||||
const events = await ndk?.fetchEvents(filter);
|
||||
|
||||
return { selfLiked: events && events.size > 0 };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSelfLiked(false);
|
||||
setZapState("none");
|
||||
setZapState('none');
|
||||
setHeartState('none');
|
||||
|
||||
if (!currentImage?.noteId || !state.userNPub) return;
|
||||
|
||||
const filter: NDKFilter = { kinds: [Kind.Reaction], '#e': [currentImage?.noteId] };
|
||||
if (currentImage.post.wasLiked !== undefined) {
|
||||
setHeartState(currentImage.post.wasLiked ? 'liked' : 'none');
|
||||
return;
|
||||
}
|
||||
|
||||
filter.authors = [nip19.decode(state.userNPub).data as string];
|
||||
|
||||
currentImage?.noteId &&
|
||||
ndk?.fetchEvents(filter).then(events => {
|
||||
setSelfLiked(events.size > 0);
|
||||
});
|
||||
}, [currentImage?.event.id]);
|
||||
fetchLikeAndZaps([currentImage.noteId], state.userNPub).then(likes => {
|
||||
currentImage.post.wasLiked = likes.selfLiked;
|
||||
setHeartState(likes.selfLiked ? 'liked' : 'none');
|
||||
});
|
||||
}, [currentImage?.post.event.id]);
|
||||
|
||||
const heartClick = async (currentImage: NostrImage) => {
|
||||
setHeartState('liking');
|
||||
console.log('heartClick');
|
||||
if (!state.userNPub) return;
|
||||
|
||||
@ -65,7 +77,8 @@ const DetailsView = ({ images, activeImageIdx, setActiveImageIdx }: DetailsViewP
|
||||
});
|
||||
console.log(ev);
|
||||
await ev.publish();
|
||||
setSelfLiked(true);
|
||||
setHeartState('liked');
|
||||
currentImage.post.wasLiked = true;
|
||||
};
|
||||
|
||||
const zapClick = async (currentImage: NostrImage) => {
|
||||
@ -102,9 +115,11 @@ const DetailsView = ({ images, activeImageIdx, setActiveImageIdx }: DetailsViewP
|
||||
await window.webln.sendPayment(invoice);
|
||||
|
||||
setZapState('zapped');
|
||||
|
||||
currentImage.post.wasZapped = true;
|
||||
};
|
||||
|
||||
if (!currentImage) return null;
|
||||
|
||||
return (
|
||||
<div className="details">
|
||||
<CloseButton onClick={() => setActiveImageIdx(undefined)}></CloseButton>
|
||||
@ -119,14 +134,16 @@ const DetailsView = ({ images, activeImageIdx, setActiveImageIdx }: DetailsViewP
|
||||
|
||||
<div>{currentImage?.content}</div>
|
||||
{state.userNPub && (
|
||||
<>
|
||||
<div className="heart" onClick={() => currentImage && heartClick(currentImage)}>
|
||||
<IconHeart filled={selfLiked}></IconHeart>
|
||||
<div className="details-actions">
|
||||
<div className={`heart ${heartState}`} onClick={() => currentImage && heartClick(currentImage)}>
|
||||
<IconHeart filled={heartState == 'liked'}></IconHeart>
|
||||
</div>
|
||||
<div className={`zap ${zapState}`} onClick={() => currentImage && zapClick(currentImage)}>
|
||||
<IconBolt></IconBolt>
|
||||
</div>
|
||||
</>
|
||||
{(activeProfile?.lud06 || activeProfile?.lud16) && (
|
||||
<div className={`zap ${zapState}`} onClick={() => currentImage && zapClick(currentImage)}>
|
||||
<IconBolt></IconBolt>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{uniq(currentImage?.tags).map(t => (
|
||||
|
@ -13,4 +13,4 @@ const IconHeart = ({ filled }: { filled: boolean }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default IconHeart;
|
||||
export default IconHeart;
|
||||
|
@ -89,8 +89,8 @@
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
|
||||
.settings .replies, .settings .reposts {
|
||||
.settings .replies,
|
||||
.settings .reposts {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
@ -127,8 +127,16 @@
|
||||
}
|
||||
|
||||
.controls button {
|
||||
background-color: transparent;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
padding: 0.5em;
|
||||
vertical-align: middle;
|
||||
border-radius: 12px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.controls button.login {
|
||||
background-color: white;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.controls button:hover {
|
||||
@ -136,10 +144,23 @@
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.controls button.login:hover {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.controls button:hover svg {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.controls img.profile {
|
||||
width: 3em;
|
||||
height: 3em;
|
||||
border-radius: 12px;
|
||||
margin-left: 0.5em;
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bottomPanel {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
isReply,
|
||||
isVideo,
|
||||
prepareContent,
|
||||
Post,
|
||||
} from './nostrImageDownload';
|
||||
import { blockedPublicKeys, adultContentTags, adultNPubs } from './env';
|
||||
import Settings from './Settings';
|
||||
@ -24,9 +25,14 @@ import IconPlay from './Icons/IconPlay';
|
||||
import IconGrid from './Icons/IconGrid';
|
||||
import useNav from '../utils/useNav';
|
||||
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
||||
import { useGlobalState } from '../utils/globalState';
|
||||
|
||||
/*
|
||||
FEATURES:
|
||||
- Improve login (show login dialog, show login status)
|
||||
- Detect if user/post does not have zap capability and show warning
|
||||
- Retrieve reactions (likes, zaps) for all posts iteratively (pagination)
|
||||
- Store posts separately from image urls to track likes/zaps per post
|
||||
- improve mobile support
|
||||
- widescreen mobile details view should be 2 columns
|
||||
- improve large grid performance by adding images on scroll
|
||||
@ -52,20 +58,21 @@ FEATURES:
|
||||
*/
|
||||
|
||||
const SlideShow = () => {
|
||||
const { ndk } = useNDK();
|
||||
const [posts, setPosts] = useState<NDKEvent[]>([]);
|
||||
const { ndk, loginWithNip07, getProfile } = useNDK();
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const images = useRef<NostrImage[]>([]);
|
||||
const fetchTimeoutHandle = useRef(0);
|
||||
const [showGrid, setShowGrid] = useState(false);
|
||||
const [showGrid, setShowGrid] = useState(true);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const { currentSettings: settings } = useNav();
|
||||
const [state, setState] = useGlobalState();
|
||||
|
||||
const fetch = () => {
|
||||
if (!ndk) {
|
||||
console.error('NDK not available.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const postSubscription = ndk.subscribe(buildFilter(settings.tags, settings.npubs, settings.showReposts));
|
||||
|
||||
postSubscription.on('event', (event: NDKEvent) => {
|
||||
@ -73,7 +80,7 @@ const SlideShow = () => {
|
||||
//event.isReply = isReply(event);
|
||||
|
||||
if (event.kind === 1063) {
|
||||
const urlTag = event?.tags?.find(t => t[0]=='url')
|
||||
const urlTag = event?.tags?.find(t => t[0] == 'url');
|
||||
if (urlTag) {
|
||||
event.content = urlTag[1];
|
||||
}
|
||||
@ -94,10 +101,10 @@ const SlideShow = () => {
|
||||
if (
|
||||
!blockedPublicKeys.includes(event.pubkey.toLowerCase()) && // remove blocked authors
|
||||
(settings.showReplies || !isReply(event)) &&
|
||||
oldPosts.findIndex(p => p.id === event.id) === -1 && // not duplicate
|
||||
oldPosts.findIndex(p => p.event.id === event.id) === -1 && // not duplicate
|
||||
(settings.showAdult || !isAdultRelated(event))
|
||||
) {
|
||||
return [...oldPosts, event];
|
||||
return [...oldPosts, { event }];
|
||||
}
|
||||
return oldPosts;
|
||||
});
|
||||
@ -125,18 +132,18 @@ const SlideShow = () => {
|
||||
useEffect(() => {
|
||||
images.current = uniqBy(
|
||||
posts.flatMap(p => {
|
||||
return extractImageUrls(p.content)
|
||||
return extractImageUrls(p.event.content)
|
||||
.filter(url => isImage(url) || isVideo(url))
|
||||
.map(url => ({
|
||||
event: p,
|
||||
post: p,
|
||||
url,
|
||||
author: nip19.npubEncode(p.pubkey),
|
||||
authorId: p.pubkey,
|
||||
content: prepareContent(p.content),
|
||||
author: nip19.npubEncode(p.event.pubkey),
|
||||
authorId: p.event.pubkey,
|
||||
content: prepareContent(p.event.content),
|
||||
type: isVideo(url) ? 'video' : 'image',
|
||||
timestamp: p.created_at,
|
||||
noteId: p.id || '',
|
||||
tags: p.tags?.filter((t: string[]) => t[0] === 't').map((t: string[]) => t[1].toLowerCase()) || [],
|
||||
timestamp: p.event.created_at,
|
||||
noteId: p.event.id || '',
|
||||
tags: p.event.tags?.filter((t: string[]) => t[0] === 't').map((t: string[]) => t[1].toLowerCase()) || [],
|
||||
}));
|
||||
}),
|
||||
'url'
|
||||
@ -179,6 +186,23 @@ const SlideShow = () => {
|
||||
return <AdultContentInfo></AdultContentInfo>;
|
||||
}
|
||||
|
||||
const onLogin = async () => {
|
||||
const result = await loginWithNip07();
|
||||
if (!result) {
|
||||
console.error('Login failed.');
|
||||
return;
|
||||
}
|
||||
|
||||
setState({ userNPub: result.npub });
|
||||
console.log(result.npub);
|
||||
};
|
||||
|
||||
const onLogout = () => {
|
||||
setState({ userNPub: undefined, profile: undefined });
|
||||
};
|
||||
|
||||
const currentUserProfile = state.userNPub && getProfile(state.userNPub);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showSettings && <Settings onClose={() => setShowSettings(false)}></Settings>}
|
||||
@ -197,8 +221,15 @@ const SlideShow = () => {
|
||||
<IconFullScreen />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state.userNPub && currentUserProfile ? (
|
||||
<img className="profile" onClick={onLogout} src={currentUserProfile.image} />
|
||||
) : (
|
||||
<button onClick={onLogin} className="btn btn-primary login">
|
||||
Login
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showGrid ? (
|
||||
<GridView images={images.current} settings={settings}></GridView>
|
||||
) : (
|
||||
|
@ -119,6 +119,7 @@ const SlideView = ({ settings, images, setShowGrid }: SlideViewProps) => {
|
||||
}
|
||||
if (event.key === 'p' || event.key === ' ' || event.key === 'P') {
|
||||
setPaused(p => !p);
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -51,7 +51,6 @@ export const visibleHashTags = [
|
||||
'travel',
|
||||
];
|
||||
|
||||
|
||||
/* All posts with the following hashtags are flagged as adult / NSFW are not shown
|
||||
by default. Users can enable this content through the adult content flag
|
||||
in the UI or through a URL parameter.
|
||||
|
@ -2,6 +2,12 @@ import { NDKEvent, NDKFilter, NDKTag } from '@nostr-dev-kit/ndk';
|
||||
import { Kind, nip19 } from 'nostr-tools';
|
||||
import { adultContentTags, adultPublicKeys } from './env';
|
||||
|
||||
export type Post = {
|
||||
event: NDKEvent;
|
||||
wasZapped?: boolean;
|
||||
wasLiked?: boolean;
|
||||
};
|
||||
|
||||
export type NostrImage = {
|
||||
url: string;
|
||||
author: string;
|
||||
@ -11,11 +17,9 @@ export type NostrImage = {
|
||||
timestamp?: number;
|
||||
noteId: string;
|
||||
type: 'image' | 'video';
|
||||
event: NDKEvent;
|
||||
post: Post;
|
||||
};
|
||||
|
||||
|
||||
|
||||
export const buildFilter = (tags: string[], npubs: string[], withReposts = false) => {
|
||||
const filter: NDKFilter = {
|
||||
kinds: [1, 1063] as Kind[],
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { NDKUserProfile } from '@nostr-dev-kit/ndk';
|
||||
import React, { createContext, useContext, useReducer } from 'react';
|
||||
|
||||
// Interface for our state
|
||||
interface GlobalState {
|
||||
userNPub?: string;
|
||||
profile?: NDKUserProfile;
|
||||
}
|
||||
const initialState: GlobalState = {
|
||||
userNPub: undefined,
|
||||
|
Loading…
Reference in New Issue
Block a user