feat: Added likes, zaps and login

This commit is contained in:
florian 2023-08-07 15:22:01 +02:00
parent 1cf45ee7d0
commit de6febd957
13 changed files with 148 additions and 71 deletions

View File

@ -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 />
) : (

View File

@ -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 (

View File

@ -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}>

View File

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

View File

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

View File

@ -13,4 +13,4 @@ const IconHeart = ({ filled }: { filled: boolean }) => {
);
};
export default IconHeart;
export default IconHeart;

View File

@ -89,8 +89,8 @@
font-size: 1.2rem;
}
.settings .replies, .settings .reposts {
.settings .replies,
.settings .reposts {
display: flex;
gap: 12px;
}

View File

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

View File

@ -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>
) : (

View File

@ -119,6 +119,7 @@ const SlideView = ({ settings, images, setShowGrid }: SlideViewProps) => {
}
if (event.key === 'p' || event.key === ' ' || event.key === 'P') {
setPaused(p => !p);
event.stopPropagation();
}
};

View File

@ -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.

View File

@ -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[],

View File

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