diff --git a/front/components/Hoverable.ts b/front/components/Hoverable.ts new file mode 100644 index 0000000..75ce45f --- /dev/null +++ b/front/components/Hoverable.ts @@ -0,0 +1,135 @@ +// credit to https://gist.github.com/ianmartorell/32bb7df95e5eff0a5ee2b2f55095e6a6 +// this file was repurosed from there +// via this issue https://gist.github.com/necolas/1c494e44e23eb7f8c5864a2fac66299a +// because RNW's pressable doesn't bubble events to parent pressables: https://github.com/necolas/react-native-web/issues/1875 + +/* eslint-disable no-inner-declarations */ +import { canUseDOM } from 'fbjs/lib/ExecutionEnvironment'; + +let isEnabled = false; + +if (canUseDOM) { + /** + * Web browsers emulate mouse events (and hover states) after touch events. + * This code infers when the currently-in-use modality supports hover + * (including for multi-modality devices) and considers "hover" to be enabled + * if a mouse movement occurs more than 1 second after the last touch event. + * This threshold is long enough to account for longer delays between the + * browser firing touch and mouse events on low-powered devices. + */ + const HOVER_THRESHOLD_MS = 1000; + let lastTouchTimestamp = 0; + + function enableHover() { + if (isEnabled || Date.now() - lastTouchTimestamp < HOVER_THRESHOLD_MS) { + return; + } + isEnabled = true; + } + + function disableHover() { + lastTouchTimestamp = Date.now(); + if (isEnabled) { + isEnabled = false; + } + } + + document.addEventListener('touchstart', disableHover, true); + document.addEventListener('touchmove', disableHover, true); + document.addEventListener('mousemove', enableHover, true); +} + +function isHoverEnabled(): boolean { + return isEnabled; +} + +import React, { useCallback, ReactChild, useRef } from 'react'; +import { useSharedValue, useAnimatedReaction } from 'react-native-reanimated'; +import { Platform } from 'react-native'; + +export interface HoverableProps { + onHoverIn?: () => void; + onHoverOut?: () => void; + onPressIn?: () => void; + onPressOut?: () => void; + children: NonNullable; +} + +export default function Hoverable({ + onHoverIn, + onHoverOut, + children, + onPressIn, + onPressOut, +}: HoverableProps) { + const showHover = useSharedValue(true); + const isHovered = useSharedValue(false); + + const hoverIn = useRef void)>(() => onHoverIn?.()); + const hoverOut = useRef void)>(() => onHoverOut?.()); + const pressIn = useRef void)>(() => onPressIn?.()); + const pressOut = useRef void)>(() => onPressOut?.()); + + hoverIn.current = onHoverIn; + hoverOut.current = onHoverOut; + pressIn.current = onPressIn; + pressOut.current = onPressOut; + + useAnimatedReaction( + () => { + return Platform.OS === 'web' && showHover.value && isHovered.value; + }, + (hovered, previouslyHovered) => { + if (hovered !== previouslyHovered) { + if (hovered && hoverIn.current) { + // no need for runOnJS, it's always web + hoverIn.current(); + } else if (hoverOut.current) { + hoverOut.current(); + } + } + }, + [] + ); + + const handleMouseEnter = useCallback(() => { + if (isHoverEnabled() && !isHovered.value) { + isHovered.value = true; + } + }, [isHovered]); + + const handleMouseLeave = useCallback(() => { + if (isHovered.value) { + isHovered.value = false; + } + }, [isHovered]); + + const handleGrant = useCallback(() => { + showHover.value = false; + pressIn.current?.(); + }, [showHover]); + + const handleRelease = useCallback(() => { + showHover.value = true; + pressOut.current?.(); + }, [showHover]); + + let webProps = {}; + if (Platform.OS === 'web') { + webProps = { + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, + // prevent hover showing while responder + onResponderGrant: handleGrant, + onResponderRelease: handleRelease, + }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return React.cloneElement(React.Children.only(children) as any, { + ...webProps, + // if child is Touchable + onPressIn: handleGrant, + onPressOut: handleRelease, + }); +} diff --git a/front/components/UI/IconButton.tsx b/front/components/UI/IconButton.tsx index d20f8fa..a2c9d02 100644 --- a/front/components/UI/IconButton.tsx +++ b/front/components/UI/IconButton.tsx @@ -195,12 +195,12 @@ const IconButton: React.FC = ({ Animated.timing(scaleValue, { toValue: scaleFactor, duration: animationDuration, - useNativeDriver: true, + useNativeDriver: false, }), Animated.timing(scaleValue, { toValue: 1, duration: animationDuration, - useNativeDriver: true, + useNativeDriver: false, }), ]), ]; diff --git a/front/components/UI/InteractiveCC.tsx b/front/components/UI/InteractiveCC.tsx index 6df5a82..c792e33 100644 --- a/front/components/UI/InteractiveCC.tsx +++ b/front/components/UI/InteractiveCC.tsx @@ -124,7 +124,7 @@ const InteractiveCC: React.FC = ({ Animated.timing(animatedValues[key]!, { toValue: stateValue, duration: duration, - useNativeDriver: true, + useNativeDriver: false, }).start(); }); }; diff --git a/front/components/V2/SongCardInfo.tsx b/front/components/V2/SongCardInfo.tsx index 398b9d7..bcf7488 100644 --- a/front/components/V2/SongCardInfo.tsx +++ b/front/components/V2/SongCardInfo.tsx @@ -1,12 +1,21 @@ import Song from '../../models/Song'; import React from 'react'; -import { Image, View } from 'react-native'; -import { Pressable, Text, IconButton, PresenceTransition, Icon, useBreakpointValue } from 'native-base'; +import { Image, Platform, View } from 'react-native'; +import { + Pressable, + Text, + IconButton, + PresenceTransition, + Icon, + useBreakpointValue, +} from 'native-base'; +import { LikeButton } from './SongCardInfoLikeBtn'; import { Ionicons } from '@expo/vector-icons'; import { useQuery } from '../../Queries'; import API from '../../API'; import { MaterialIcons } from '@expo/vector-icons'; import { useLikeSongMutation } from '../../utils/likeSongMutation'; +import Hoverable from '../Hoverable'; type SongCardInfoProps = { song: Song; @@ -21,7 +30,7 @@ const SongCardInfo = (props: SongCardInfoProps) => { const [isHovered, setIsHovered] = React.useState(false); const [isSlided, setIsSlided] = React.useState(false); const user = useQuery(API.getUserInfo); - const isLiked = (props.song.likedByUsers ?? []).filter(({ userId }) => userId === user.data?.id).length > 0; + const [isLiked, setIsLiked] = React.useState(false); const { mutate } = useLikeSongMutation(); const CardDims = { @@ -32,16 +41,27 @@ const SongCardInfo = (props: SongCardInfoProps) => { const Scores = [ { icon: 'time', - score: props.song.lastScore ?? '?', + score: props.song.lastScore ?? '-', }, { icon: 'trophy', - score: props.song.bestScore ?? '?', + score: props.song.bestScore ?? '-', }, ]; + React.useEffect(() => { + if (!user.data) { + return; + } + setIsLiked( + props.song.likedByUsers?.some(({ userId }) => userId === user.data?.id) ?? false + ); + }, [user.data, props.song.likedByUsers]); + return ( - { backgroundColor: 'rgba(16, 16, 20, 0.70)', borderRadius: 12, overflow: 'hidden', + position: 'relative', + }} + onHoverIn={() => { + setIsHovered(true); + }} + onHoverOut={() => { + setIsHovered(false); + setIsSlided(false); }} > - { - setIsHovered(true); - }} - onHoverOut={() => { - setIsHovered(false); - setIsSlided(false); + width: CardDims.width, + height: CardDims.height, + backgroundColor: 'rgba(16, 16, 20, 0.7)', + borderRadius: 12, + display: 'flex', + justifyContent: 'flex-end', + alignItems: 'center', }} > - <> - + {Scores.map((score, idx) => ( + + + + {score.score} + + + ))} + + + { + if (isHovered) { + setIsSlided(true); + } + }} + > + + - - {Scores.map((score, idx) => ( - - - - {score.score} - - - ))} - - - + { - if (isHovered) { - setIsSlided(true); - } + backgroundColor: 'rgba(0, 0, 0, 0.75)', + justifyContent: 'flex-end', + alignItems: 'flex-start', + paddingHorizontal: 10, + paddingVertical: 7, + borderRadius: 12, }} > - - - - {props.song.name} - - - {props.song.artist?.name} - - - { - mutate({ songId: props.song.id, like: !isLiked }); + {props.song.name} + + + > + {props.song.artist?.name} + + + { + console.log('like'); + mutate({ songId: props.song.id, like: !isLiked }); + }} + isLiked={isLiked} + /> - - + + + {Platform.OS === 'web' && ( + + { + setIsPlayHovered(true); }} - visible={isSlided} - initial={{ - opacity: 0, - }} - animate={{ - opacity: 1, + onHoverOut={() => { + setIsPlayHovered(false); }} > { + e.stopPropagation(); + props.onPress(); + }} style={{ - position: 'absolute', - width: '100%', - height: '100%', + width: 40, + height: 40, + borderRadius: 100, display: 'flex', - justifyContent: 'flex-end', + justifyContent: 'center', alignItems: 'center', + backgroundColor: isPlayHovered + ? 'rgba(96, 117, 249, 0.9)' + : 'rgba(96, 117, 249, 0.7)', }} > - { - setIsPlayHovered(true); - }} - onHoverOut={() => { - setIsPlayHovered(false); - }} - borderRadius={100} - marginBottom={35} - onPress={props.onPlay} - > - {({ isPressed, isHovered }) => ( - { - if (isPressed) { - return 'rgba(96, 117, 249, 1)'; - } else if (isHovered) { - return 'rgba(96, 117, 249, 0.9)'; - } else { - return 'rgba(96, 117, 249, 0.7)'; - } - })(), - }} - > - - - )} - + - - - - + + + )} + ); }; diff --git a/front/components/V2/SongCardInfoLikeBtn.tsx b/front/components/V2/SongCardInfoLikeBtn.tsx new file mode 100644 index 0000000..48db585 --- /dev/null +++ b/front/components/V2/SongCardInfoLikeBtn.tsx @@ -0,0 +1,37 @@ +import { Platform, View } from 'react-native'; +import { IconButton } from 'native-base'; +import { MaterialIcons } from '@expo/vector-icons'; + +type LikeButtonProps = { + isLiked: boolean; + onPress?: () => void; + color?: string; +}; + +export const LikeButton = ({ isLiked, color, onPress }: LikeButtonProps) => { + if (Platform.OS === 'web') { + // painful error of no onHover event control + return ( + + + + ); + } + return ( + + ); +}; diff --git a/front/package.json b/front/package.json index f221312..c821fb2 100644 --- a/front/package.json +++ b/front/package.json @@ -34,6 +34,7 @@ "expo-splash-screen": "~0.20.5", "expo-status-bar": "~1.6.0", "expo-system-ui": "~2.4.0", + "fbjs": "^3.0.5", "i18next": "^23.5.1", "iconsax-react-native": "^0.0.8", "native-base": "^3.4.28", diff --git a/front/yarn.lock b/front/yarn.lock index 47afdeb..6ce02fc 100644 --- a/front/yarn.lock +++ b/front/yarn.lock @@ -6070,7 +6070,7 @@ fbjs-css-vars@^1.0.0: resolved "https://registry.yarnpkg.com/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz#216551136ae02fe255932c3ec8775f18e2c078b8" integrity sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ== -fbjs@^3.0.0, fbjs@^3.0.4: +fbjs@^3.0.0, fbjs@^3.0.4, fbjs@^3.0.5: version "3.0.5" resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-3.0.5.tgz#aa0edb7d5caa6340011790bd9249dbef8a81128d" integrity sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==