From bf8ffd8a90fb79dd1083efe393b3fdde548ddbe2 Mon Sep 17 00:00:00 2001 From: Adeyemi Gbenga Date: Fri, 18 Oct 2024 14:29:41 +0100 Subject: [PATCH 1/4] Video feed (#192) * feat: added video upload and fix tags page issue * disable submit when uploading video * fix: moved upload to nextjs server side * fix: removed #hashtag clicking as older notes dont have tags which might need to blank page * fix: added extra check when creating note and modified Minivideo Player by adding preview --- apps/mobile/.env.example | 8 +- apps/mobile/src/assets/icons.tsx | 19 +- apps/mobile/src/components/Loading/index.tsx | 66 ++- .../VideoPlayer/MiniVideoPlayer.tsx | 189 +++++++++ .../src/components/VideoPlayer/index.tsx | 54 +++ apps/mobile/src/hooks/api/useFileUpload.ts | 57 +++ apps/mobile/src/modules/Post/index.tsx | 140 +++---- apps/mobile/src/modules/Post/styles.ts | 9 +- apps/mobile/src/modules/PostCard/index.tsx | 4 +- apps/mobile/src/modules/PostCard/styles.ts | 1 - .../src/modules/VideoPostCard/VideoPost.tsx | 393 ++++++++++++++++++ .../src/modules/VideoPostCard/index.tsx | 67 +++ .../src/modules/VideoPostCard/styles.ts | 120 ++++++ .../src/screens/CreatePost/FormPost/index.tsx | 176 ++++++-- .../src/screens/CreatePost/FormPost/styles.ts | 7 + apps/mobile/src/screens/Feed/index.tsx | 7 + apps/mobile/src/screens/Tags/index.tsx | 130 +++--- apps/mobile/src/screens/Tags/styles.ts | 5 +- packages/afk_nostr_sdk/src/hooks/index.ts | 2 + .../src/hooks/search/useTagSearch.tsx | 42 ++ .../afk_nostr_sdk/src/hooks/useSendVideo.ts | 87 ++++ 21 files changed, 1414 insertions(+), 169 deletions(-) create mode 100644 apps/mobile/src/components/VideoPlayer/MiniVideoPlayer.tsx create mode 100644 apps/mobile/src/components/VideoPlayer/index.tsx create mode 100644 apps/mobile/src/modules/VideoPostCard/VideoPost.tsx create mode 100644 apps/mobile/src/modules/VideoPostCard/index.tsx create mode 100644 apps/mobile/src/modules/VideoPostCard/styles.ts create mode 100644 packages/afk_nostr_sdk/src/hooks/search/useTagSearch.tsx create mode 100644 packages/afk_nostr_sdk/src/hooks/useSendVideo.ts diff --git a/apps/mobile/.env.example b/apps/mobile/.env.example index 56cacea1..10e05a19 100644 --- a/apps/mobile/.env.example +++ b/apps/mobile/.env.example @@ -30,4 +30,10 @@ EXPO_PUBLIC_EKUBO_API="https://mainnet-api.ekubo.org" EXPO_PUBLIC_EKUBO_ROUTE_API="https://quoter-mainnet-api.ekubo.org" # AVNU API -EXPO_PUBLIC_AVNU_API="https://starknet.api.avnu.fi" \ No newline at end of file +EXPO_PUBLIC_AVNU_API="https://starknet.api.avnu.fi" + +#PINATA +EXPO_PUBLIC_PINATA_JWT="" +EXPO_PUBLIC_PINATA_UPLOAD_GATEWAY_URL='' +EXPO_PUBLIC_PINATA_PINATA_SIGN_URL='https://api.pinata.cloud/v3/files/sign' +EXPO_PUBLIC_PINATA_UPLOAD_URL='https://uploads.pinata.cloud/v3/files' diff --git a/apps/mobile/src/assets/icons.tsx b/apps/mobile/src/assets/icons.tsx index 09835200..dee0c048 100644 --- a/apps/mobile/src/assets/icons.tsx +++ b/apps/mobile/src/assets/icons.tsx @@ -345,7 +345,24 @@ export const GalleryIcon: React.FC = (props) => { ); }; - +export const VideoIcon: React.FC = (props) => { + return ( + + + + + ); +}; export const GifIcon: React.FC = (props) => { return ( diff --git a/apps/mobile/src/components/Loading/index.tsx b/apps/mobile/src/components/Loading/index.tsx index 75759c94..4fc28f11 100644 --- a/apps/mobile/src/components/Loading/index.tsx +++ b/apps/mobile/src/components/Loading/index.tsx @@ -1,5 +1,17 @@ -import React from 'react'; -import {View, ActivityIndicator, Text, StyleSheet} from 'react-native'; +import React, {useEffect} from 'react'; +import {ActivityIndicator, Easing, StyleSheet, View} from 'react-native'; +import Animated, { + cancelAnimation, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated'; + +interface LoadingAnimationProps { + size?: number; + color?: string; +} const Loading = () => { return ( @@ -19,3 +31,53 @@ const styles = StyleSheet.create({ }); export default Loading; + +interface ThemedLoadingSpinnerProps { + size?: number; + color?: string; + borderWidth?: number; +} + +export const LoadingSpinner: React.FC = ({ + size = 22, + color = 'white', + borderWidth = 3, +}) => { + const rotation = useSharedValue(0); + + const animatedStyles = useAnimatedStyle(() => { + return { + transform: [ + { + rotateZ: `${rotation.value}deg`, + }, + ], + }; + }, [rotation.value]); + + const styles = StyleSheet.create({ + spinner: { + height: size, + width: size, + borderRadius: size / 2, + borderWidth, + borderTopColor: 'transparent', + borderRightColor: 'transparent', + borderBottomColor: 'transparent', + borderLeftColor: color, + }, + }); + + useEffect(() => { + rotation.value = withRepeat( + withTiming(360, { + duration: 1000, + easing: Easing.linear, + }), + 200, + ); + return () => cancelAnimation(rotation); + }, []); + + return ; +}; diff --git a/apps/mobile/src/components/VideoPlayer/MiniVideoPlayer.tsx b/apps/mobile/src/components/VideoPlayer/MiniVideoPlayer.tsx new file mode 100644 index 00000000..6124bf83 --- /dev/null +++ b/apps/mobile/src/components/VideoPlayer/MiniVideoPlayer.tsx @@ -0,0 +1,189 @@ +import {Ionicons} from '@expo/vector-icons'; +import {AVPlaybackStatus, ResizeMode, Video} from 'expo-av'; +import React, {useEffect, useRef, useState} from 'react'; +import {Modal, Pressable, SafeAreaView, StyleSheet, View, ViewStyle} from 'react-native'; + +interface VideoPlayerProps { + uri: string; + customStyle?: ViewStyle; +} + +export default function MiniVideoPlayer({uri, customStyle}: VideoPlayerProps) { + const miniVideoRef = useRef setMenuOpen(false)} - handle={ - setMenuOpen(true)} /> - } - > - - { - if (!event) return; - - showTipModal(event); - setMenuOpen(false); - }} - /> - )} diff --git a/apps/mobile/src/modules/Post/styles.ts b/apps/mobile/src/modules/Post/styles.ts index c48b7cc6..f6cbabd2 100644 --- a/apps/mobile/src/modules/Post/styles.ts +++ b/apps/mobile/src/modules/Post/styles.ts @@ -68,7 +68,14 @@ export default ThemedStyleSheet((theme) => ({ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - // overflowX: 'scroll', + }, + hashTagsContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 4, + }, + footerContent: { + gap: 10, }, footerComments: { flexDirection: 'row', diff --git a/apps/mobile/src/modules/PostCard/index.tsx b/apps/mobile/src/modules/PostCard/index.tsx index 4644265a..13687b39 100644 --- a/apps/mobile/src/modules/PostCard/index.tsx +++ b/apps/mobile/src/modules/PostCard/index.tsx @@ -43,9 +43,7 @@ const ClickableHashtag = ({hashtag, onPress}: any) => { const styles = useStyles(stylesheet); return ( - onPress(hashtag)}> - {hashtag} - + {hashtag} ); }; diff --git a/apps/mobile/src/modules/PostCard/styles.ts b/apps/mobile/src/modules/PostCard/styles.ts index 9d3cad42..4b643aca 100644 --- a/apps/mobile/src/modules/PostCard/styles.ts +++ b/apps/mobile/src/modules/PostCard/styles.ts @@ -10,6 +10,5 @@ export default ThemedStyleSheet((theme) => ({ }, hashtagColor: { color: theme.colors.primary, - textDecorationLine: 'underline', }, })); diff --git a/apps/mobile/src/modules/VideoPostCard/VideoPost.tsx b/apps/mobile/src/modules/VideoPostCard/VideoPost.tsx new file mode 100644 index 00000000..3a56976e --- /dev/null +++ b/apps/mobile/src/modules/VideoPostCard/VideoPost.tsx @@ -0,0 +1,393 @@ +import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; +import {useNavigation} from '@react-navigation/native'; +import {useQueryClient} from '@tanstack/react-query'; +import { + useBookmark, + useProfile, + useReact, + useReactions, + useReplyNotes, + useRepost, +} from 'afk_nostr_sdk'; +// import { useAuth } from '../../store/auth'; +import {useAuth} from 'afk_nostr_sdk'; +import {useMemo, useState} from 'react'; +import React from 'react'; +import {ActivityIndicator, Pressable, View} from 'react-native'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withSequence, + withSpring, + withTiming, +} from 'react-native-reanimated'; + +import {CommentIcon, LikeFillIcon, LikeIcon, RepostIcon} from '../../assets/icons'; +import {Avatar, Icon, IconButton, Menu, Text} from '../../components'; +import Badge from '../../components/Badge'; +import MiniVideoPlayer from '../../components/VideoPlayer/MiniVideoPlayer'; +import {useNostrAuth, useStyles, useTheme} from '../../hooks'; +import {useTipModal, useToast} from '../../hooks/modals'; +import {MainStackNavigationProps} from '../../types'; +import {removeHashFn, shortenPubkey} from '../../utils/helpers'; +import {getElapsedTimeStringFull} from '../../utils/timestamp'; +import {ContentWithClickableHashtags} from '../PostCard'; +import stylesheet from './styles'; + +export type PostProps = { + asComment?: boolean; + event?: NDKEvent; + repostedEventProps?: string; + isRepost?: boolean; + isBookmarked?: boolean; +}; + +export const VideoPost: React.FC = ({ + asComment, + event, + repostedEventProps, + isRepost, + isBookmarked = false, +}) => { + const repostedEvent = repostedEventProps ?? undefined; + + const {theme} = useTheme(); + const styles = useStyles(stylesheet); + const {showToast} = useToast(); + + const navigation = useNavigation(); + + const {publicKey} = useAuth(); + const {show: showTipModal} = useTipModal(); + const {data: profile} = useProfile({publicKey: event?.pubkey}); + const reactions = useReactions({noteId: event?.id}); + const userReaction = useReactions({authors: [publicKey], noteId: event?.id}); + const comments = useReplyNotes({noteId: event?.id}); + const react = useReact(); + const queryClient = useQueryClient(); + const repostMutation = useRepost({event}); + const {bookmarkNote, removeBookmark} = useBookmark(publicKey); + const [noteBookmarked, setNoteBookmarked] = useState(isBookmarked); + const {handleCheckNostrAndSendConnectDialog} = useNostrAuth(); + + const [menuOpen, setMenuOpen] = useState(false); + + const scale = useSharedValue(1); + const [isContentExpanded, setIsContentExpanded] = useState(false); + + const toggleExpandedContent = () => { + setIsContentExpanded((prev) => !prev); + }; + + const isLiked = useMemo( + () => + Array.isArray(userReaction.data) && + userReaction.data[0] && + userReaction.data[0]?.content !== '-', + [userReaction.data], + ); + + const likes = useMemo(() => { + if (!reactions.data) return 0; + + const likesCount = reactions.data.filter((reaction) => reaction.content !== '-').length; + const dislikesCount = reactions.data.length - likesCount; + return likesCount - dislikesCount; + }, [reactions.data]); + + const hashTags = useMemo(() => { + return event?.tags?.filter((tag) => tag[0] === 't').map((tag) => tag[1]) || []; + }, [event?.tags]); + + //Video MetaData + const videoMetadata = useMemo(() => { + const imetaTag = event?.tags?.find((tag) => tag[0] === 'imeta') || []; + + const dimensions = imetaTag.find((item) => item.startsWith('dim'))?.split(' ')[1] || ''; + const url = imetaTag.find((item) => item.startsWith('url'))?.split(' ')[1] || ''; + const mimeType = imetaTag.find((item) => item.startsWith('m'))?.split(' ')[1] || ''; + const images = + imetaTag.filter((item) => item.startsWith('image')).map((item) => item.split(' ')[1]) || []; + const fallbacks = + imetaTag.filter((item) => item.startsWith('fallback')).map((item) => item.split(' ')[1]) || + []; + const service = imetaTag.find((item) => item.startsWith('service'))?.split(' ')[1] || ''; + + return {dimensions, url, mimeType, images, fallbacks, service}; + }, [event?.tags]); + + const animatedIconStyle = useAnimatedStyle(() => ({ + transform: [{scale: scale.value}], + })); + + const handleProfilePress = (userId?: string) => { + if (userId) { + navigation.navigate('Profile', {publicKey: userId}); + } + }; + + const handleNavigateToPostDetails = () => { + if (!event?.id) return; + navigation.navigate('PostDetail', {postId: event?.id, post: event}); + }; + + const toggleLike = async () => { + if (!event?.id) return; + + await handleCheckNostrAndSendConnectDialog(); + + await react.mutateAsync( + {event, type: isLiked ? 'dislike' : 'like'}, + { + onSuccess: () => { + queryClient.invalidateQueries({queryKey: ['reactions', event?.id]}); + + scale.value = withSequence( + withTiming(1.5, {duration: 100, easing: Easing.out(Easing.ease)}), + withSpring(1, {damping: 6, stiffness: 200}), + ); + }, + }, + ); + }; + + const handleRepost = async () => { + if (!event) return; + try { + await handleCheckNostrAndSendConnectDialog(); + + await repostMutation.mutateAsync(); + showToast({title: 'Post reposted successfully', type: 'success'}); + } catch (error) { + console.error('Repost error:', error); + showToast({title: 'Failed to repost', type: 'error'}); + } + }; + + const handleBookmark = async () => { + if (!event) return; + try { + await handleCheckNostrAndSendConnectDialog(); + + if (noteBookmarked) { + await removeBookmark({eventId: event.id}); + showToast({title: 'Post removed from bookmarks', type: 'success'}); + } else { + await bookmarkNote({event}); + showToast({title: 'Post bookmarked successfully', type: 'success'}); + } + // Invalidate the queries to refetch data + queryClient.invalidateQueries({queryKey: ['search', {authors: [event.pubkey]}]}); + queryClient.invalidateQueries({queryKey: ['bookmarksWithNotes', event.pubkey]}); + setNoteBookmarked((prev) => !prev); + } catch (error) { + console.error('Bookmark error:', error); + showToast({title: 'Failed to bookmark', type: 'error'}); + } + }; + + const content = event?.content || ''; + const truncatedContent = content.length > 200 ? `${content.slice(0, 200)}...` : content; + + const handleHashtagPress = (hashtag: string) => { + const tag = removeHashFn(hashtag); + navigation.navigate('Tags', {tagName: tag}); + }; + + return ( + + {repostedEvent || + event?.kind == NDKKind.Repost || + (isRepost && ( + + + Reposted + + ))} + + + + handleProfilePress(event?.pubkey)}> + + + + + + {profile?.displayName ?? + profile?.name ?? + profile?.nip05 ?? + shortenPubkey(event?.pubkey)} + + + + {(profile?.nip05 || profile?.name) && ( + <> + + @{profile?.nip05 ?? profile.name} + + + + + )} + + + {getElapsedTimeStringFull((event?.created_at ?? Date.now()) * 1000)} + + + + + + + + + {isLiked ? ( + + ) : ( + + )} + + + {likes > 0 && ( + + {likes} {likes === 1 ? 'like' : 'likes'} + + )} + + + + + + + + + {content.length > 200 && ( + + {isContentExpanded ? 'See less' : 'See more...'} + + )} + + + {!content ? ( + + ) : ( + + )} + + + {!asComment && ( + <> + + {hashTags.map((hashTag, index) => ( + handleHashtagPress(hashTag)} key={index}> + + + ))} + + + + + + + + + + {comments.data?.pages.flat().length} comments + + + + + { + if (!event) return; + showTipModal(event); + }} + > + + + + + + {repostMutation.isPending && } + + + + + + + + + setMenuOpen(false)} + handle={ + setMenuOpen(true)} /> + } + > + + { + if (!event) return; + + showTipModal(event); + setMenuOpen(false); + }} + /> + + + + )} + + ); +}; diff --git a/apps/mobile/src/modules/VideoPostCard/index.tsx b/apps/mobile/src/modules/VideoPostCard/index.tsx new file mode 100644 index 00000000..832166dc --- /dev/null +++ b/apps/mobile/src/modules/VideoPostCard/index.tsx @@ -0,0 +1,67 @@ +import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; +import {useState} from 'react'; +import React from 'react'; +import {Pressable, View} from 'react-native'; + +import {Text} from '../../components'; +import {useStyles} from '../../hooks'; +import stylesheet from './styles'; +import {VideoPost} from './VideoPost'; + +export type PostCardProps = { + event?: NDKEvent; + isRepostProps?: boolean; + isBookmarked?: boolean; +}; +const hashtags = /\B#\w*[a-zA-Z]+\w*/g; + +export const VideoPostCard: React.FC = ({event, isRepostProps, isBookmarked}) => { + const styles = useStyles(stylesheet); + + let repostedEvent = undefined; + const [isRepost, setIsRepost] = useState( + (isRepostProps ?? event?.kind == NDKKind.Repost) ? true : false, + ); + + if (event?.kind == NDKKind.Repost) { + repostedEvent = JSON.stringify(event?.content); + } + return ( + + + + ); +}; + +const ClickableHashtag = ({hashtag, onPress}: any) => { + const styles = useStyles(stylesheet); + return ( + + {hashtag} + + ); +}; + +export const ContentWithClickableHashtags = ({content, onHashtagPress}: any) => { + const parts = content.split(hashtags); + const matches = content.match(hashtags); + + return ( + + {parts.map((part: string, index: number) => ( + + {part} + {matches && index < parts.length - 1 && ( + + )} + + ))} + + ); +}; diff --git a/apps/mobile/src/modules/VideoPostCard/styles.ts b/apps/mobile/src/modules/VideoPostCard/styles.ts new file mode 100644 index 00000000..e6188238 --- /dev/null +++ b/apps/mobile/src/modules/VideoPostCard/styles.ts @@ -0,0 +1,120 @@ +import {Spacing, ThemedStyleSheet} from '../../styles'; + +export default ThemedStyleSheet((theme) => ({ + card_container: { + backgroundColor: theme.colors.surface, + padding: Spacing.xsmall, + marginHorizontal: Spacing.medium, + marginBottom: Spacing.large, + borderRadius: 16, + }, + hashtagColor: { + color: theme.colors.primary, + }, + + //Video Card + container: {}, + + repost: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: Spacing.xxsmall, + }, + + info: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: Spacing.small, + }, + infoUser: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.small, + }, + infoProfile: { + flex: 1, + }, + infoDetails: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.small, + }, + infoDetailsDivider: { + width: 3, + height: 3, + borderRadius: 3, + backgroundColor: theme.colors.textLight, + }, + infoLikes: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + padding: Spacing.xxxsmall, + }, + + content: { + marginBottom: Spacing.medium, + color: theme.colors.text, + }, + // contentImage: { + // width: '100%', + // height: 'auto', + // resizeMode: 'cover', + // borderRadius: 8, + // overflow: 'hidden', + // marginTop: Spacing.small, + // }, + contentImage: { + width: '100%', + height: '100%', + // resizeMode: 'cover', + borderRadius: 8, + overflow: 'hidden', + marginTop: Spacing.small, + }, + + footer: { + width: '100%', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + hashTagsContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 4, + paddingTop: 5, + }, + footerContent: { + gap: 10, + }, + footerComments: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.xxxsmall, + }, + seeMore: { + color: theme.colors.primary, + fontSize: 13, + marginTop: Spacing.xsmall, + }, + + //Inner content + innerContainer: { + justifyContent: 'space-between', + flexDirection: 'row', + alignItems: 'flex-start', + flexWrap: 'wrap', + width: '100%', + }, + innerContentContainer: { + flex: 1, + }, + title: { + fontSize: 18, + fontWeight: 'bold', + marginBottom: 10, + }, +})); diff --git a/apps/mobile/src/screens/CreatePost/FormPost/index.tsx b/apps/mobile/src/screens/CreatePost/FormPost/index.tsx index 23b20f99..20a7fe52 100644 --- a/apps/mobile/src/screens/CreatePost/FormPost/index.tsx +++ b/apps/mobile/src/screens/CreatePost/FormPost/index.tsx @@ -1,20 +1,23 @@ import {useNavigation} from '@react-navigation/native'; import {useQueryClient} from '@tanstack/react-query'; -import {useSendNote} from 'afk_nostr_sdk'; +import {useSendNote, useSendVideoEvent} from 'afk_nostr_sdk'; import * as ImagePicker from 'expo-image-picker'; import {useRef, useState} from 'react'; +import React from 'react'; import {Image, KeyboardAvoidingView, Pressable, TextInput, View} from 'react-native'; import {SafeAreaView} from 'react-native-safe-area-context'; -import {GalleryIcon, SendIconContained} from '../../../assets/icons'; +import {GalleryIcon, SendIconContained, VideoIcon} from '../../../assets/icons'; +import {LoadingSpinner} from '../../../components/Loading'; +import VideoPlayer from '../../../components/VideoPlayer'; import {useNostrAuth, useStyles, useTheme} from '../../../hooks'; import {useFileUpload} from '../../../hooks/api'; +import {usePinataVideoUpload} from '../../../hooks/api/useFileUpload'; import {useToast} from '../../../hooks/modals'; import {MainStackNavigationProps} from '../../../types'; import {SelectedTab} from '../../../types/tab'; import {getImageRatio} from '../../../utils/helpers'; import stylesheet from './styles'; -// import {useSendNote} from "afk_nostr_sdk/hooks" export const FormCreatePost: React.FC = () => { const {theme} = useTheme(); @@ -29,6 +32,10 @@ export const FormCreatePost: React.FC = () => { const navigation = useNavigation(); const {handleCheckNostrAndSendConnectDialog} = useNostrAuth(); + const videoPinataUpload = usePinataVideoUpload(); + const sendVideoEvent = useSendVideoEvent(); + const [video, setVideo] = useState(); + const [tags, setTags] = useState([]); const inputRef = useRef(null); @@ -46,48 +53,116 @@ export const FormCreatePost: React.FC = () => { setImage(pickerResult.assets[0]); }; + const handleVideoSelect = async () => { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Videos, + allowsEditing: false, + selectionLimit: 1, + exif: false, + quality: 1, + }); + + if (!result.canceled) { + const asset = result.assets[0]; + setVideo(asset); + } + }; + const handleSendNote = async () => { - if (!note || note?.trim()?.length == 0) { - showToast({type: 'error', title: 'Please write your note'}); + // if (!note || note?.trim()?.length == 0) { + // showToast({type: 'error', title: 'Please write your note'}); + // return; + // } + + if (!note?.trim().length && !image && !video) { + showToast({type: 'error', title: 'Please add a note, image, or video'}); return; } + const isAuth = await handleCheckNostrAndSendConnectDialog(); + if (!isAuth) return; + let imageUrl: string | undefined; + if (image) { const result = await fileUpload.mutateAsync(image); if (result.data.url) imageUrl = result.data.url; } - await handleCheckNostrAndSendConnectDialog(); - try { - sendNote.mutate( - { - content: note, - tags: [ - ...tags, - ...(image && imageUrl ? [['image', imageUrl, `${image.width}x${image.height}`]] : []), - ], + if (video) { + videoPinataUpload.mutate(video, { + onSuccess(data) { + const videoMetadata = { + dimension: `${video.width}x${video.height}`, + url: data.url, + sha256: data.id, // Assuming ipfs hash is SHA256 + mimeType: 'video/mp4', //Making this default + imageUrls: [], // Thumbnail can be added future + fallbackUrls: [], + useNip96: false, + }; + sendVideoEvent.mutate( + { + content: note || '', + title: 'Video Note', + publishedAt: Math.floor(Date.now() / 1000), + isVertical: video?.height > video?.width, + videoMetadata: [videoMetadata], + hashtags: tags.map((tag) => tag[1]), + }, + { + onSuccess() { + showToast({type: 'success', title: 'Note sent successfully'}); + setVideo(''); + setNote(''); + }, + onError(error) { + console.log(error, 'error'); + showToast({ + type: 'error', + title: 'Error! Note could not be sent. Please try again later.', + }); + }, + }, + ); }, - { - onSuccess() { - showToast({type: 'success', title: 'Note sent successfully'}); - queryClient.invalidateQueries({queryKey: ['rootNotes']}); - navigation.goBack(); + onError() { + showToast({ + type: 'error', + title: 'Error! Error Uploading Video', + }); + }, + }); + } else { + try { + sendNote.mutate( + { + content: note || '', + tags: [ + ...tags, + ...(image && imageUrl ? [['image', imageUrl, `${image.width}x${image.height}`]] : []), + ], }, - onError(e) { - console.log('error', e); - showToast({ - type: 'error', - title: 'Error! Note could not be sent. Please try again later.', - }); + { + onSuccess() { + showToast({type: 'success', title: 'Note sent successfully'}); + queryClient.invalidateQueries({queryKey: ['rootNotes']}); + navigation.goBack(); + }, + onError(e) { + console.log('error', e); + showToast({ + type: 'error', + title: 'Error! Note could not be sent. Please try again later.', + }); + }, }, - }, - ); - } catch (e) { - console.log('sendNote error', e); + ); + } catch (e) { + console.log('sendNote error', e); + } } }; - const handleTabSelected = (tab: string | SelectedTab, screen?: string) => { setSelectedTab(tab as any); if (screen) { @@ -130,18 +205,45 @@ export const FormCreatePost: React.FC = () => { /> )} + + {video && ( + + + + )} - - - - + + + {!video && ( + + + + )} + + {!image && ( + + + + )} + - - - + {videoPinataUpload.isPending || sendVideoEvent.isPending ? ( + + + + ) : ( + + + + )} diff --git a/apps/mobile/src/screens/CreatePost/FormPost/styles.ts b/apps/mobile/src/screens/CreatePost/FormPost/styles.ts index 33e44099..9be95034 100644 --- a/apps/mobile/src/screens/CreatePost/FormPost/styles.ts +++ b/apps/mobile/src/screens/CreatePost/FormPost/styles.ts @@ -61,4 +61,11 @@ export default ThemedStyleSheet((theme) => ({ right: Spacing.pagePadding, bottom: '110%', }, + + videoContainer: { + padding: 10, + overflow: 'hidden', + width: '100%', + height: 400, + }, })); diff --git a/apps/mobile/src/screens/Feed/index.tsx b/apps/mobile/src/screens/Feed/index.tsx index e603986b..55d15ed2 100644 --- a/apps/mobile/src/screens/Feed/index.tsx +++ b/apps/mobile/src/screens/Feed/index.tsx @@ -10,6 +10,7 @@ import SearchComponent from '../../components/search'; import {useStyles, useTheme} from '../../hooks'; import {ChannelComponent} from '../../modules/ChannelCard'; import {PostCard} from '../../modules/PostCard'; +import {VideoPostCard} from '../../modules/VideoPostCard'; import {FeedScreenProps} from '../../types'; import stylesheet from './styles'; @@ -27,6 +28,8 @@ export const Feed: React.FC = ({navigation}) => { NDKKind.GroupChat, NDKKind.ChannelMessage, NDKKind.Metadata, + NDKKind.VerticalVideo, + NDKKind.HorizontalVideo, ]); const contacts = useContacts({authors: [publicKey]}); @@ -47,6 +50,8 @@ export const Feed: React.FC = ({navigation}) => { if (!notes.data?.pages) return []; const flattenedPages = notes.data.pages.flat(); + + console.log(flattenedPages, 'note pages'); if (!search || search.length === 0) { setFeedData(flattenedPages as any); return flattenedPages; @@ -125,6 +130,8 @@ export const Feed: React.FC = ({navigation}) => { return ; } else if (item.kind === NDKKind.ChannelMessage) { return ; + } else if (item.kind === NDKKind.VerticalVideo || item.kind === NDKKind.HorizontalVideo) { + return ; } else if (item.kind === NDKKind.Text) { return ; } diff --git a/apps/mobile/src/screens/Tags/index.tsx b/apps/mobile/src/screens/Tags/index.tsx index a1377e39..a744066a 100644 --- a/apps/mobile/src/screens/Tags/index.tsx +++ b/apps/mobile/src/screens/Tags/index.tsx @@ -1,68 +1,49 @@ import {NDKKind} from '@nostr-dev-kit/ndk'; -import {useAllProfiles, useSearch} from 'afk_nostr_sdk'; -import {useEffect, useMemo, useState} from 'react'; -import {FlatList, Image, Pressable, RefreshControl, Text, View} from 'react-native'; +import {useAllProfiles, useSearchTag} from 'afk_nostr_sdk'; +import React, {useMemo} from 'react'; +import { + ActivityIndicator, + FlatList, + Image, + Pressable, + RefreshControl, + Text, + View, +} from 'react-native'; import {AddPostIcon} from '../../assets/icons'; +import {IconButton} from '../../components'; import {BubbleUser} from '../../components/BubbleUser'; import {useStyles, useTheme} from '../../hooks'; import {ChannelComponent} from '../../modules/ChannelCard'; import {PostCard} from '../../modules/PostCard'; +import {VideoPostCard} from '../../modules/VideoPostCard'; import {TagsScreenProps} from '../../types'; import stylesheet from './styles'; +const KINDS: NDKKind[] = [ + NDKKind.Text, + NDKKind.ChannelCreation, + NDKKind.GroupChat, + NDKKind.ChannelMessage, + NDKKind.Metadata, + NDKKind.VerticalVideo, + NDKKind.HorizontalVideo, +]; + export const TagsView: React.FC = ({navigation, route}) => { const {theme} = useTheme(); const styles = useStyles(stylesheet); + const profiles = useAllProfiles({limit: 10}); - const [feedData, setFeedData] = useState(null); - const [kinds] = useState([ - NDKKind.Text, - NDKKind.ChannelCreation, - NDKKind.GroupChat, - NDKKind.ChannelMessage, - NDKKind.Metadata, - ]); - const notes = useSearch({ - kinds, + const notes = useSearchTag({ + kinds: KINDS, limit: 10, + hashtag: route.params?.tagName, }); - // Filter notes based on the hashtag present in the URL query - const filteredNotes = useMemo(() => { - const urlQuery = route?.params.tagName; - if (!notes.data?.pages || !urlQuery) return []; - - const flattenedPages = notes.data.pages.flat(); - return flattenedPages.filter((item) => - item?.tags?.some((tag: any) => tag[0] === 't' && tag[1] === urlQuery), - ); - }, [notes.data?.pages, route?.params.tagName]); - - // Update the feedData when filtered notes change - useEffect(() => { - setFeedData(filteredNotes as any); - }, [filteredNotes]); - - const renderHeader = () => ( - - #{route?.params.tagName} - {filteredNotes.length} notes - profiles.fetchNextPage()} - refreshControl={ - profiles.refetch()} /> - } - ItemSeparatorComponent={() => } - renderItem={({item}) => } - /> - - ); + const flattenedNotes = useMemo(() => notes.data?.pages.flat() || [], [notes.data?.pages]); return ( @@ -71,18 +52,65 @@ export const TagsView: React.FC = ({navigation, route}) => { source={require('../../assets/feed-background-afk.png')} resizeMode="cover" /> + + {notes?.isLoading && } + {notes?.data?.pages?.length == 0 && } + + + + + + + #{route?.params.tagName} + {flattenedNotes.length} notes + + + profiles.fetchNextPage()} + refreshControl={ + profiles.refetch()} + /> + } + ItemSeparatorComponent={() => } + renderItem={({item}) => } + /> + + + } contentContainerStyle={styles.flatListContent} - data={feedData} + data={flattenedNotes} keyExtractor={(item) => item?.id} renderItem={({item}) => { if (item.kind === NDKKind.ChannelCreation || item.kind === NDKKind.ChannelMetadata) { return ; - } else if (item.kind === NDKKind.ChannelMessage || item.kind === NDKKind.Text) { + } else if (item.kind === NDKKind.ChannelMessage) { + return ; + } else if (item.kind === NDKKind.VerticalVideo || item.kind === NDKKind.HorizontalVideo) { + return ; + } else if (item.kind === NDKKind.Text) { return ; } - return null; + return <>; }} refreshControl={ notes.refetch()} /> diff --git a/apps/mobile/src/screens/Tags/styles.ts b/apps/mobile/src/screens/Tags/styles.ts index cfddb81b..3c58b045 100644 --- a/apps/mobile/src/screens/Tags/styles.ts +++ b/apps/mobile/src/screens/Tags/styles.ts @@ -32,12 +32,15 @@ export default ThemedStyleSheet((theme) => ({ storySeparator: { width: 8, }, + backButton: { + marginTop: -4, + }, headerContainer: { marginBottom: 20, padding: 10, paddingVertical: 5, - paddingHorizontal: 18, + paddingHorizontal: 5, borderBottomWidth: 1, borderBottomColor: theme.colors.divider, }, diff --git a/packages/afk_nostr_sdk/src/hooks/index.ts b/packages/afk_nostr_sdk/src/hooks/index.ts index a71273db..7fae9394 100644 --- a/packages/afk_nostr_sdk/src/hooks/index.ts +++ b/packages/afk_nostr_sdk/src/hooks/index.ts @@ -4,6 +4,8 @@ export {useMessagesChannels} from './channel/useMessagesChannels'; export {useSendMessageChannel} from './channel/useSendMessageChannel'; export {useGetPublicGroup} from './group/public/useGetPublicGroup'; export {useSearch} from './search/useSearch'; +export {useSearchTag} from './search/useTagSearch'; +export {useSendVideoEvent} from "./useSendVideo" export {useSearchUsers} from './search/useSearchUsers'; export {useAllProfiles} from './useAllProfiles'; export {useContacts} from './useContacts'; diff --git a/packages/afk_nostr_sdk/src/hooks/search/useTagSearch.tsx b/packages/afk_nostr_sdk/src/hooks/search/useTagSearch.tsx new file mode 100644 index 00000000..44b80f51 --- /dev/null +++ b/packages/afk_nostr_sdk/src/hooks/search/useTagSearch.tsx @@ -0,0 +1,42 @@ +// useSearchUsers.ts +import {NDKKind} from '@nostr-dev-kit/ndk'; +import {useInfiniteQuery} from '@tanstack/react-query'; + +import {useNostrContext} from '../../context/NostrContext'; +interface UseSearchTag { + authors?: string[]; + search?: string; + kind?: NDKKind; + kinds?: NDKKind[]; + limit?: number; + hashtag?: string; // New option for hashtag search +} +export const useSearchTag = (options?: UseSearchTag) => { + const {ndk} = useNostrContext(); + + return useInfiniteQuery({ + initialPageParam: 0, + queryKey: ['searchHashTag', options?.authors, options?.search, options?.hashtag], + getNextPageParam: (lastPage: any, allPages, lastPageParam) => { + if (!lastPage?.length) return undefined; + + const pageParam = lastPage[lastPage.length - 1].created_at - 1; + + if (!pageParam || pageParam === lastPageParam) return undefined; + return pageParam; + }, + queryFn: async ({pageParam}) => { + const notes = await ndk.fetchEvents({ + kinds: options?.kinds ?? [options?.kind ?? NDKKind.Text], + authors: options?.authors, + search: options?.search, + until: pageParam || Math.round(Date.now() / 1000), + '#t': [options?.hashtag], + limit: options?.limit ?? 20, + }); + + return [...notes]; + }, + placeholderData: {pages: [], pageParams: []}, + }); +}; diff --git a/packages/afk_nostr_sdk/src/hooks/useSendVideo.ts b/packages/afk_nostr_sdk/src/hooks/useSendVideo.ts new file mode 100644 index 00000000..7b4df1ef --- /dev/null +++ b/packages/afk_nostr_sdk/src/hooks/useSendVideo.ts @@ -0,0 +1,87 @@ +import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; +import {useMutation} from '@tanstack/react-query'; + +import {useNostrContext} from '../context'; + +type VideoMetadata = { + dimension: string; + url: string; + sha256: string; + mimeType: string; + imageUrls: string[]; + fallbackUrls: string[]; + useNip96?: boolean; +}; + +type VideoEventData = { + content: string; + title: string; + publishedAt: number; + isVertical: boolean; + videoMetadata: VideoMetadata[]; + duration?: number; + textTracks?: Array<{url: string; type: string; lang?: string}>; + contentWarning?: string; + alt?: string; + segments?: Array<{start: string; end: string; title: string; thumbnailUrl?: string}>; + hashtags?: string[]; + participants?: Array<{pubkey: string; relayUrl?: string}>; + references?: string[]; +}; + +export const useSendVideoEvent = () => { + const {ndk} = useNostrContext(); + + return useMutation({ + mutationKey: ['sendVideoEvent', ndk], + mutationFn: async (data: VideoEventData) => { + const event = new NDKEvent(ndk); + event.kind = data?.isVertical ? NDKKind.VerticalVideo : NDKKind.HorizontalVideo; + event.content = data?.content; + + event.tags = [ + ['d', crypto.randomUUID()], + ['title', data.title], + ['published_at', data.publishedAt.toString()], + ]; + + if (data.alt) event.tags.push(['alt', data.alt]); + if (data.duration) event.tags.push(['duration', data.duration.toString()]); + if (data.contentWarning) event.tags.push(['content-warning', data.contentWarning]); + + data.videoMetadata.forEach((meta) => { + const imetaTag = [ + 'imeta', + `dim ${meta.dimension}`, + `url ${meta.url}`, + `x ${meta.sha256}`, + `m ${meta.mimeType}`, + ...meta.imageUrls.map((url) => `image ${url}`), + ...meta.fallbackUrls.map((url) => `fallback ${url}`), + ]; + if (meta.useNip96) imetaTag.push('service nip96'); + event.tags.push(imetaTag); + }); + + data.textTracks?.forEach((track) => { + event.tags.push(['text-track', track.url, track.type, track.lang].filter(Boolean)); + }); + + data.segments?.forEach((segment) => { + event.tags.push( + ['segment', segment.start, segment.end, segment.title, segment.thumbnailUrl].filter( + Boolean, + ), + ); + }); + + data.hashtags?.forEach((tag) => event.tags.push(['t', tag])); + data.participants?.forEach((participant) => + event.tags.push(['p', participant.pubkey, participant.relayUrl].filter(Boolean)), + ); + data.references?.forEach((ref) => event.tags.push(['r', ref])); + + return event.publish(); + }, + }); +}; From 641a9ae00df9b5591f8b4872983d80ef2545e37c Mon Sep 17 00:00:00 2001 From: MSG <59928086+MSghais@users.noreply.github.com> Date: Sun, 20 Oct 2024 11:57:11 +0200 Subject: [PATCH 2/4] Feat/move forward (#195) * init onramp money iframe * delete engine * go * add in backend + block script * go fix backend * username deploy + add artpeace old test to fix + vars in canvas * vars * lint fix * disable root lint fix * clean --- apps/data-backend/package.json | 4 +- .../src/routes/stripe/createPaymentIntent.ts | 2 +- apps/mobile/.env.example | 5 + .../src/modules/onramp/onramp_money/index.tsx | 63 ++ apps/mobile/src/screens/Wallet/index.tsx | 10 +- apps/pwa/src/app/page.tsx | 2 + backend/cmd/backend/backend.go | 12 +- backend/cmd/consumer/consumer.go | 13 +- backend/indexer/script.js | 2 +- docs/docs/resources/Architecture.md | 11 + docs/docs/resources/Module.md | 61 ++ .../{tutorial-basics => resources}/Roadmap.md | 0 docs/docs/resources/_category_.json | 8 + docs/docs/resources/image.png | Bin 0 -> 193491 bytes docs/docs/resources/resources.md | 12 + docs/docs/tutorial-basics/_category_.json | 2 +- docs/docs/tutorial-basics/features.md | 14 + docs/docusaurus.config.ts | 9 + docs/package.json | 5 +- onchain/cairo/src/afk_id/mod.cairo | 1 + onchain/cairo/src/afk_id/username_store.cairo | 96 +++ onchain/cairo/src/lib.cairo | 8 +- onchain/cairo/src/pixel/mod.cairo | 6 + .../pixel/username_store/username_store.cairo | 2 +- onchain/cairo/src/tests/art_peace_tests.cairo | 549 ++++++++++++++++++ onchain/cairo/src/tests/utils.cairo | 64 ++ packages/common/src/contracts.ts | 6 + packages/pixel_ui/src/App.tsx | 11 +- packages/pixel_ui/src/index.tsx | 9 +- scripts/.env.exemple | 11 +- scripts/deploy/username_store.ts | 40 ++ scripts/package.json | 1 + scripts/utils/username_store.ts | 104 ++++ turbo.json | 4 +- 34 files changed, 1115 insertions(+), 32 deletions(-) create mode 100644 apps/mobile/src/modules/onramp/onramp_money/index.tsx create mode 100644 docs/docs/resources/Architecture.md create mode 100644 docs/docs/resources/Module.md rename docs/docs/{tutorial-basics => resources}/Roadmap.md (100%) create mode 100644 docs/docs/resources/_category_.json create mode 100644 docs/docs/resources/image.png create mode 100644 docs/docs/resources/resources.md create mode 100644 docs/docs/tutorial-basics/features.md create mode 100644 onchain/cairo/src/afk_id/username_store.cairo create mode 100644 onchain/cairo/src/tests/art_peace_tests.cairo create mode 100644 onchain/cairo/src/tests/utils.cairo create mode 100644 scripts/deploy/username_store.ts create mode 100644 scripts/utils/username_store.ts diff --git a/apps/data-backend/package.json b/apps/data-backend/package.json index 7d00bc8f..80fc13f0 100644 --- a/apps/data-backend/package.json +++ b/apps/data-backend/package.json @@ -6,8 +6,8 @@ "scripts": { "build": "tsc", "build:index": "tsc", - "build:all_repo": "pnpm run build:indexer-prisma && cd ../../apps/data-backend tsc", - "build:all": "pnpm run build:indexer-prisma && cd ../../apps/data-backend && pnpm prisma:setup && tsc", + "build:all":"pnpm run build:indexer-prisma && cd ../../apps/data-backend tsc", + "build:all_repo": "pnpm run build:indexer-prisma && cd ../../apps/data-backend && pnpm prisma:setup && tsc", "start": "ts-node src/index.ts", "start:dev": "ts-node-dev src/index.ts", "start:prod": "ts-node dist/index.js", diff --git a/apps/data-backend/src/routes/stripe/createPaymentIntent.ts b/apps/data-backend/src/routes/stripe/createPaymentIntent.ts index b0fe5a51..cab4d0dc 100644 --- a/apps/data-backend/src/routes/stripe/createPaymentIntent.ts +++ b/apps/data-backend/src/routes/stripe/createPaymentIntent.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { prisma } from "@prisma/client"; +// import { prisma } from "@prisma/client"; import dotenv from "dotenv" dotenv.config() const stripe = require('stripe')(process.env.STRIPE_SERVER_API_KEY); diff --git a/apps/mobile/.env.example b/apps/mobile/.env.example index 10e05a19..a673a80f 100644 --- a/apps/mobile/.env.example +++ b/apps/mobile/.env.example @@ -37,3 +37,8 @@ EXPO_PUBLIC_PINATA_JWT="" EXPO_PUBLIC_PINATA_UPLOAD_GATEWAY_URL='' EXPO_PUBLIC_PINATA_PINATA_SIGN_URL='https://api.pinata.cloud/v3/files/sign' EXPO_PUBLIC_PINATA_UPLOAD_URL='https://uploads.pinata.cloud/v3/files' + +# Onramp/Offramp + +EXPO_PUBLIC_APP_ID_ONRAMP_MONEY= + diff --git a/apps/mobile/src/modules/onramp/onramp_money/index.tsx b/apps/mobile/src/modules/onramp/onramp_money/index.tsx new file mode 100644 index 00000000..16d2979e --- /dev/null +++ b/apps/mobile/src/modules/onramp/onramp_money/index.tsx @@ -0,0 +1,63 @@ +// OnrampMoney.ts +import '../../../../applyGlobalPolyfills'; +import { View, Text, Platform } from 'react-native'; +import WebView from 'react-native-webview'; +import { ScrollView } from 'react-native-gesture-handler'; +import PolyfillCrypto from 'react-native-webview-crypto'; + +export function OnrampMoney() { + enum Action { + Buy, + Sell, + Swap + } + + const base = `https://onramp.money/app/?appId=` + const appId = `${process.env.EXPO_PUBLIC_APP_ID_ONRAMP_MONEY}&walletAddress=` + const renderOnrampView = () => { + if (Platform.OS === 'web') { + return ( + + + ); + } else if (WebView) { + return ( + { + window.ReactNativeWebView.postMessage(event.data?.type); + }); + `} + onMessage={(event) => { + + }} + /> + ); + } + return null; + }; + + return ( + + + + + {renderOnrampView()} + + + + + + + + + + ); +} diff --git a/apps/mobile/src/screens/Wallet/index.tsx b/apps/mobile/src/screens/Wallet/index.tsx index f668abf7..600a8a0c 100644 --- a/apps/mobile/src/screens/Wallet/index.tsx +++ b/apps/mobile/src/screens/Wallet/index.tsx @@ -15,6 +15,7 @@ import { CashuWalletView } from '../../modules/Cashu'; import { LayerswapView } from '../../modules/Bridge/layerswap'; import { PaymentStripeScreen } from '../../modules/Payment/stripe'; import CheckoutScreen from '../../modules/Payment/stripe/checkout'; +import { OnrampMoney } from '../../modules/onramp/onramp_money'; export const Wallet: React.FC = ({ navigation }) => { const styles = useStyles(stylesheet); @@ -76,16 +77,19 @@ export const Wallet: React.FC = ({ navigation }) => { )} - {/* {selectedTab == SelectedTab.ONRAMP_OFFRAMP && ( + {selectedTab == SelectedTab.ONRAMP_OFFRAMP && ( Onramp/Offramp solution coming soon - - + )} + {/* + {selectedTab == SelectedTab.WALLET_INTERNAL && ( + + Onramp/Offramp solution coming soon )} */} diff --git a/apps/pwa/src/app/page.tsx b/apps/pwa/src/app/page.tsx index c9d21445..d96be564 100644 --- a/apps/pwa/src/app/page.tsx +++ b/apps/pwa/src/app/page.tsx @@ -10,6 +10,8 @@ export default function App() { {typeof window !== 'undefined' && ( )} {/*