Skip to content

Commit

Permalink
implement Nostr video creation and viewing
Browse files Browse the repository at this point in the history
  • Loading branch information
akintewe committed Oct 17, 2024
1 parent 3b1c325 commit 6ad3813
Show file tree
Hide file tree
Showing 12 changed files with 28,711 additions and 69 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,4 @@ node_modules/
.env


pnpm-lock.yaml
pnpm-lock.yamlapps/mobile/.env
3 changes: 2 additions & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@
"viem": "2.x",
"wagmi": "^2.12.8",
"zustand": "^4.5.2",
"@stripe/stripe-react-native":"0.38.6"
"@stripe/stripe-react-native":"0.38.6",
"dotenv": "^16.0.0"
},
"devDependencies": {
"@babel/core": "^7.20.0",
Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/src/components/NostrVideo/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,12 @@ const NostrVideo = ({ item, shouldPlay }: { shouldPlay: boolean; item: NostrEven
}, [shouldPlay]);

const extractVideoURL = (event: NostrEvent) => {
return event?.tags?.find((tag) => tag?.[0] === 'url')?.[1] || '';
const urlTag = event?.tags?.find((tag) => tag?.[0] === 'url');
if (urlTag) {
const ipfsHash = urlTag[1].replace('ipfs://', '');
return `https://gateway.pinata.cloud/ipfs/${ipfsHash}`;
}
return '';
};

const handleProfile = () => {
Expand Down
18 changes: 9 additions & 9 deletions apps/mobile/src/modules/ShortVideos/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,16 @@ const ShortVideosModule = () => {
const { theme } = useTheme();
const [currentViewableItemIndex, setCurrentViewableItemIndex] = useState(0);
const viewabilityConfig = { viewAreaCoveragePercentThreshold: 50 };
const videos = useGetVideos();
const [videosEventsState, setVideosEvents] = useState<NostrEvent[]>(
videos?.data?.pages?.flat() as NostrEvent[],
);
const { data: videosData, fetchNextPage, hasNextPage, isFetchingNextPage } = useGetVideos();

const videosEvents = useMemo(() => {
return videos?.data?.pages?.flat() as NostrEvent[]
}, [videos?.data?.pages])
return videosData?.pages?.flat() as NostrEvent[] || [];
}, [videosData?.pages]);

const fetchNostrEvents = async () => {
// This mock should be replaced with actual implementation (hook integration to get videos)
setVideosEvents(mockEvents);
if (hasNextPage && !isFetchingNextPage) {
await fetchNextPage();
}
};

const onViewableItemsChanged = ({ viewableItems }: any) => {
Expand All @@ -45,11 +43,13 @@ const ShortVideosModule = () => {
renderItem={({ item, index }) => (
<NostrVideo item={item} shouldPlay={index === currentViewableItemIndex} />
)}
keyExtractor={(item, index) => item.content + index}
keyExtractor={(item, index) => (item.id ?? index.toString()) + index}
pagingEnabled
horizontal={false}
showsVerticalScrollIndicator={false}
viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs.current}
onEndReached={fetchNostrEvents}
onEndReachedThreshold={0.5}
/>
) : (
<View style={styles.noDataContainer}>
Expand Down
19 changes: 17 additions & 2 deletions apps/mobile/src/screens/CreateChannel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {useToast} from '../../hooks/modals';
import {CreateChannelScreenProps, MainStackNavigationProps} from '../../types';
import {ChannelHead} from './Head';
import stylesheet from './styles';
import { uploadToPinata } from 'afk_nostr_sdk/src/utils/pinata'; // Add this import

const UsernameInputLeft = (
<Text weight="bold" color="inputPlaceholder">
Expand All @@ -40,7 +41,7 @@ type FormValues = {
relays: string[];
};

export const CreateChannel: React.FC<CreateChannelScreenProps> = () => {
export const CreateChannel: React.FC<CreateChannelScreenProps> = ({ navigation }) => {
const formikRef = useRef<FormikProps<FormValues>>(null);

const {theme} = useTheme();
Expand All @@ -56,7 +57,6 @@ export const CreateChannel: React.FC<CreateChannelScreenProps> = () => {
const queryClient = useQueryClient();
const {relays} = useSettingsStore();
const {showToast} = useToast();
const navigation = useNavigation<MainStackNavigationProps>();

if (profile.isLoading) return null;

Expand Down Expand Up @@ -154,6 +154,21 @@ export const CreateChannel: React.FC<CreateChannelScreenProps> = () => {
}
};

const pickVideo = async () => {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Videos,
allowsEditing: true,
aspect: [16, 9],
quality: 1,
});

if (!result.canceled) {
const videoUri = result.assets[0].uri;
const cid = await uploadToPinata(videoUri);
// Use the CID in your form or state
}
};

return (
<ScrollView automaticallyAdjustKeyboardInsets style={styles.container}>
<ChannelHead
Expand Down
114 changes: 82 additions & 32 deletions apps/mobile/src/screens/CreatePost/FormPost/index.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import {useNavigation} from '@react-navigation/native';
import {useQueryClient} from '@tanstack/react-query';
import {useSendNote} from 'afk_nostr_sdk';
import {useSendNote, useSendVideo} from 'afk_nostr_sdk';
import * as ImagePicker from 'expo-image-picker';
import {useRef, useState} from 'react';
import {Image, KeyboardAvoidingView, Pressable, TextInput, View} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context';
import * as FileSystem from 'expo-file-system';
import { Video, AVPlaybackStatus } from 'expo-av';

import {GalleryIcon, SendIconContained} from '../../../assets/icons';
import {useNostrAuth, useStyles, useTheme} from '../../../hooks';
Expand All @@ -14,6 +16,7 @@ import {MainStackNavigationProps} from '../../../types';
import {SelectedTab} from '../../../types/tab';
import {getImageRatio} from '../../../utils/helpers';
import stylesheet from './styles';
import { NDKKind } from '@nostr-dev-kit/ndk';
// import {useSendNote} from "afk_nostr_sdk/hooks"

export const FormCreatePost: React.FC = () => {
Expand All @@ -31,6 +34,10 @@ export const FormCreatePost: React.FC = () => {

const [tags, setTags] = useState<string[][]>([]);
const inputRef = useRef<TextInput>(null);
const [video, setVideo] = useState<ImagePicker.ImagePickerAsset | undefined>();
const sendVideo = useSendVideo();
const [videoTitle, setVideoTitle] = useState('');
const [videoDuration, setVideoDuration] = useState<number | undefined>();

const onGalleryPress = async () => {
const pickerResult = await ImagePicker.launchImageLibraryAsync({
Expand All @@ -43,48 +50,91 @@ export const FormCreatePost: React.FC = () => {
});

if (pickerResult.canceled || !pickerResult.assets.length) return;
setImage(pickerResult.assets[0]);
const asset = pickerResult.assets[0];
if (asset.type === 'video') {
setVideo(asset);
setImage(undefined);
} else {
setImage(asset);
setVideo(undefined);
}
};

const handleSendNote = async () => {
if (!note || note?.trim()?.length == 0) {
showToast({type: 'error', title: 'Please write your note'});
if (!note && !video && !image) {
showToast({type: 'error', title: 'Please add content to your post'});
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}`]] : []),
],
},
{
onSuccess() {
showToast({type: 'success', title: 'Note sent successfully'});
queryClient.invalidateQueries({queryKey: ['rootNotes']});
navigation.goBack();
if (video) {
// Get video duration
const videoRef = new Video({ source: { uri: video.uri } });
const status = await videoRef.getStatusAsync();
const duration = status.isLoaded && 'durationMillis' in status && status.durationMillis !== undefined
? status.durationMillis / 1000
: undefined;

sendVideo.mutate(
{
content: note || '',
videoUri: video.uri,
kind: NDKKind.VerticalVideo,
title: videoTitle || 'Untitled Video',
duration: duration,
alt: note, // Using the note as alt text, adjust if needed
contentWarning: undefined, // Add logic to set this if needed
additionalTags: tags,
},
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: 'Video posted successfully'});
queryClient.invalidateQueries({queryKey: ['getVideos']});
navigation.goBack();
},
onError(e) {
console.log('error', e);
showToast({
type: 'error',
title: 'Error! Video could not be posted. Please try again later.',
});
},
}
);
} else {
let imageUrl: string | undefined;
if (image) {
const response = await fileUpload.mutateAsync(image);
imageUrl = response.data.url; // Adjust this based on the actual response structure
}

sendNote.mutate(
{
content: note ?? '',
tags: [
...tags,
...(image && imageUrl ? [['image', imageUrl, `${image.width}x${image.height}`]] : []),
],
},
},
);
{
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);
console.log('sendNote/sendVideo error', e);
}
};

Expand Down
32 changes: 32 additions & 0 deletions apps/mobile/src/utils/pinata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import axios from 'axios';
import * as FileSystem from 'expo-file-system';

export const uploadToPinata = async (fileUri: string) => {
const apiKey = process.env.EXPO_PUBLIC_PINATA_API_KEY;
const apiSecret = process.env.EXPO_PUBLIC_PINATA_API_SECRET;

// Read the file as a binary string
const fileInfo = await FileSystem.getInfoAsync(fileUri);
const fileContent = await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 });

const formData = new FormData();
formData.append('file', {
uri: fileUri,
type: 'video/mp4',
name: 'video.mp4',
} as any); // Use 'as any' to bypass TypeScript checks

try {
const res = await axios.post("https://api.pinata.cloud/pinning/pinFileToIPFS", formData, {
headers: {
'Content-Type': 'multipart/form-data',
pinata_api_key: apiKey,
pinata_secret_api_key: apiSecret,
},
});
return res.data.IpfsHash;
} catch (error) {
console.error('Error uploading to Pinata:', error);
throw error;
}
};
40 changes: 20 additions & 20 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,20 @@
"prisma": {},
"scripts": {
"build": "turbo run build",
"build:website":"cd apps/website && pnpm run build",
"dev:website":"cd apps/website && pnpm run dev",
"build:mobile":"cd apps/mobile && pnpm run build",
"dev:mobile":"cd apps/mobile && pnpm run start",
"build:nostr_sdk":"cd packages/afk_nostr_sdk && pnpm run build",
"build:indexer-prisma":"cd packages/indexer-prisma && pnpm install && pnpm pull && pnpm generate",
"build:backend":"cd apps/data-backend && pnpm run build",
"build:backend:all":"pnpm run build:indexer-prisma && cd apps/data-backend && pnpm install && pnpm run build",
"run:backend":"cd apps/data-backend && pnpm run start",
"run:backend:prod":"cd apps/data-backend && pnpm run start:prod",
"build:website": "cd apps/website && pnpm run build",
"dev:website": "cd apps/website && pnpm run dev",
"build:mobile": "cd apps/mobile && pnpm run build",
"dev:mobile": "cd apps/mobile && pnpm run start",
"build:nostr_sdk": "cd packages/afk_nostr_sdk && pnpm run build",
"build:indexer-prisma": "cd packages/indexer-prisma && pnpm install && pnpm pull && pnpm generate",
"build:backend": "cd apps/data-backend && pnpm run build",
"build:backend:all": "pnpm run build:indexer-prisma && cd apps/data-backend && pnpm install && pnpm run build",
"run:backend": "cd apps/data-backend && pnpm run start",
"run:backend:prod": "cd apps/data-backend && pnpm run start:prod",
"dev": "turbo run dev",
"web":"turbo run dev --filter=website",
"mobile":"turbo run start --filter=mobile",
"extension":"turbo run dev --filter=extensions",
"web": "turbo run dev --filter=website",
"mobile": "turbo run start --filter=mobile",
"extension": "turbo run dev --filter=extensions",
"lint": "turbo run lint",
"clean": "turbo run clean && rimraf node_modules",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
Expand All @@ -39,11 +39,12 @@
"dependencies": {
"@babel/core": "^7.20.0",
"@babel/plugin-proposal-export-namespace-from": "^7.18.9",
"@prisma/client":"^5.18.0",
"prettier":"^2.8.8",
"prisma":"5.18.0",
"tsup":"^8.0.2",
"turbo":"^1.13.2"
"@prisma/client": "^5.18.0",
"dotenv": "^16.4.5",
"prettier": "^2.8.8",
"prisma": "5.18.0",
"tsup": "^8.0.2",
"turbo": "^1.13.2"
},
"devDependencies": {
"prisma": "^5.18.0",
Expand All @@ -54,6 +55,5 @@
"node": ">=14.0.0"
},
"packageManager": "pnpm@8.15.9",
"pnpm": {
}
"pnpm": {}
}
Loading

0 comments on commit 6ad3813

Please sign in to comment.