Skip to content
This repository has been archived by the owner on Oct 4, 2023. It is now read-only.

[PAY-1430] "Leaving Audius" Warning for External Links (and createModal helper) #3860

Merged
merged 14 commits into from
Aug 10, 2023
10 changes: 10 additions & 0 deletions packages/common/src/store/reducers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ import deletePlaylistConfirmationReducer from './ui/delete-playlist-confirmation
import { DeletePlaylistConfirmationModalState } from './ui/delete-playlist-confirmation-modal/types'
import duplicateAddConfirmationReducer from './ui/duplicate-add-confirmation-modal/slice'
import { DuplicateAddConfirmationModalState } from './ui/duplicate-add-confirmation-modal/types'
import {
LeavingAudiusModalState,
leavingAudiusModalReducer
} from './ui/leaving-audius-modal'
import mobileOverflowModalReducer from './ui/mobile-overflow-menu/slice'
import { MobileOverflowModalState } from './ui/mobile-overflow-menu/types'
import modalsReducer from './ui/modals/slice'
Expand Down Expand Up @@ -191,6 +195,9 @@ export const reducers = () => ({
publishPlaylistConfirmationModal: publishPlaylistConfirmationReducer,
mobileOverflowModal: mobileOverflowModalReducer,
modals: modalsReducer,
modalsWithState: combineReducers({
leavingAudiusModal: leavingAudiusModalReducer
}),
musicConfetti: musicConfettiReducer,
nowPlaying: nowPlayingReducer,
reactions: reactionsReducer,
Expand Down Expand Up @@ -312,6 +319,9 @@ export type CommonState = {
publishPlaylistConfirmationModal: PublishPlaylistConfirmationModalState
mobileOverflowModal: MobileOverflowModalState
modals: ModalsState
modalsWithState: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would the plan be to have models just all unified at some point?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah this could use some discussion. We have other modals just toplevel here, so we could do the same and remove this nesting.

could probably also preemptively merge into modals by moving existing modals from ModalName: boolean | 'closing' to ModalName: { isOpen: boolean | 'closing' } 🤔 Should be a straightforward migration, just some types and underlying store structure, all the call sites stay the same

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice if modal state could vary in shape. It seems like we have some base modal state (isOpen, onClose, etc), and then state we are passing to modals through reducers for the logic specific to the modal. Maybe those things don't have to co-exist?

Anyway yes, worth some discussion. Would like to get the three ways of doing this down to one 😅

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The coexistence was a big part of the motivation behind the createModal helper, but if we don't want that then maybe there's no need for the helper and we go back to separate reducers etc

leavingAudiusModal: LeavingAudiusModalState
}
musicConfetti: MusicConfettiState
nowPlaying: NowPlayingState
reactions: ReactionsState
Expand Down
6 changes: 6 additions & 0 deletions packages/common/src/store/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,9 @@ export {
CreateChatModalState
} from './create-chat-modal/slice'
export * as createChatModalSelectors from './create-chat-modal/selectors'

export {
leavingAudiusModalReducer,
useLeavingAudiusModal,
LeavingAudiusModalState
} from './leaving-audius-modal'
17 changes: 17 additions & 0 deletions packages/common/src/store/ui/leaving-audius-modal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { createModal } from '../modals/createModal'

export type LeavingAudiusModalState = {
link: string
}

const leavingAudiusModal = createModal<LeavingAudiusModalState>({
reducerPath: 'leavingAudiusModal',
initialState: {
isOpen: false,
link: ''
},
sliceSelector: (state) => state.ui.modalsWithState
})

export const useLeavingAudiusModal = leavingAudiusModal.hook
export const leavingAudiusModalReducer = leavingAudiusModal.reducer
97 changes: 97 additions & 0 deletions packages/common/src/store/ui/modals/createModal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { useCallback, useRef } from 'react'

import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { useDispatch, useSelector } from 'react-redux'

import { CommonState } from 'store/index'

export type BaseModalState = {
rickyrombo marked this conversation as resolved.
Show resolved Hide resolved
isOpen: boolean
}

/**
* Creates the necessary actions/reducers/selectors for a modal,
* and returns the reducer to be wired to the store
* and hook to be used in the application.
*/
export const createModal = <T>({
reducerPath,
initialState,
sliceSelector
}: {
reducerPath: string
initialState: T & BaseModalState
sliceSelector?: (state: CommonState) => Record<string, any>
}) => {
const slice = createSlice({
name: `modals/${reducerPath}`,
initialState,
reducers: {
setState: (_, action: PayloadAction<T & BaseModalState>) => {
return action.payload
}
}
})

const selector = (state: CommonState) => {
let baseState: (T & BaseModalState) | undefined
if (sliceSelector) {
baseState = sliceSelector(state)[reducerPath]
} else {
baseState = state[reducerPath]
}
if (!baseState) {
throw new Error(
`State for ${reducerPath} is undefined - did you forget to register the reducer in @audius/common/src/modals/reducers.ts?`
rickyrombo marked this conversation as resolved.
Show resolved Hide resolved
)
}
return baseState
}

// Need to explicitly retype this because TS got confused
const setState = slice.actions.setState as (
state: T & BaseModalState
) => PayloadAction<T & BaseModalState>

/**
* A hook that returns the state of the modal,
* a setter callback that opens the modal,
* and a close callback that clears the state and closes it.
* @returns [state, open, close] state of the modal, callback to open the modal, callback to close the modal
*/
const useModal = () => {
// Use a ref to prevent flickers on close when state is cleared.
rickyrombo marked this conversation as resolved.
Show resolved Hide resolved
// Only reflect state changes on modal open.
const lastOpenedState = useRef<T & BaseModalState>(initialState)
const currentState = useSelector(selector)
if (currentState.isOpen) {
lastOpenedState.current = currentState
} else {
// Keep existing state, except now set visible to false
lastOpenedState.current = {
...lastOpenedState.current,
isOpen: false
}
}
const dispatch = useDispatch()
const open = useCallback(
(state?: T) => {
if (!state) {
dispatch(setState({ ...initialState, isOpen: true }))
rickyrombo marked this conversation as resolved.
Show resolved Hide resolved
} else {
dispatch(setState({ ...state, isOpen: true }))
}
},
[dispatch]
)
const close = useCallback(() => {
dispatch(setState({ ...initialState, isOpen: false }))
}, [dispatch])
return [lastOpenedState.current, open, close] as const
}

return {
hook: useModal,
reducer: slice.reducer
} as const
rickyrombo marked this conversation as resolved.
Show resolved Hide resolved
}
2 changes: 2 additions & 0 deletions packages/mobile/src/app/Drawers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { FeedFilterDrawer } from 'app/components/feed-filter-drawer'
import { ForgotPasswordDrawer } from 'app/components/forgot-password-drawer'
import { GatedContentUploadPromptDrawer } from 'app/components/gated-content-upload-prompt-drawer/GatedContentUploadPromptDrawer'
import { InboxUnavailableDrawer } from 'app/components/inbox-unavailable-drawer/InboxUnavailableDrawer'
import { LeavingAudiusDrawer } from 'app/components/leaving-audius-drawer'
import { LockedContentDrawer } from 'app/components/locked-content-drawer'
import { OverflowMenuDrawer } from 'app/components/overflow-menu-drawer'
import { PlaybackRateDrawer } from 'app/components/playback-rate-drawer'
Expand Down Expand Up @@ -156,6 +157,7 @@ export const Drawers = () => {
drawer={Drawer}
/>
))}
<LeavingAudiusDrawer />
</>
)
}
4 changes: 2 additions & 2 deletions packages/mobile/src/assets/images/iconQuestionCircle.svg
rickyrombo marked this conversation as resolved.
Show resolved Hide resolved
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ export const ChallengeRewardsDrawer = ({
isFullscreen
isGestureSupported={false}
title={title}
titleIcon={titleIcon}
titleImage={titleIcon}
>
<View style={styles.content}>
<View style={styles.task}>
Expand Down
17 changes: 16 additions & 1 deletion packages/mobile/src/components/core/Hyperlink.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ComponentProps } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'

import { isAudiusUrl, useLeavingAudiusModal } from '@audius/common'
import type { Match } from 'autolinker/dist/es2015'
import type { LayoutRectangle, TextStyle } from 'react-native'
import { Text, View } from 'react-native'
Expand Down Expand Up @@ -42,6 +43,7 @@ export type HyperlinkProps = ComponentProps<typeof Autolink> & {
// Pass touches through text elements
allowPointerEventsToPassThrough?: boolean
styles?: StylesProp<{ root: TextStyle; link: TextStyle }>
warnExternal?: boolean
}

export const Hyperlink = (props: HyperlinkProps) => {
Expand All @@ -50,6 +52,7 @@ export const Hyperlink = (props: HyperlinkProps) => {
source,
styles: stylesProp,
style,
warnExternal,
...other
} = props
const styles = useStyles()
Expand Down Expand Up @@ -91,7 +94,19 @@ export const Hyperlink = (props: HyperlinkProps) => {
}
}, [links, linkRefs, linkContainerRef])

