diff --git a/locale/en.yaml b/locale/en.yaml index 8db687f4a..da1b6aafb 100644 --- a/locale/en.yaml +++ b/locale/en.yaml @@ -132,6 +132,7 @@ uwave: join: Join Waitlist leave: Leave Waitlist leaveBooth: Stop playing + autoLeave: Stop playing after this add: Add to waitlist remove: Remove from waitlist diff --git a/src/components/FooterBar/UserFooterContent.tsx b/src/components/FooterBar/UserFooterContent.tsx index afba915bd..1c0c51839 100644 --- a/src/components/FooterBar/UserFooterContent.tsx +++ b/src/components/FooterBar/UserFooterContent.tsx @@ -16,13 +16,7 @@ import { canSkipSelector, } from '../../reducers/booth'; import { activePlaylistSelector, nextMediaSelector } from '../../reducers/playlists'; -import { - joinWaitlist, - leaveWaitlist, - baseEtaSelector, - userInWaitlistSelector, - isLockedSelector, -} from '../../reducers/waitlist'; +import { baseEtaSelector, userInWaitlistSelector } from '../../reducers/waitlist'; import NextMedia from './NextMedia'; import UserInfo from './UserInfo'; import SkipButton from './SkipButton'; @@ -43,7 +37,6 @@ function UserFooterContent({ user: currentUser }: UserFooterContentProps) { const currentDJ = useSelector(djSelector); const historyID = useSelector(historyIDSelector); const showSkip = useSelector(canSkipSelector); - const waitlistIsLocked = useSelector(isLockedSelector); const voteStats = useSelector(currentVoteStatsSelector); const dispatch = useDispatch(); const handleTogglePlaylistManager = useCallback(() => { @@ -75,18 +68,6 @@ function UserFooterContent({ user: currentUser }: UserFooterContentProps) { return dispatch(skipSelf({ remove: false })); }, [userIsDJ, dispatch]); - const handleJoinWaitlist = useCallback(() => { - dispatch(joinWaitlist()).catch(() => { - // error is already reported - }); - }, [dispatch]); - const handleLeaveWaitlist = useCallback(() => { - if (userIsDJ) { - return dispatch(skipSelf({ remove: true })); - } - return dispatch(leaveWaitlist()); - }, [userIsDJ, dispatch]); - const canVote = !userIsDJ && !!currentDJ; return ( @@ -135,12 +116,7 @@ function UserFooterContent({ user: currentUser }: UserFooterContentProps) { )}
- +
); diff --git a/src/components/FooterBar/WaitlistButton.css b/src/components/FooterBar/WaitlistButton.css index d6722b210..164a5107d 100644 --- a/src/components/FooterBar/WaitlistButton.css +++ b/src/components/FooterBar/WaitlistButton.css @@ -7,7 +7,6 @@ width: 125px; background: var(--highlight-color); color: var(--text-color); - cursor: pointer; font-size: 11pt; text-transform: uppercase; @@ -20,6 +19,13 @@ background: var(--highbright-color); } } +.WaitlistButton--split { + background: var(--highlight-color); + color: var(--text-color); + &:hover { + background: var(--highbright-color); + } +} .WaitlistButton--locked { width: 140px; diff --git a/src/components/FooterBar/WaitlistButton.tsx b/src/components/FooterBar/WaitlistButton.tsx index 955adddfb..a8cdfb867 100644 --- a/src/components/FooterBar/WaitlistButton.tsx +++ b/src/components/FooterBar/WaitlistButton.tsx @@ -1,23 +1,49 @@ import cx from 'clsx'; -import { memo } from 'react'; +import { useRef, useState } from 'react'; +import { useAsyncCallback } from 'react-async-hook'; import { useTranslator } from '@u-wave/react-translate'; +import Popover from '@mui/material/Popover'; +import MenuList from '@mui/material/MenuList'; +import MenuItem from '@mui/material/MenuItem'; +import ListItemIcon from '@mui/material/ListItemIcon'; import Button from '@mui/material/Button'; -import { mdiLock } from '@mdi/js'; +import ButtonGroup from '@mui/material/ButtonGroup'; +import { mdiCheck, mdiLock, mdiMenuUp } from '@mdi/js'; import SvgIcon from '../SvgIcon'; +import { useDispatch, useSelector } from '../../hooks/useRedux'; +import { + joinWaitlist, + leaveWaitlist, + userInWaitlistSelector, + waitlistIsLockedSelector, +} from '../../reducers/waitlist'; +import { isCurrentDJSelector, setAutoLeave, skipSelf } from '../../reducers/booth'; -type WaitlistButtonProps = { - userIsDJ: boolean, - userInWaitlist: boolean, - isLocked: boolean, - onClick: () => void, -}; -function WaitlistButton({ - userIsDJ, - userInWaitlist, - isLocked, - onClick, -}: WaitlistButtonProps) { +function WaitlistButton() { const { t } = useTranslator(); + const isDJ = useSelector(isCurrentDJSelector); + const isInWaitlist = useSelector(userInWaitlistSelector); + const isLocked = useSelector(waitlistIsLockedSelector); + const autoLeave = useSelector((state) => state.booth.autoLeave ?? false); + const dispatch = useDispatch(); + const anchorRef = useRef(null); + const [open, setOpen] = useState(false); + + const handleSkipRemove = useAsyncCallback(async () => { + await dispatch(skipSelf({ remove: true })); + }); + + const handleAutoLeave = useAsyncCallback(async () => { + await dispatch(setAutoLeave({ autoLeave: !autoLeave })); + }); + + const handleLeave = useAsyncCallback(async () => { + await dispatch(leaveWaitlist()); + }); + + const handleJoin = useAsyncCallback(async () => { + await dispatch(joinWaitlist()); + }); let icon; if (isLocked) { @@ -28,19 +54,71 @@ function WaitlistButton({ 'WaitlistButton-icon', // The user can still leave the waitlist, if it's locked, // but cannot join the waitlist. - !userInWaitlist && 'WaitlistButton-icon--locked', + !isInWaitlist && 'WaitlistButton-icon--locked', )} /> ); } - let label; - if (userIsDJ) { - label = t('waitlist.leaveBooth'); - } else if (userInWaitlist) { - label = t('waitlist.leave'); - } else { - label = t('waitlist.join'); + if (isDJ) { + return ( + <> + + + + + setOpen(false)} + > + + + + {autoLeave ? : null} + + {t('waitlist.autoLeave')} + + + + + ); + } + + if (isInWaitlist) { + return ( + + ); } return ( @@ -49,14 +127,14 @@ function WaitlistButton({ root: 'WaitlistButton', disabled: 'WaitlistButton--locked', }} - disabled={isLocked && !userInWaitlist} - onClick={() => onClick()} + disabled={isLocked || handleJoin.loading} + onClick={handleJoin.execute} > {icon} - {isLocked && ' '} - {label} + {isLocked ? ' ' : null} + {t('waitlist.join')} ); } -export default memo(WaitlistButton); +export default WaitlistButton; diff --git a/src/mobile/containers/UsersDrawer.js b/src/mobile/containers/UsersDrawer.js index 28eb203da..d671de7bf 100644 --- a/src/mobile/containers/UsersDrawer.js +++ b/src/mobile/containers/UsersDrawer.js @@ -5,7 +5,7 @@ import { leaveWaitlist, waitlistUsersSelector, userInWaitlistSelector, - isLockedSelector, + waitlistIsLockedSelector, } from '../../reducers/waitlist'; import { isLoggedInSelector } from '../../reducers/auth'; import { djSelector } from '../../reducers/booth'; @@ -21,7 +21,7 @@ const mapStateToProps = createStructuredSelector({ open: usersDrawerIsOpenSelector, userIsLoggedIn: isLoggedInSelector, userInWaitlist: userInWaitlistSelector, - isLockedWaitlist: isLockedSelector, + isLockedWaitlist: waitlistIsLockedSelector, }); const mapDispatchToProps = { diff --git a/src/reducers/__tests__/booth.js b/src/reducers/__tests__/booth.js index 7673c3687..8be0c06ca 100644 --- a/src/reducers/__tests__/booth.js +++ b/src/reducers/__tests__/booth.js @@ -11,6 +11,7 @@ describe('reducers/booth', () => { it('should default to an empty DJ booth', () => { const state = booth(undefined, { type: '@@redux/INIT' }); expect(state).toEqual({ + autoLeave: null, historyID: null, djID: null, media: null, @@ -32,6 +33,7 @@ describe('reducers/booth', () => { timestamp: 1449767164107, })); expect(state).toEqual({ + autoLeave: null, historyID: 'someRandomID', djID: 'seventeen', media: { artist: 'about tess', title: 'Imaginedit' }, @@ -48,6 +50,7 @@ describe('reducers/booth', () => { it('should stop playing if there is no next song', () => { const state = booth(initialState(), advanceInner(null)); expect(state).toEqual({ + autoLeave: null, historyID: null, djID: null, media: null, diff --git a/src/reducers/auth.ts b/src/reducers/auth.ts index 9ff8bfda6..7a2de7661 100644 --- a/src/reducers/auth.ts +++ b/src/reducers/auth.ts @@ -67,6 +67,7 @@ export type InitialStatePayload = { favorites: string[], }, } | null, + autoLeave?: boolean, // Only on recent u-wave-core waitlist: string[], waitlistLocked: boolean, activePlaylist: string | null, diff --git a/src/reducers/booth.ts b/src/reducers/booth.ts index 3f7bf2472..680431757 100644 --- a/src/reducers/booth.ts +++ b/src/reducers/booth.ts @@ -11,7 +11,7 @@ import { currentUserHasRoleSelector, } from './users'; import { currentTimeSelector } from './server'; -import { initState } from './auth'; +import { currentUserIDSelector, initState } from './auth'; import uwFetch from '../utils/fetch'; import { createAsyncThunk, type Thunk } from '../redux/api'; import type { StoreState } from '../redux/configureStore'; @@ -38,6 +38,7 @@ interface PlayingState { media: Media, startTime: number, stats: { upvotes: string[], downvotes: string[], favorites: string[] }, + autoLeave?: boolean | null, } interface EmptyState { @@ -46,6 +47,7 @@ interface EmptyState { media: null, startTime: null, stats: null, + autoLeave?: null, } type State = (PlayingState | EmptyState) & { @@ -58,6 +60,7 @@ const initialState = { djID: null, startTime: null, stats: null, + autoLeave: null, isFullscreen: false, } as State; @@ -94,6 +97,21 @@ export const skipCurrentDJ = createAsyncThunk('booth/skip', async (options: { re }]); }); +// This action doesn't need further handling because it will cause updates +// over WebSocket on the server. +export const setAutoLeave = createAsyncThunk('booth/setAutoLeave', async ( + options: { autoLeave: boolean }, + api, +) => { + const userID = currentUserIDSelector(api.getState()); + const { data } = await uwFetch<{ data: { autoLeave: boolean } }>(['/booth/leave', { + method: 'put', + data: { userID, autoLeave: options.autoLeave }, + }]); + + return data; +}); + export const upvote = createAsyncThunk('booth/upvote', async ({ historyID }: { historyID: string }) => { await uwFetch([`/booth/${historyID}/vote`, { method: 'put', data: { direction: 1 } }]); }); @@ -148,6 +166,7 @@ const slice = createSlice({ downvotes: [], favorites: [], }, + autoLeave: null, }; } return { @@ -157,6 +176,7 @@ const slice = createSlice({ djID: null, startTime: null, stats: null, + autoLeave: null, }; }, prepare(payload: AdvancePayload | null, previous: PreviousBooth | null = null) { @@ -237,6 +257,13 @@ const slice = createSlice({ stats: null, }); } + + state.autoLeave = payload.autoLeave ?? false; + }); + builder.addCase(setAutoLeave.fulfilled, (state, { payload }) => { + if (state.djID != null) { + state.autoLeave = payload.autoLeave; + } }); }, selectors: { diff --git a/src/reducers/waitlist.ts b/src/reducers/waitlist.ts index 4987d5310..2cccb0b5d 100644 --- a/src/reducers/waitlist.ts +++ b/src/reducers/waitlist.ts @@ -118,7 +118,7 @@ export const { waitlistUpdated, } = slice.actions; -export function isLockedSelector(state: StoreState) { +export function waitlistIsLockedSelector(state: StoreState) { return state.waitlist.locked; } @@ -158,7 +158,7 @@ export function userInWaitlistSelector(state: StoreState) { } export const waitlistSelector = createStructuredSelector({ - locked: isLockedSelector, + locked: waitlistIsLockedSelector, users: waitlistUsersSelector, });