diff --git a/assets/icons/location-refined.svg b/assets/icons/location-refined.svg new file mode 100644 index 0000000000..b7bce7fb58 --- /dev/null +++ b/assets/icons/location-refined.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/media-player/play_round.svg b/assets/icons/media-player/play_round.svg index 2127247a8b..a1eddc575e 100644 --- a/assets/icons/media-player/play_round.svg +++ b/assets/icons/media-player/play_round.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/icons/video.svg b/assets/icons/video.svg index 26877b21cf..b316f1dcef 100644 --- a/assets/icons/video.svg +++ b/assets/icons/video.svg @@ -1,3 +1,3 @@ - - + + diff --git a/package.json b/package.json index 69916d17d4..d248ac8e10 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,8 @@ "@solana/web3.js": "^1.87.6", "@tanstack/react-query": "^4.12.0", "@types/crypto-js": "^4.2.2", - "@types/leaflet": "^1.9.8", + "@types/leaflet": "^1.9.12", + "@types/leaflet.markercluster": "^1.5.4", "@types/papaparse": "^5.3.14", "assert": "^2.1.0", "axios": "^1.6.2", @@ -112,6 +113,7 @@ "immutable": "^4.0.0", "kubernetes-models": "^4.3.1", "leaflet": "^1.9.4", + "leaflet.markercluster": "^1.5.3", "listr2": "^8.0.1", "lodash": "^4.17.21", "long": "^5.2.1", diff --git a/packages/components/FilePreview/AudioView.tsx b/packages/components/FilePreview/AudioView.tsx index cc539082fc..66a10c2d63 100644 --- a/packages/components/FilePreview/AudioView.tsx +++ b/packages/components/FilePreview/AudioView.tsx @@ -27,7 +27,6 @@ export const AudioView: React.FC<{ imageURI?: string; duration: number; waveform: number[]; - authorId: string; postId: string; fallbackImageURI?: string; }> = ({ @@ -35,13 +34,12 @@ export const AudioView: React.FC<{ imageURI, duration, waveform, - authorId, postId, fallbackImageURI: fallbackImageSource, }) => { const { media, handlePlayPause, loadAndPlaySoundsQueue, playbackStatus } = useMediaPlayer(); - const isInMediaPlayer = media?.postId === postId; + const isInMediaPlayer = !!media && postId === media.postId; const onPressPlayPause = async () => { if (isInMediaPlayer) { diff --git a/packages/components/FilePreview/FilesPreviewsContainer.tsx b/packages/components/FilePreview/FilesPreviewsContainer.tsx index 980767e7b7..bebd9151fd 100644 --- a/packages/components/FilePreview/FilesPreviewsContainer.tsx +++ b/packages/components/FilePreview/FilesPreviewsContainer.tsx @@ -5,9 +5,6 @@ import { v4 as uuidv4 } from "uuid"; import { EditableAudioPreview } from "./EditableAudioPreview"; import { ImagesViews } from "./ImagesViews"; import { VideoView } from "./VideoView"; -import { useSelectedNetworkId } from "../../hooks/useSelectedNetwork"; -import useSelectedWallet from "../../hooks/useSelectedWallet"; -import { getUserId } from "../../networks"; import { GIF_MIME_TYPE } from "../../utils/mime"; import { convertGIFToLocalFileType } from "../../utils/social-feed"; import { layout } from "../../utils/style/layout"; @@ -30,10 +27,6 @@ export const FilesPreviewsContainer: React.FC = ({ onAudioUpdate, showSmallPreview = false, }) => { - const selectedWallet = useSelectedWallet(); - const selectedNetworkId = useSelectedNetworkId(); - const userId = getUserId(selectedNetworkId, selectedWallet?.address); - const audioFiles = useMemo( () => files?.filter((file) => file.fileType === "audio"), [files], @@ -82,7 +75,6 @@ export const FilesPreviewsContainer: React.FC = ({ file={file} onDelete={onDelete} isEditable - authorId={userId} showSmallPreview={showSmallPreview} /> ))} diff --git a/packages/components/FilePreview/VideoView.tsx b/packages/components/FilePreview/VideoView.tsx index 496aa06ded..60aef42e31 100644 --- a/packages/components/FilePreview/VideoView.tsx +++ b/packages/components/FilePreview/VideoView.tsx @@ -11,19 +11,17 @@ import { LocalFileData, RemoteFileData } from "../../utils/types/files"; import { BrandText } from "../BrandText"; import { MediaPlayerVideo } from "../mediaPlayer/MediaPlayerVideo"; -interface VideoPreviewProps { +interface Props { file: LocalFileData | RemoteFileData; onDelete?: (file: LocalFileData | RemoteFileData) => void; isEditable?: boolean; postId?: string; - authorId: string; showSmallPreview?: boolean; } -export const VideoView: React.FC = ({ +export const VideoView: React.FC = ({ file, onDelete, - authorId, postId, isEditable = false, showSmallPreview = false, diff --git a/packages/components/IconBox.tsx b/packages/components/IconBox.tsx index b28c061494..08184ebade 100644 --- a/packages/components/IconBox.tsx +++ b/packages/components/IconBox.tsx @@ -12,8 +12,8 @@ interface IconBoxProps { style?: StyleProp; disabled?: boolean; iconProps?: { - height: number; - width: number; + height?: number; + width?: number; color?: string; }; } diff --git a/packages/components/TopMenu/TopMenuLiveMint.tsx b/packages/components/TopMenu/TopMenuLiveMint.tsx index c735a6e586..a9bf6e1ee0 100644 --- a/packages/components/TopMenu/TopMenuLiveMint.tsx +++ b/packages/components/TopMenu/TopMenuLiveMint.tsx @@ -17,7 +17,7 @@ import { COLLECTION_VIEW_SM_HEIGHT, COLLECTION_VIEW_SM_WIDTH, } from "../CollectionView"; -import { SmallCarousel } from "../carousels/SmallCarousel"; +import { SmallCarousel } from "../carousels/SmallCarousel/SmallCarousel"; export const TopMenuLiveMint: React.FC = () => { const selectedNetworkId = useSelectedNetworkId(); diff --git a/packages/components/TopMenu/TopMenuMyTeritories.tsx b/packages/components/TopMenu/TopMenuMyTeritories.tsx index 0aa0987aa7..ef3aa4bacd 100644 --- a/packages/components/TopMenu/TopMenuMyTeritories.tsx +++ b/packages/components/TopMenu/TopMenuMyTeritories.tsx @@ -12,7 +12,7 @@ import FlexCol from "../FlexCol"; import FlexRow from "../FlexRow"; import { OmniLink } from "../OmniLink"; import { LegacyTertiaryBox } from "../boxes/LegacyTertiaryBox"; -import { SmallCarousel } from "../carousels/SmallCarousel"; +import { SmallCarousel } from "../carousels/SmallCarousel/SmallCarousel"; import { UserAvatarWithFrame } from "../images/AvatarWithFrame"; const ORG_CARD_WIDTH = 164; diff --git a/packages/components/carousels/SmallCarousel.tsx b/packages/components/carousels/SmallCarousel.tsx deleted file mode 100644 index e826c83be8..0000000000 --- a/packages/components/carousels/SmallCarousel.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import React, { useMemo, useRef, useState } from "react"; -import { - StyleProp, - StyleSheet, - TouchableOpacity, - ViewStyle, -} from "react-native"; -import Carousel, { - ICarouselInstance, - TCarouselProps, -} from "react-native-reanimated-carousel"; - -import chevronLeftSVG from "../../../assets/icons/chevron-left.svg"; -import chevronRightSVG from "../../../assets/icons/chevron-right.svg"; -import FlexRow from "../FlexRow"; -import { SVG } from "../SVG"; -import { InnerSideBlackShadow } from "../shadows/InnerSideBlackShadow"; - -const chevronSize = 16; - -type ButtonProps = { - shadowHeight: number; - onPress?: () => void; - style?: StyleProp; -}; - -const PrevButton: React.FC = ({ - onPress, - shadowHeight, - style, -}) => { - return ( - - - - - - ); -}; - -const NextButton: React.FC = ({ - onPress, - shadowHeight, - style, -}) => ( - - - - - -); - -export const SmallCarousel: React.FC = ( - props, -) => { - const { width = 0, height = 0, style, data, ...carouselProps } = props; - // loop is true by default in Carousel, so we need to override SmallCarousel props.loop like this - const isLoop = props.loop === undefined || props.loop; - - const carouselRef = useRef(null); - const viewWidth = StyleSheet.flatten(style)?.width; - const step = Math.floor( - (typeof viewWidth === "number" ? viewWidth : 0) / (width || 0), - ); - const [currentIndex, setCurrentIndex] = useState(0); - const [isScrolling, setScrolling] = useState(false); - - const onScrollEnd = (index: number) => { - setCurrentIndex(index); - // Prevent spamming buttons - setScrolling(false); - }; - - const onPressPrev = () => { - // No matter the carousel or items width, if it's the last press on Prev, we set "Reached the carousel's start" - if (!isLoop && currentIndex - step <= 0) { - carouselRef.current?.scrollTo({ index: 0, animated: true }); - } else { - carouselRef.current?.prev({ count: step }); - } - }; - const onPressNext = () => { - // No matter the carousel or items width, if it's the last press on Next, we set "Reached the carousel's end" - if (!isLoop && currentIndex + step >= data.length) { - carouselRef.current?.scrollTo({ index: data.length - 1, animated: true }); - } else { - carouselRef.current?.next({ count: step }); - } - }; - - const isPrevButtonDisplayed = useMemo( - () => - // The button always displayed if loop carousel - (isLoop || - // If not loop, the button is hidden if the carousel is at start - (!isLoop && currentIndex > 0)) && - // The button is always hidden if all items are visible (without doing next/prev) - data.length > step, - [isLoop, currentIndex, data.length, step], - ); - const isNextButtonDisplayed = useMemo( - () => - (isLoop || (!isLoop && currentIndex < data.length - 1)) && - data.length > step, - [isLoop, currentIndex, data.length, step], - ); - - return ( - - {isPrevButtonDisplayed && ( - - )} - setScrolling(true)} - onScrollEnd={onScrollEnd} - onConfigurePanGesture={(g) => g.enableTrackpadTwoFingerGesture(true)} - {...carouselProps} - /> - {isNextButtonDisplayed && ( - - )} - - ); -}; - -// FIXME: remove StyleSheet.create -// eslint-disable-next-line no-restricted-syntax -const styles = StyleSheet.create({ - buttonContainer: { - top: 0, - zIndex: 10, - position: "absolute", - }, - buttonShadow: { - justifyContent: "center", - alignItems: "center", - }, -}); diff --git a/packages/components/carousels/SmallCarousel/SmallCarousel.tsx b/packages/components/carousels/SmallCarousel/SmallCarousel.tsx new file mode 100644 index 0000000000..4b1805ae00 --- /dev/null +++ b/packages/components/carousels/SmallCarousel/SmallCarousel.tsx @@ -0,0 +1,119 @@ +import { FC } from "react"; +import { StyleProp, TouchableOpacity, ViewStyle } from "react-native"; +import Carousel, { TCarouselProps } from "react-native-reanimated-carousel"; + +import FlexRow from "../../FlexRow"; +import { SVG } from "../../SVG"; +import { InnerSideBlackShadow } from "../../shadows/InnerSideBlackShadow"; + +import chevronLeftSVG from "@/assets/icons/chevron-left.svg"; +import chevronRightSVG from "@/assets/icons/chevron-right.svg"; +import { useSmallCarousel } from "@/components/carousels/SmallCarousel/useSmallCarousel"; + +const CHEVRON_SIZE = 16; + +type ButtonProps = { + shadowHeight: number; + onPress?: () => void; + style?: StyleProp; +}; + +const PrevButton: FC = ({ onPress, shadowHeight, style }) => { + return ( + + + + + + ); +}; + +const NextButton: FC = ({ onPress, shadowHeight, style }) => ( + + + + + +); + +export const SmallCarousel: FC = ( + props, +) => { + const { + carouselRef, + setScrolling, + width, + height, + style, + data, + carouselProps, + isScrolling, + onScrollEnd, + onPressPrev, + onPressNext, + isPrevButtonEnabled, + isNextButtonEnabled, + } = useSmallCarousel(props); + + return ( + + {isPrevButtonEnabled && ( + + )} + setScrolling(true)} + onScrollEnd={onScrollEnd} + onConfigurePanGesture={(g) => g.enableTrackpadTwoFingerGesture(true)} + {...carouselProps} + /> + {isNextButtonEnabled && ( + + )} + + ); +}; + +const buttonContainerCStyle: ViewStyle = { + top: 0, + zIndex: 10, + position: "absolute", +}; +const buttonShadowCStyle: ViewStyle = { + justifyContent: "center", + alignItems: "center", +}; diff --git a/packages/components/carousels/SmallCarousel/SmallCarouselAlt.tsx b/packages/components/carousels/SmallCarousel/SmallCarouselAlt.tsx new file mode 100644 index 0000000000..e79014c4a6 --- /dev/null +++ b/packages/components/carousels/SmallCarousel/SmallCarouselAlt.tsx @@ -0,0 +1,116 @@ +import { FC } from "react"; +import { View, ViewStyle } from "react-native"; +import Carousel, { TCarouselProps } from "react-native-reanimated-carousel"; + +import { SVG } from "../../SVG"; + +import chevronLeftSVG from "@/assets/icons/chevron-left.svg"; +import chevronRightSVG from "@/assets/icons/chevron-right.svg"; +import { CustomPressable } from "@/components/buttons/CustomPressable"; +import { useSmallCarousel } from "@/components/carousels/SmallCarousel/useSmallCarousel"; +import { SpacerRow } from "@/components/spacer"; +import { neutral33 } from "@/utils/style/colors"; +import { layout } from "@/utils/style/layout"; + +// SmallCarousel but with rounded buttons instead of shadowed sides buttons + +const CHEVRON_SIZE = 16; + +type ButtonProps = { + onPress?: () => void; + disabled?: boolean; +}; + +const PrevButton: FC = ({ onPress, disabled }) => { + return ( + + + + ); +}; + +const NextButton: FC = ({ onPress, disabled }) => ( + + + +); + +export const SmallCarouselAlt: React.FC = ( + props, +) => { + const { + carouselRef, + setScrolling, + width, + height, + style, + data, + carouselProps, + isScrolling, + onScrollEnd, + onPressPrev, + onPressNext, + isPrevButtonEnabled, + isNextButtonEnabled, + } = useSmallCarousel(props); + + return ( + + setScrolling(true)} + onScrollEnd={onScrollEnd} + onConfigurePanGesture={(g) => g.enableTrackpadTwoFingerGesture(true)} + {...carouselProps} + /> + {data.length > 1 && ( + + + + + + )} + + ); +}; + +const leftRightButtonCStyle: ViewStyle = { + borderRadius: 999, + width: 24, + height: 24, + alignItems: "center", + justifyContent: "center", + backgroundColor: neutral33, +}; diff --git a/packages/components/carousels/SmallCarousel/useSmallCarousel.ts b/packages/components/carousels/SmallCarousel/useSmallCarousel.ts new file mode 100644 index 0000000000..0f004e3c05 --- /dev/null +++ b/packages/components/carousels/SmallCarousel/useSmallCarousel.ts @@ -0,0 +1,76 @@ +import { useRef, useState } from "react"; +import { StyleSheet } from "react-native"; +import { + ICarouselInstance, + TCarouselProps, +} from "react-native-reanimated-carousel"; + +export const useSmallCarousel = ( + props: TCarouselProps & { height: number }, +) => { + const { + width = 0, + height = 0, + style, + data, + loop: isLoop = true, + ...carouselProps + } = props; + + const carouselRef = useRef(null); + const viewWidth = StyleSheet.flatten(style)?.width; + const step = + width && typeof viewWidth === "number" ? Math.floor(viewWidth / width) : 0; + const [currentIndex, setCurrentIndex] = useState(0); + const [isScrolling, setScrolling] = useState(false); + + const onScrollEnd = (index: number) => { + setCurrentIndex(index); + // Prevent spamming buttons + setScrolling(false); + }; + + const onPressPrev = () => { + // No matter the carousel or items width, if it's the last press on Prev, we set "Reached the carousel's start" + if (!isLoop && currentIndex - step <= 0) { + carouselRef.current?.scrollTo({ index: 0, animated: true }); + } else { + carouselRef.current?.prev({ count: step }); + } + }; + const onPressNext = () => { + // No matter the carousel or items width, if it's the last press on Next, we set "Reached the carousel's end" + if (!isLoop && currentIndex + step >= data.length) { + carouselRef.current?.scrollTo({ index: data.length - 1, animated: true }); + } else { + carouselRef.current?.next({ count: step }); + } + }; + + const isPrevButtonEnabled = + // The button always enabled if loop carousel + (isLoop || + // If not loop, the button is disabled if the carousel is at start + currentIndex > 0) && + // The button is always disabled if all items are visible (without doing next/prev) + data.length > step; + + const isNextButtonEnabled = + (isLoop || currentIndex < data.length - 1) && data.length > step; + + return { + carouselRef, + setScrolling, + width, + height, + style, + data, + carouselProps, + isScrolling, + onScrollEnd, + onPressPrev, + onPressNext, + isPrevButtonEnabled, + isNextButtonEnabled, + }; +}; diff --git a/packages/components/mediaPlayer/MediaPlayerBarRefined.tsx b/packages/components/mediaPlayer/MediaPlayerBarRefined.tsx new file mode 100644 index 0000000000..03fd8125ad --- /dev/null +++ b/packages/components/mediaPlayer/MediaPlayerBarRefined.tsx @@ -0,0 +1,46 @@ +import { AVPlaybackStatusSuccess } from "expo-av"; +import React, { FC } from "react"; +import { View } from "react-native"; + +import { SVG } from "../SVG"; +import { CustomPressable } from "../buttons/CustomPressable"; +import { SpacerRow } from "../spacer"; + +import pauseSVG from "@/assets/icons/pause.svg"; +import playSVG from "@/assets/icons/play.svg"; +import { TimerSliderAlt } from "@/components/mediaPlayer/TimerSliderAlt"; +import { secondaryColor } from "@/utils/style/colors"; +export const MediaPlayerBarRefined: FC<{ + playbackStatus?: AVPlaybackStatusSuccess; + onPressPlayPause: () => void; + isInMediaPlayer: boolean; +}> = ({ playbackStatus, onPressPlayPause }) => { + return ( + + + + + + + + + ); +}; diff --git a/packages/components/mediaPlayer/MediaPlayerVideo.tsx b/packages/components/mediaPlayer/MediaPlayerVideo.tsx index bd3c56874e..c37a763cb5 100644 --- a/packages/components/mediaPlayer/MediaPlayerVideo.tsx +++ b/packages/components/mediaPlayer/MediaPlayerVideo.tsx @@ -14,7 +14,13 @@ import React, { useRef, useState, } from "react"; -import { StyleProp, View, ViewStyle } from "react-native"; +import { + AnimatableNumericValue, + StyleProp, + StyleSheet, + View, + ViewStyle, +} from "react-native"; import { v4 as uuidv4 } from "uuid"; import { TimerSlider } from "./TimerSlider"; @@ -37,7 +43,7 @@ import { useMousePosition } from "@/hooks/useMousePosition"; import { web3ToWeb2URI } from "@/utils/ipfs"; import { prettyMediaDuration } from "@/utils/mediaPlayer"; import { SOCIAl_CARD_BORDER_RADIUS } from "@/utils/social-feed"; -import { neutralA3, secondaryColor } from "@/utils/style/colors"; +import { neutral00, neutralA3, secondaryColor } from "@/utils/style/colors"; import { fontSemibold13 } from "@/utils/style/fonts"; import { layout } from "@/utils/style/layout"; import { SocialFeedVideoMetadata } from "@/utils/types/feed"; @@ -48,6 +54,7 @@ interface MediaPlayerVideoProps { postId?: string; resizeMode?: ResizeMode; style?: StyleProp; + hideControls?: boolean; } const CONTROLS_HEIGHT = 68; @@ -59,16 +66,22 @@ export const MediaPlayerVideo: FC = ({ postId, resizeMode, style, + hideControls, }) => { const { media, onLayoutPlayerVideo } = useMediaPlayer(); const { current: id } = useRef(uuidv4()); - const isInMediaPlayer = useMemo(() => media?.id === id, [id, media?.id]); + // TODO: Really need useMemo here ? + const isInMediaPlayer = useMemo( + () => !!media && (postId === media?.postId || media?.id === id), + [media, postId, id], + ); const containerRef = useRef(null); const videoRef = useRef + + {track?.location && ( + + + track.location && + navigation.navigate("Feed", { + tab: "map", + post: post.id, + }) + } + stroke={neutralFF} + /> + + )} {track?.title || ""} @@ -148,6 +169,12 @@ const imgButtonsBoxStyle: ViewStyle = { justifyContent: "space-between", }; +const positionButtonBoxStyle: ViewStyle = { + position: "absolute", + top: layout.spacing_x1_5, + right: layout.spacing_x1_5, +}; + const contentDescriptionStyle: TextStyle = { ...fontMedium13, color: neutral77, diff --git a/packages/components/music/UploadMusicModal/UploadTrack.tsx b/packages/components/music/UploadMusicModal/UploadTrack.tsx index 782130ab36..c98a7cc2aa 100644 --- a/packages/components/music/UploadMusicModal/UploadTrack.tsx +++ b/packages/components/music/UploadMusicModal/UploadTrack.tsx @@ -8,7 +8,8 @@ import { } from "react-native"; import { useSelector } from "react-redux"; -import Add from "../../../../assets/icons/add-primary.svg"; +import AudioSVG from "../../../../assets/icons/audio.svg"; +import LocationRefinedSvg from "../../../../assets/icons/location-refined.svg"; import { useFeedbacks } from "../../../context/FeedbacksProvider"; import { useWalletControl } from "../../../context/WalletControlProvider"; import { useFeedPosting } from "../../../hooks/feed/useFeedPosting"; @@ -28,6 +29,7 @@ import { import { fontSemibold14 } from "../../../utils/style/fonts"; import { layout } from "../../../utils/style/layout"; import { + CustomLatLngExpression, PostCategory, SocialFeedTrackMetadata, } from "../../../utils/types/feed"; @@ -43,6 +45,7 @@ import { FeedFeeText } from "../../socialFeed/FeedFeeText"; import { SpacerColumn, SpacerRow } from "../../spacer"; import { SelectAudioVideo } from "@/components/mini/SelectAudioVideo"; +import { MapModal } from "@/components/socialFeed/modals/MapModal/MapModal"; import { FeedPostingStepId, feedPostingStep } from "@/utils/feed/posting"; interface Props { @@ -52,7 +55,10 @@ interface Props { const UPLOAD_ALBUM_MODAL_WIDTH = 564; export const UploadTrack: React.FC = ({ onUploadDone }) => { - const { setToastError } = useFeedbacks(); + const [isMapShown, setIsMapShown] = useState(false); + const [location, setLocation] = useState(); + + const { setToast } = useFeedbacks(); const selectedNetwork = useSelectedNetworkInfo(); const selectedWallet = useSelectedWallet(); const userId = selectedWallet?.userId; @@ -82,6 +88,8 @@ export const UploadTrack: React.FC = ({ onUploadDone }) => { const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [localAudioFile, setLocalAudioFile] = useState(); + const isPublishDisabled = + !localAudioFile?.url || !title || isLoading || !canPayForPost; const processCreateMusicAudioPost = async ( track: SocialFeedTrackMetadata, @@ -98,9 +106,11 @@ export const UploadTrack: React.FC = ({ onUploadDone }) => { console.error("post submit err", err); setIsUploadLoading(false); setIsProgressBarShown(false); - setToastError({ + setToast({ title: "Post creation failed", message: err instanceof Error ? err.message : `${err}`, + type: "error", + mode: "normal", }); } }; @@ -135,9 +145,11 @@ export const UploadTrack: React.FC = ({ onUploadDone }) => { userIPFSKey || (await generateIpfsKey(selectedNetwork?.id || "", userId)); if (!pinataJWTKey) { console.error("upload file err : No Pinata JWT"); - setToastError({ + setToast({ title: "File upload failed", message: "No Pinata JWT", + type: "error", + mode: "normal", }); setIsUploadLoading(false); return; @@ -150,9 +162,11 @@ export const UploadTrack: React.FC = ({ onUploadDone }) => { }); if (!uploadedFiles.find((file) => file.url)) { console.error("upload file err : Fail to pin to IPFS"); - setToastError({ + setToast({ title: "File upload failed", message: "Fail to pin to IPFS, please try to Publish again", + type: "error", + mode: "normal", }); setIsUploadLoading(false); return; @@ -161,6 +175,7 @@ export const UploadTrack: React.FC = ({ onUploadDone }) => { title, description, audioFile: uploadedFiles[0], + location, }; await processCreateMusicAudioPost(track); }; @@ -194,7 +209,7 @@ export const UploadTrack: React.FC = ({ onUploadDone }) => { - + {localAudioFile?.url ? ( = ({ onUploadDone }) => { onPress={onPress} disabled={isLoading} > - + Add audio @@ -237,14 +257,31 @@ export const UploadTrack: React.FC = ({ onUploadDone }) => { paddingVertical: layout.spacing_x2, }} > - + Add Audio + Add Audio } /> )} + + setIsMapShown(true)} + disabled={isLoading} + > + + + Handle location + + = ({ onUploadDone }) => { Use. = ({ onUploadDone }) => { )} + + {isMapShown && ( + setIsMapShown(false)} + setLocation={setLocation} + location={location} + postCategory={postCategory} + /> + )} ); }; const buttonContainerStyle: ViewStyle = { - marginTop: layout.spacing_x2_5, + // marginTop: layout.spacing_x2_5, flexDirection: "row", alignItems: "center", justifyContent: "center", height: 40, borderRadius: 999, backgroundColor: neutral30, - marginBottom: layout.spacing_x2, + // marginBottom: layout.spacing_x2, }; const buttonTextStyle: TextStyle = { ...fontSemibold14, diff --git a/packages/components/music/UploadMusicModal/index.tsx b/packages/components/music/UploadMusicModal/index.tsx index 52ceacfb80..7c7921f005 100644 --- a/packages/components/music/UploadMusicModal/index.tsx +++ b/packages/components/music/UploadMusicModal/index.tsx @@ -31,7 +31,7 @@ export const UploadMusicModal: FC<{ {uploadMode === UploadMode.SINGLE_TRACK ? ( ) : ( - <>{/*TODO*/} + <>{/*TODO Album ?*/} )} ); diff --git a/packages/components/socialFeed/Map/Map.tsx b/packages/components/socialFeed/Map/Map.tsx new file mode 100644 index 0000000000..28c3497595 --- /dev/null +++ b/packages/components/socialFeed/Map/Map.tsx @@ -0,0 +1,32 @@ +import { FC } from "react"; +import { LeafletView } from "react-native-leaflet-view"; + +import { MapProps } from "@/components/socialFeed/Map/Map.types"; +import { DEFAULT_MAP_POSITION, MAP_LAYER_URL } from "@/utils/feed/map"; + +// TODO: Map mobile + +export const Map: FC = ({ + creatingPostLocation, + // consultedPostId, TODO: +}) => { + return ( + <>} + zoom={12} + mapCenterPosition={DEFAULT_MAP_POSITION} + mapLayers={[ + { + url: MAP_LAYER_URL, + }, + ]} + mapMarkers={[ + { + position: DEFAULT_MAP_POSITION, + icon: "", + size: [32, 32], + }, + ]} + /> + ); +}; diff --git a/packages/components/socialFeed/Map/Map.types.ts b/packages/components/socialFeed/Map/Map.types.ts new file mode 100644 index 0000000000..629d90bec0 --- /dev/null +++ b/packages/components/socialFeed/Map/Map.types.ts @@ -0,0 +1,10 @@ +import { StyleProp, ViewStyle } from "react-native"; + +import { CustomLatLngExpression, PostCategory } from "@/utils/types/feed"; + +export interface MapProps { + creatingPostLocation?: CustomLatLngExpression; // When the user is adding a location to a post he's creating + creatingPostCategory?: PostCategory; // When the user is adding a location to a post he's creating + consultedPostId?: string; // When the user want to consult a post on the map (By clicking on LocationButton) + style?: StyleProp; +} diff --git a/packages/components/socialFeed/Map/Map.web.tsx b/packages/components/socialFeed/Map/Map.web.tsx new file mode 100644 index 0000000000..e1ef7c6534 --- /dev/null +++ b/packages/components/socialFeed/Map/Map.web.tsx @@ -0,0 +1,286 @@ +import "./styles.css"; +import "leaflet/dist/leaflet.css"; +import { DivIcon, LatLngBounds, point, PointExpression } from "leaflet"; +import { + Dispatch, + FC, + SetStateAction, + useEffect, + useMemo, + useState, +} from "react"; +import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet"; +import MarkerClusterGroup from "react-leaflet-cluster"; +import "leaflet.markercluster"; +import { HeatmapLayer } from "react-leaflet-heatmap-layer-v3/lib"; +import { View } from "react-native"; + +import { Post } from "@/api/feed/v1/feed"; +import { MapProps } from "@/components/socialFeed/Map/Map.types"; +import { ArticleMapPost } from "@/components/socialFeed/Map/MapPosts/ArticleMapPost"; +import { MusicMapPost } from "@/components/socialFeed/Map/MapPosts/MusicMapPost"; +import { NormalMapPost } from "@/components/socialFeed/Map/MapPosts/NormalMapPost"; +import { PictureMapPost } from "@/components/socialFeed/Map/MapPosts/PictureMapPost"; +import { VideoMapPost } from "@/components/socialFeed/Map/MapPosts/VideoMapPost"; +import { useFetchFeedLocation } from "@/hooks/feed/useFetchFeed"; +import { usePost } from "@/hooks/feed/usePost"; +import { useSelectedNetworkId } from "@/hooks/useSelectedNetwork"; +import { + DEFAULT_MAP_POSITION, + getMapPostIconColorRgba, + getMapPostIconSVGString, + MAP_LAYER_URL, +} from "@/utils/feed/map"; +import { zodTryParseJSON } from "@/utils/sanitize"; +import { + CustomLatLngExpression, + PostCategory, + zodSocialFeedCommonMetadata, + ZodSocialFeedPostMetadata, +} from "@/utils/types/feed"; + +interface MarkerPopup { + position: CustomLatLngExpression; + post: Post; + fileURL?: string; + isHighlighted?: boolean; +} +interface MapManagerProps { + setBounds: Dispatch>; + creatingPostLocation?: CustomLatLngExpression; + consultedPostLocation?: CustomLatLngExpression; +} +const MapManager = ({ + setBounds, + creatingPostLocation, + consultedPostLocation, +}: MapManagerProps) => { + const map = useMap(); + const [isMapReady, setMapReady] = useState(false); + const [isConsultedPostConsulted, setConsultedPostConsulted] = useState(false); + + useEffect(() => { + const updateBounds = () => { + setBounds(map.getBounds()); + }; + + // Updates map bounds when ready (Once) + map.whenReady(() => { + if (!isMapReady) { + updateBounds(); + setMapReady(true); + } + }); + + // Updates map bounds on map manipulation + map.on("moveend", updateBounds); + map.on("zoomend", updateBounds); + + // Center to creatingPostLocation when it's updated + if (creatingPostLocation) { + map.setView(creatingPostLocation); + } + // Center to consultedPostLocation when it's updated (Once) + if (consultedPostLocation && !isConsultedPostConsulted) { + map.setView(consultedPostLocation); + setConsultedPostConsulted(true); + } + + // Clean listeners + return () => { + map.off("moveend", updateBounds); + map.off("zoomend", updateBounds); + }; + }, [ + map, + isMapReady, + setBounds, + creatingPostLocation, + consultedPostLocation, + isConsultedPostConsulted, + ]); + + return null; +}; + +export const Map: FC = ({ + consultedPostId, + style, + creatingPostLocation, + creatingPostCategory = -1, +}) => { + const selectedNetworkId = useSelectedNetworkId(); + const [bounds, setBounds] = useState(null); + + // Fetch the consulted post + const { post: consultedPost } = usePost(consultedPostId); + const consultedPostBaseMetadata = + consultedPost && + zodTryParseJSON(zodSocialFeedCommonMetadata, consultedPost.metadata); + const consultedPostLocation = consultedPostBaseMetadata?.location; + + // Fetch existing posts that have a location and display them as markers + const { data } = useFetchFeedLocation({ + north: bounds?.getNorth(), + south: bounds?.getSouth(), + west: bounds?.getWest(), + east: bounds?.getEast(), + networkId: selectedNetworkId, + }); + const posts = data?.list; + const aggregatedPosts = data?.aggregations; + + // Markers + const markers: MarkerPopup[] = useMemo(() => { + if (!posts) return []; + const results: MarkerPopup[] = []; + posts.forEach((post, index) => { + const metadata = zodTryParseJSON( + ZodSocialFeedPostMetadata, + post.metadata, + ); + if (!metadata?.location) return; + results.push({ + position: metadata.location, + post, + isHighlighted: post.id === consultedPostId, + }); + }); + return results; + }, [posts, consultedPostId]); + + // Heatmap + const heatPoints = aggregatedPosts + ? aggregatedPosts.map((aggregatedPost) => { + return [ + aggregatedPost.lat, + aggregatedPost.long, + aggregatedPost.totalPoints, + ]; + }) + : []; + + const borderClass = "icon-border"; + const borderHighlightedClassFlag = "--highlighted"; + // Custom map post icon + const postIcon = (postCategory: PostCategory, isHighlighted?: boolean) => { + const size = 32; + const borderWidth = 1; + const sizeWithBorders = 32 + borderWidth * 2; + return new DivIcon({ + html: `
${getMapPostIconSVGString(postCategory)}
`, + className: "", + iconSize: [sizeWithBorders, sizeWithBorders], + }); + }; + + // Custom cluster icon + const clusterIcon = (cluster: any) => { + const isHighlighted = cluster + .getAllChildMarkers() + .some((child: any) => + child.options.icon.options.html.includes("icon-border--highlighted"), + ); + return new DivIcon({ + html: `
${cluster.getChildCount()}
`, + className: "custom-marker-cluster", + iconSize: point(33, 33, true) as PointExpression, + }); + }; + + return ( + + + {/*----Loads and displays tiles on the map*/} + + {/*---- Heatmap displayed when dezoom*/} + + Array.isArray(point) && typeof point[2] === "string" + ? parseFloat(point[2]) + : 0 + } + longitudeExtractor={(point) => + Array.isArray(point) && typeof point[1] === "number" ? point[1] : 0 + } + latitudeExtractor={(point) => + Array.isArray(point) && typeof point[0] === "number" ? point[0] : 0 + } + /> + {/*---- Existing posts that have a location*/} + + {/* When the user is creating a post*/} + {creatingPostLocation && ( + + )} + {/* Mapping through the markers (Fetched posts) */} + {markers?.map((marker, index) => ( + + {marker.post.category === PostCategory.Normal ? ( + + + + ) : marker.post.category === PostCategory.MusicAudio || + marker.post.category === PostCategory.Audio ? ( + + + + ) : marker.post.category === PostCategory.VideoNote || + marker.post.category === PostCategory.Video ? ( + + + + ) : marker.post.category === PostCategory.Picture ? ( + + + + ) : marker.post.category === PostCategory.Article ? ( + + + + ) : null} + + ))} + + + + + ); +}; diff --git a/packages/components/socialFeed/Map/MapPosts/ArticleMapPost.tsx b/packages/components/socialFeed/Map/MapPosts/ArticleMapPost.tsx new file mode 100644 index 0000000000..0349acc156 --- /dev/null +++ b/packages/components/socialFeed/Map/MapPosts/ArticleMapPost.tsx @@ -0,0 +1,63 @@ +import React, { FC } from "react"; +import { View } from "react-native"; + +import { Post } from "@/api/feed/v1/feed"; +import { BrandText } from "@/components/BrandText"; +import { Separator } from "@/components/separators/Separator"; +import { MapPostWrapper } from "@/components/socialFeed/Map/MapPosts/MapPostWrapper"; +import { + createStateFromHTML, + getTruncatedArticleHTML, + isArticleHTMLNeedsTruncate, +} from "@/components/socialFeed/RichText"; +import { SpacerColumn } from "@/components/spacer"; +import { zodTryParseJSON } from "@/utils/sanitize"; +import { neutralFF, withAlpha } from "@/utils/style/colors"; +import { fontSemibold10 } from "@/utils/style/fonts"; +import { + ZodSocialFeedArticleMetadata, + ZodSocialFeedPostMetadata, +} from "@/utils/types/feed"; +export const ArticleMapPost: FC<{ + post: Post; +}> = ({ post }) => { + const metadata = zodTryParseJSON(ZodSocialFeedArticleMetadata, post.metadata); + const oldMetadata = zodTryParseJSON(ZodSocialFeedPostMetadata, post.metadata); + const metadataToUse = metadata || oldMetadata; + const title = metadataToUse?.title || "Article from Social Feed"; + + const shortDescription = () => { + const message = metadataToUse?.message; + if (metadata?.shortDescription) { + return metadata.shortDescription; + } + if (!message) return ""; + if (isArticleHTMLNeedsTruncate(message, true)) { + const { truncatedHtml } = getTruncatedArticleHTML(message); + const contentState = + createStateFromHTML(truncatedHtml).getCurrentContent(); + return ( + metadata?.shortDescription || + // Old articles doesn't have shortDescription, so we use the start of the html content + contentState.getPlainText() + ); + } + return ""; + }; + + return ( + + + {title} + + + + + + + {shortDescription().trim().replace("\n", " ")} + + + + ); +}; diff --git a/packages/components/socialFeed/Map/MapPosts/MapPostWrapper.tsx b/packages/components/socialFeed/Map/MapPosts/MapPostWrapper.tsx new file mode 100644 index 0000000000..5a8539aae6 --- /dev/null +++ b/packages/components/socialFeed/Map/MapPosts/MapPostWrapper.tsx @@ -0,0 +1,73 @@ +import { FC, ReactNode, useState } from "react"; +import { StyleProp, View, ViewStyle } from "react-native"; + +import { Post } from "@/api/feed/v1/feed"; +import FlexRow from "@/components/FlexRow"; +import { SVG } from "@/components/SVG"; +import { CustomPressable } from "@/components/buttons/CustomPressable"; +import { GradientText } from "@/components/gradientText"; +import { SpacerColumn, SpacerRow } from "@/components/spacer"; +import { useNSUserInfo } from "@/hooks/useNSUserInfo"; +import { parseUserId } from "@/networks"; +import { + getMapPostIconSVG, + getMapPostTextGradientType, +} from "@/utils/feed/map"; +import { useAppNavigation } from "@/utils/navigation"; +import { DEFAULT_USERNAME } from "@/utils/social-feed"; +import { fontSemibold10 } from "@/utils/style/fonts"; +import { layout } from "@/utils/style/layout"; +import { tinyAddress } from "@/utils/text"; + +export const MapPostWrapper: FC<{ + post: Post; + children: ReactNode; + style?: StyleProp; +}> = ({ post, children, style }) => { + const navigation = useAppNavigation(); + const [isHeaderHovered, setHeaderHovered] = useState(false); + const [, authorAddress] = parseUserId(post.authorId); + const authorNSInfo = useNSUserInfo(post.authorId); + const username = + authorNSInfo?.metadata?.tokenId || + tinyAddress(authorAddress) || + DEFAULT_USERNAME; + + return ( + + navigation.navigate("FeedPostView", { id: post.id })} + style={{ opacity: isHeaderHovered ? 0.5 : 1 }} + onHoverOut={() => setHeaderHovered(false)} + onHoverIn={() => setHeaderHovered(true)} + > + + + + + + @{username} + + + + + + + {children} + + ); +}; diff --git a/packages/components/socialFeed/Map/MapPosts/MusicMapPost.tsx b/packages/components/socialFeed/Map/MapPosts/MusicMapPost.tsx new file mode 100644 index 0000000000..7a66e8c39c --- /dev/null +++ b/packages/components/socialFeed/Map/MapPosts/MusicMapPost.tsx @@ -0,0 +1,89 @@ +import React, { FC, useRef } from "react"; +import { View } from "react-native"; +import { v4 as uuidv4 } from "uuid"; + +import { Post } from "@/api/feed/v1/feed"; +import { BrandText } from "@/components/BrandText"; +import { MediaPlayerBarRefined } from "@/components/mediaPlayer/MediaPlayerBarRefined"; +import { Separator } from "@/components/separators/Separator"; +import { MapPostWrapper } from "@/components/socialFeed/Map/MapPosts/MapPostWrapper"; +import { SpacerColumn } from "@/components/spacer"; +import { useMediaPlayer } from "@/context/MediaPlayerProvider"; +import { zodTryParseJSON } from "@/utils/sanitize"; +import { errorColor, neutralFF, withAlpha } from "@/utils/style/colors"; +import { fontSemibold10 } from "@/utils/style/fonts"; +import { + zodSocialFeedCommonMetadata, + ZodSocialFeedPostMetadata, + ZodSocialFeedTrackMetadata, +} from "@/utils/types/feed"; +import { Media } from "@/utils/types/mediaPlayer"; + +export const MusicMapPost: FC<{ + post: Post; +}> = ({ post }) => { + const { current: id } = useRef(uuidv4()); + const { handlePlayPause, playbackStatus, loadAndPlaySoundsQueue, media } = + useMediaPlayer(); + const isInMediaPlayer = + !!media && (post.id === media.postId || media.id === id); + const musicAudioNotePostMetadata = zodTryParseJSON( + ZodSocialFeedTrackMetadata, + post.metadata, + ); + const musicPostMetadata = zodTryParseJSON( + ZodSocialFeedPostMetadata, + post.metadata, + ); + const baseMetadata = zodTryParseJSON( + zodSocialFeedCommonMetadata, + post.metadata, + ); + const title = baseMetadata?.title || "Music from Social Feed"; + + // MusicAudio and Music have different metadata but have the same render on the map, so we handle these 2 cases + const mediaToPlay: Media | undefined = musicAudioNotePostMetadata + ? { + id, + fileUrl: musicAudioNotePostMetadata.audioFile.url, + duration: musicAudioNotePostMetadata.audioFile.audioMetadata?.duration, + postId: post.id, + } + : musicPostMetadata?.files + ? { + id, + fileUrl: musicPostMetadata.files[0].url, + duration: musicPostMetadata.files[0].audioMetadata?.duration, + postId: post.id, + } + : undefined; + + const onPressPlayPause = async () => { + if (isInMediaPlayer) await handlePlayPause(); + else if (mediaToPlay) await loadAndPlaySoundsQueue([mediaToPlay]); + }; + + return ( + + + {title} + + + + + + {mediaToPlay ? ( + + ) : ( + + No media to play + + )} + + + ); +}; diff --git a/packages/components/socialFeed/Map/MapPosts/NormalMapPost.tsx b/packages/components/socialFeed/Map/MapPosts/NormalMapPost.tsx new file mode 100644 index 0000000000..452e3246e5 --- /dev/null +++ b/packages/components/socialFeed/Map/MapPosts/NormalMapPost.tsx @@ -0,0 +1,37 @@ +import { FC } from "react"; + +import { Post } from "@/api/feed/v1/feed"; +import { BrandText } from "@/components/BrandText"; +import { MapPostWrapper } from "@/components/socialFeed/Map/MapPosts/MapPostWrapper"; +import { TextRenderer } from "@/components/socialFeed/NewsFeed/TextRenderer/TextRenderer"; +import { HTML_TAG_REGEXP } from "@/utils/regex"; +import { zodTryParseJSON } from "@/utils/sanitize"; +import { errorColor, neutralFF } from "@/utils/style/colors"; +import { fontSemibold10 } from "@/utils/style/fonts"; +import { ZodSocialFeedPostMetadata } from "@/utils/types/feed"; + +export const NormalMapPost: FC<{ + post: Post; +}> = ({ post }) => { + const postMetadata = zodTryParseJSON( + ZodSocialFeedPostMetadata, + post.metadata, + ); + + return ( + + {postMetadata ? ( + + ) : ( + + Wrong post format + + )} + + ); +}; diff --git a/packages/components/socialFeed/Map/MapPosts/PictureMapPost.tsx b/packages/components/socialFeed/Map/MapPosts/PictureMapPost.tsx new file mode 100644 index 0000000000..e7818fdb33 --- /dev/null +++ b/packages/components/socialFeed/Map/MapPosts/PictureMapPost.tsx @@ -0,0 +1,63 @@ +import React, { FC } from "react"; + +import { Post } from "@/api/feed/v1/feed"; +import { BrandText } from "@/components/BrandText"; +import { OptimizedImage } from "@/components/OptimizedImage"; +import { SmallCarouselAlt } from "@/components/carousels/SmallCarousel/SmallCarouselAlt"; +import { MapPostWrapper } from "@/components/socialFeed/Map/MapPosts/MapPostWrapper"; +import { GIF_URL_REGEX } from "@/utils/regex"; +import { zodTryParseJSON } from "@/utils/sanitize"; +import { errorColor } from "@/utils/style/colors"; +import { fontSemibold10 } from "@/utils/style/fonts"; +import { ZodSocialFeedPostMetadata } from "@/utils/types/feed"; + +export const PictureMapPost: FC<{ + post: Post; +}> = ({ post }) => { + const postMetadata = zodTryParseJSON( + ZodSocialFeedPostMetadata, + post.metadata, + ); + const imagesFiles = postMetadata?.files?.filter( + (file) => file.fileType === "image" || file.fileType === "base64", + ); + + return ( + + {!imagesFiles?.length && !postMetadata?.gifs?.length ? ( + + No image found + + ) : ( + + !item.url && !GIF_URL_REGEX.test(item) ? ( + + Image not found + + ) : ( + + ) + } + /> + )} + + ); +}; diff --git a/packages/components/socialFeed/Map/MapPosts/VideoMapPost.tsx b/packages/components/socialFeed/Map/MapPosts/VideoMapPost.tsx new file mode 100644 index 0000000000..0543c18f7a --- /dev/null +++ b/packages/components/socialFeed/Map/MapPosts/VideoMapPost.tsx @@ -0,0 +1,166 @@ +import { + AVPlaybackStatus, + AVPlaybackStatusSuccess, + ResizeMode, + Video, +} from "expo-av"; +import React, { FC, useRef, useState } from "react"; +import { View } from "react-native"; +import { v4 as uuidv4 } from "uuid"; + +import { Post } from "@/api/feed/v1/feed"; +import { BrandText } from "@/components/BrandText"; +import { MediaPlayerBarRefined } from "@/components/mediaPlayer/MediaPlayerBarRefined"; +import { Separator } from "@/components/separators/Separator"; +import { MapPostWrapper } from "@/components/socialFeed/Map/MapPosts/MapPostWrapper"; +import { SpacerColumn } from "@/components/spacer"; +import { useFeedbacks } from "@/context/FeedbacksProvider"; +import { useMediaPlayer } from "@/context/MediaPlayerProvider"; +import { web3ToWeb2URI } from "@/utils/ipfs"; +import { zodTryParseJSON } from "@/utils/sanitize"; +import { errorColor, neutralFF, withAlpha } from "@/utils/style/colors"; +import { fontSemibold10 } from "@/utils/style/fonts"; +import { + SocialFeedVideoMetadata, + zodSocialFeedCommonMetadata, + ZodSocialFeedPostMetadata, + ZodSocialFeedVideoMetadata, +} from "@/utils/types/feed"; +import { Media } from "@/utils/types/mediaPlayer"; + +export const VideoMapPost: FC<{ + post: Post; +}> = ({ post }) => { + const { current: id } = useRef(uuidv4()); + const videoRef = useRef