const handlePress = useOnOpenLink(source)
const openLink = useOnOpenLink(source)
const [, openLeavingAudiusModal] = useLeavingAudiusModal()

const handlePress = useCallback(
(url) => {
if (warnExternal && !isAudiusUrl(url)) {
openLeavingAudiusModal({ link: url })
} else {
openLink(url)
}
},
[openLink, warnExternal, openLeavingAudiusModal]
)

const renderLink = useCallback(
(text, match, index) => (
Expand Down
13 changes: 10 additions & 3 deletions packages/mobile/src/components/drawer/Drawer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ReactNode } from 'react'
import type { ComponentType, ReactNode } from 'react'
import { useMemo, useCallback, useEffect, useRef, useState } from 'react'

import type {
Expand All @@ -19,6 +19,7 @@ import type { Edge } from 'react-native-safe-area-context'
import { useSafeAreaInsets, SafeAreaView } from 'react-native-safe-area-context'

import { makeStyles } from 'app/styles'
import type { SvgProps } from 'app/types/svg'
import { attachToDy } from 'app/utils/animation'

import { DrawerHeader } from './DrawerHeader'
Expand Down Expand Up @@ -114,7 +115,11 @@ export type DrawerProps = {
/**
* Icon to display in the header next to the title (must also include title)
*/
titleIcon?: ImageSourcePropType
titleIcon?: ComponentType<SvgProps>
/**
* Icon (as image source) to display in the header next to the title (must also include title)
*/
titleImage?: ImageSourcePropType
/**
* Whether or not this is a fullscreen drawer with a dismiss button
*/
Expand Down Expand Up @@ -237,7 +242,7 @@ export const springToValue = ({
// Only allow titleIcon with title
type DrawerComponent = {
(props: DrawerProps & { title: string }): React.ReactElement
(props: Omit<DrawerProps, 'titleIcon'>): React.ReactElement
(props: Omit<DrawerProps, 'titleIcon' | 'titleImage'>): React.ReactElement
}
export const Drawer: DrawerComponent = ({
isOpen,
Expand All @@ -247,6 +252,7 @@ export const Drawer: DrawerComponent = ({
onOpen,
title,
titleIcon,
titleImage,
isFullscreen,
shouldBackgroundDim = true,
isGestureSupported = true,
Expand Down Expand Up @@ -652,6 +658,7 @@ export const Drawer: DrawerComponent = ({
onClose={onClose}
title={title}
titleIcon={titleIcon}
titleImage={titleImage}
isFullscreen={isFullscreen}
/>
{children}
Expand Down
30 changes: 22 additions & 8 deletions packages/mobile/src/components/drawer/DrawerHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import type { ImageSourcePropType, ImageStyle } from 'react-native'
import type { ComponentType } from 'react'

import type { ImageSourcePropType } from 'react-native'
import { TouchableOpacity, View, Image, Text } from 'react-native'

import IconRemove from 'app/assets/images/iconRemove.svg'
import { makeStyles } from 'app/styles'
import type { SvgProps } from 'app/types/svg'
import { useColor } from 'app/utils/theme'

type DrawerHeaderProps = {
onClose: () => void
title?: string
titleIcon?: ImageSourcePropType
titleIcon?: ComponentType<SvgProps>
titleImage?: ImageSourcePropType
isFullscreen?: boolean
}

Expand Down Expand Up @@ -47,9 +51,16 @@ export const useStyles = makeStyles(({ palette, typography, spacing }) => ({
}))

export const DrawerHeader = (props: DrawerHeaderProps) => {
const { onClose, title, titleIcon, isFullscreen } = props
const {
onClose,
title,
titleIcon: TitleIcon,
titleImage,
isFullscreen
} = props
const styles = useStyles()
const closeColor = useColor('neutralLight4')
const iconRemoveColor = useColor('neutralLight4')
const titleIconColor = useColor('neutral')

return title || isFullscreen ? (
<View style={styles.titleBarContainer}>
Expand All @@ -59,14 +70,17 @@ export const DrawerHeader = (props: DrawerHeaderProps) => {
onPress={onClose}
style={styles.dismissContainer}
>
<IconRemove width={30} height={30} fill={closeColor} />
<IconRemove width={30} height={30} fill={iconRemoveColor} />
</TouchableOpacity>
)}
{title && (
<View style={styles.titleContainer}>
{titleIcon && (
<Image style={styles.titleIcon as ImageStyle} source={titleIcon} />
)}
{TitleIcon ? (
rickyrombo marked this conversation as resolved.
Show resolved Hide resolved
<TitleIcon style={styles.titleIcon} fill={titleIconColor} />
) : null}
{titleImage ? (
<Image style={styles.titleIcon} source={titleImage} />
) : null}
<Text style={styles.titleLabel}>{title}</Text>
</View>
)}
Expand Down
Loading