diff --git a/jam-ui/cypress/e2e/sessions/new-session-page.cy.js b/jam-ui/cypress/e2e/sessions/new-session-page.cy.js index 8c5f2db64..d14f774cd 100644 --- a/jam-ui/cypress/e2e/sessions/new-session-page.cy.js +++ b/jam-ui/cypress/e2e/sessions/new-session-page.cy.js @@ -1,5 +1,7 @@ /// +import useNativeAppCheck from '../../../src/hooks/useNativeAppCheck'; + describe('Create new session', () => { beforeEach(() => { cy.stubAuthenticate({ id: '6' }); @@ -58,7 +60,7 @@ describe('Create new session', () => { cy.get('[data-testid=modal-choose-friends]').should('not.contain', 'Seth Call') }) - it("prefill form using saved data in localStorage", () => { + it.only("prefill form using saved data in localStorage", () => { cy.visit('/sessions/new'); cy.get('[data-testid=session-privacy]').select("2") cy.get('[data-testid=autocomplete-text]').type('Dav') @@ -85,4 +87,6 @@ describe('Create new session', () => { cy.get('[data-testid=btn-create-session]').should('be.disabled') }) + + }) \ No newline at end of file diff --git a/jam-ui/package-lock.json b/jam-ui/package-lock.json index af4f66ec6..52596ef14 100644 --- a/jam-ui/package-lock.json +++ b/jam-ui/package-lock.json @@ -6376,6 +6376,11 @@ "resolved": "https://registry.npmjs.org/custom-event-polyfill/-/custom-event-polyfill-1.0.7.tgz", "integrity": "sha512-TDDkd5DkaZxZFM8p+1I3yAlvM3rSr1wbrOliG4yJiwinMZN8z/iGL7BTlDkrJcYTmgUSb4ywVCc3ZaUtOtC76w==" }, + "custom-protocol-check": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/custom-protocol-check/-/custom-protocol-check-1.4.0.tgz", + "integrity": "sha512-eMTyp8AKnE5eo+mKNqG3743eb5ZND5LhBgf9F8BN2tVdhSBnOCHH7me7iTcv0BUDhUW2dBQiHWLWMy776yZW1A==" + }, "cyclist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-1.0.1.tgz", diff --git a/jam-ui/package.json b/jam-ui/package.json index c82d9d90b..95bc4cc70 100644 --- a/jam-ui/package.json +++ b/jam-ui/package.json @@ -24,10 +24,12 @@ "chance": "^1.1.8", "chart.js": "^2.9.3", "classnames": "^2.2.6", + "custom-protocol-check": "^1.4.0", "echarts": "^4.9.0", "echarts-for-react": "^2.0.16", "element-resize-event": "^3.0.3", "emoji-mart": "^3.0.0", + "faker": "^5.5.3", "fuse.js": "^6.4.3", "google-maps-react": "^2.0.6", "i18next": "^21.3.3", @@ -78,8 +80,7 @@ "react-typed": "^1.2.0", "reactstrap": "^8.6.0", "slick-carousel": "^1.8.1", - "uuid": "^3.4.0", - "faker": "^5.5.3" + "uuid": "^3.4.0" }, "scripts": { "start": "react-scripts start", diff --git a/jam-ui/src/components/common/JKModalDialog.js b/jam-ui/src/components/common/JKModalDialog.js new file mode 100644 index 000000000..307ce5221 --- /dev/null +++ b/jam-ui/src/components/common/JKModalDialog.js @@ -0,0 +1,43 @@ +import React, { useEffect, useState } from 'react'; +import { Button, Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap'; +import { useTranslation } from 'react-i18next'; +import PropTypes from 'prop-types'; + +const JKModalDialog = ({ title, children, show, onToggle }) => { + const [modal, setModal] = useState(show); + + const toggle = () => { + setModal(!modal); + onToggle(!modal); + }; + + const { t } = useTranslation(); + + useEffect(() => { + setModal(show); + }, [show]); + + return ( + + {title} + {children} + + + + + ); +}; + +JKModalDialog.propTypes = { + show: PropTypes.bool.isRequired, + title: PropTypes.string.isRequired, + children: PropTypes.node.isRequired, + onToggle: PropTypes.func.isRequired +}; + +JKModalDialog.defaultProps = { + show: false, + title: 'Modal Dialog' +}; + +export default JKModalDialog; diff --git a/jam-ui/src/components/page/JKMusicSessions.js b/jam-ui/src/components/page/JKMusicSessions.js index c4014be50..9cd2e19bd 100644 --- a/jam-ui/src/components/page/JKMusicSessions.js +++ b/jam-ui/src/components/page/JKMusicSessions.js @@ -1,11 +1,13 @@ import React, { useEffect } from 'react'; -import { Alert, Col, Row, Card, CardBody, Table } from 'reactstrap'; +import { Alert, Col, Row, Card, CardBody } from 'reactstrap'; import FalconCardHeader from '../common/FalconCardHeader'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import { fetchSessions } from '../../store/features/sessionsSlice'; import { isIterableArray } from '../../helpers/utils'; import { useResponsive } from '@farfetch/react-context-responsive'; +import JKModalDialog from '../common/JKModalDialog'; +import { useNativeApp } from '../../context/NativeAppContext'; import JKSessionSwiper from '../sessions/JKSessionSwiper'; import JKSessionList from '../sessions/JKSessionList'; @@ -17,42 +19,74 @@ function JKMusicSessions() { const sessions = useSelector(state => state.session.sessions); const loadingStatus = useSelector(state => state.session.status); const { greaterThan } = useResponsive(); + const { nativeAppUnavailable, setNativeAppUnavailable } = useNativeApp(); useEffect(() => { dispatch(fetchSessions()); }, []); + const toggleAppUnavilableModel = () => { + setNativeAppUnavailable(!nativeAppUnavailable); + }; + return ( - - - - {loadingStatus === 'loading' && sessions.length === 0 ? ( - - ) : isIterableArray(sessions) ? ( - <> - {greaterThan.sm ? ( - -
- -
-
- ) : ( - - - - )} - - ) : ( - - - - {t('no_records', { ns: 'common' })} - - - - )} -
-
+ <> + + + + {loadingStatus === 'loading' && sessions.length === 0 ? ( + + ) : isIterableArray(sessions) ? ( + <> + {greaterThan.sm ? ( + +
+ +
+
+ ) : ( + + + + )} + + ) : ( + + + + {t('no_records', { ns: 'common' })} + + + + )} +
+
+ +

{t('new.app_unavailable_modal.body', { ns: 'sessions' })}

+
+ + Download JamKazam App + + + Get Help + +
+
+ ); } diff --git a/jam-ui/src/components/page/JKNewMusicSession.js b/jam-ui/src/components/page/JKNewMusicSession.js index abf84fab0..870ddfd54 100644 --- a/jam-ui/src/components/page/JKNewMusicSession.js +++ b/jam-ui/src/components/page/JKNewMusicSession.js @@ -1,5 +1,5 @@ import React, { useRef, useState, useEffect } from 'react'; -import {useHistory} from 'react-router-dom'; +//import {useHistory} from 'react-router-dom'; import { Form, FormGroup, Input, Label, Card, CardBody, Button, Row, Col } from 'reactstrap'; import FalconCardHeader from '../common/FalconCardHeader'; import JKTooltip from '../common/JKTooltip'; @@ -9,53 +9,59 @@ import JKFriendsAutoComplete from '../people/JKFriendsAutoComplete'; import JKSessionInviteesChips from '../people/JKSessionInviteesChips'; import { getFriends } from '../../helpers/rest'; import jkCustomUrlScheme from '../../helpers/jkCustomUrlScheme'; +import JKModalDialog from '../common/JKModalDialog'; +import useNativeAppCheck from '../../hooks/useNativeAppCheck'; +import { useNativeApp } from '../../context/NativeAppContext'; const privacyMap = { - "public": 1, - "private_invite": 2, - "private_approve": 3 -} + public: 1, + private_invite: 2, + private_approve: 3 +}; const JKNewMusicSession = () => { const { currentUser } = useAuth(); const { t } = useTranslation(); const [friends, setFriends] = useState([]); - const [isFriendsFetched, setIsFriendsFetched] = useState(false) - const [description, setDescription] = useState("") + const [isFriendsFetched, setIsFriendsFetched] = useState(false); + const [description, setDescription] = useState(''); const [invitees, setInvitees] = useState([]); - const [privacy, setPrivacy] = useState("1"); + const [privacy, setPrivacy] = useState('1'); const [submitted, setSubmitted] = useState(false); - const history = useHistory(); - const formRef = useRef() + const [showAppUnavailable, setShowAppUnavailable] = useState(false); + //const history = useHistory(); + const formRef = useRef(); + const isNativeAppAvailable = useNativeAppCheck(); + const { nativeAppUnavailable, setNativeAppUnavailable } = useNativeApp(); useEffect(() => { fetchFriends(); }, []); useEffect(() => { - if(isFriendsFetched){ - populateFormDataFromLocalStorage() + if (isFriendsFetched) { + populateFormDataFromLocalStorage(); } - }, [isFriendsFetched]) + }, [isFriendsFetched]); const fetchFriends = () => { getFriends(currentUser.id) - .then(resp => { - if (resp.ok) { - return resp.json(); - } - }) - .then(data => { - console.log('friends = ', data) - setFriends(data); - setIsFriendsFetched(true); - }); - } + .then(resp => { + if (resp.ok) { + return resp.json(); + } + }) + .then(data => { + console.log('friends = ', data); + setFriends(data); + setIsFriendsFetched(true); + }); + }; const populateFormDataFromLocalStorage = () => { try { const formData = localStorage.getItem('formData'); - if(formData){ + if (formData) { const formDataItems = JSON.parse(formData); setDescription(formDataItems['description']); setPrivacy(formDataItems['privacy']); @@ -64,11 +70,11 @@ const JKNewMusicSession = () => { updateSessionInvitations(invitees); } } catch (error) { - console.error('localStorage is not available', error) + console.error('localStorage is not available', error); } - } + }; - const handleSubmit = event => { + const handleSubmit = async event => { event.preventDefault(); setSubmitted(true); const formData = new FormData(event.target); @@ -78,35 +84,39 @@ const JKNewMusicSession = () => { inviteeIds: invitees.map(i => i.id).join() }; console.log(payload); - //window.open jamkazam app url using custom URL scheme - //an example URL would be: jamkazam://url=https://www.jamkazam.com/client#/createSession/privacy~2|description~hello|inviteeIds~1,2,3,4 - const q = `privacy~${payload.privacy}|description~${payload.description}|inviteeIds~${payload.inviteeIds}` - //const url = encodeURI(`${process.env.REACT_APP_CLIENT_BASE_URL}/client#/createSession/${q}`) - //const urlScheme = `jamkazam://url=${url}` - const urlScheme = jkCustomUrlScheme('createSession', q) - window.open(urlScheme) try { //store this payload in localstorage. - localStorage.setItem('formData', JSON.stringify(payload)) + localStorage.setItem('formData', JSON.stringify(payload)); } catch (error) { - console.error("localStorage is not available", error); + console.error('localStorage is not available', error); + } + //check if jamkazam app is installed + try { + await isNativeAppAvailable(); + //window.open jamkazam app url using custom URL scheme + //an example URL would be: jamkazam://url=https://www.jamkazam.com/client#/createSession/privacy~2|description~hello|inviteeIds~1,2,3,4 + const q = `privacy~${payload.privacy}|description~${payload.description}|inviteeIds~${payload.inviteeIds}`; + const urlScheme = jkCustomUrlScheme('createSession', q); + window.open(urlScheme); + //history.push('/sessions'); + } catch (error) { + toggleAppUnavilableModel(); } - //history.push('/sessions'); return false; }; const handleOnSelect = submittedItems => { - updateSessionInvitations(submittedItems) + updateSessionInvitations(submittedItems); }; - const updateSessionInvitations = (submittedInvitees) => { + const updateSessionInvitations = submittedInvitees => { const updatedInvitees = Array.from(new Set([...invitees, ...submittedInvitees])); setInvitees(updatedInvitees); - const friendIds = submittedInvitees.map(si => si.id) + const friendIds = submittedInvitees.map(si => si.id); const updatedFriends = friends.filter(f => !friendIds.includes(f.id)); setFriends(updatedFriends); - } + }; const removeInvitee = invitee => { const updatedInvitees = invitees.filter(i => i.id !== invitee.id); @@ -115,6 +125,13 @@ const JKNewMusicSession = () => { setFriends(updatedFriends); }; + const toggleAppUnavilableModel = () => { + setNativeAppUnavailable(prev => !prev); + if (!nativeAppUnavailable) { + setSubmitted(false); + } + }; + return (
@@ -128,10 +145,21 @@ const JKNewMusicSession = () => { {t('new.privacy', { ns: 'sessions' })}{' '} - setPrivacy(e.target.value)} data-testid="session-privacy"> - - - + setPrivacy(e.target.value)} + data-testid="session-privacy" + > + + + @@ -141,9 +169,7 @@ const JKNewMusicSession = () => { - { invitees.length > 0 && - - } + {invitees.length > 0 && } setDescription(e.target.value)} + onChange={e => setDescription(e.target.value)} name="description" type="textarea" data-testid="session-description" @@ -161,7 +187,9 @@ const JKNewMusicSession = () => { - + @@ -169,6 +197,31 @@ const JKNewMusicSession = () => { + +

{t('modals.native_app_unavailable.body', { ns: 'common' })}

+
+ toggleAppUnavilableModel()} + target="_blank" + className="btn btn-primary mr-2" + > + {t('modals.native_app_unavailable.download_button', { ns: 'common' })} + + toggleAppUnavilableModel()} + target="_blank" + className="btn btn-light" + > + {t('modals.native_app_unavailable.help_button', { ns: 'common' })} + +
+
); }; diff --git a/jam-ui/src/components/people/JKFriendsAutoComplete.js b/jam-ui/src/components/people/JKFriendsAutoComplete.js index da4a071ed..32976d470 100644 --- a/jam-ui/src/components/people/JKFriendsAutoComplete.js +++ b/jam-ui/src/components/people/JKFriendsAutoComplete.js @@ -66,25 +66,37 @@ function JKFriendsAutoComplete({ friends, onSelect }) { onSelect(selectedFriends); }; - const toggleVisibility = (isVisible) => { - setShowModal(isVisible) - } + const toggleVisibility = isVisible => { + setShowModal(isVisible); + }; return (
- - - - - + + + + + - @@ -93,14 +105,19 @@ function JKFriendsAutoComplete({ friends, onSelect }) { {filteredFriends.map(f => ( handleOnClick(f)}> - - {f.first_name} {f.last_name} + + {f.first_name} {f.last_name} ))} - +
); } @@ -110,10 +127,10 @@ JKFriendsAutoComplete.propTypes = { PropTypes.shape({ id: PropTypes.string.isRequired, first_name: PropTypes.string, - last_name: PropTypes.string, + last_name: PropTypes.string }) - ), + ), onSelect: PropTypes.func.isRequired -} +}; export default JKFriendsAutoComplete; diff --git a/jam-ui/src/components/sessions/JKSession.js b/jam-ui/src/components/sessions/JKSession.js index 58e13debc..524a6d713 100644 --- a/jam-ui/src/components/sessions/JKSession.js +++ b/jam-ui/src/components/sessions/JKSession.js @@ -13,12 +13,16 @@ import jkCustomUrlScheme from '../../helpers/jkCustomUrlScheme'; import JKUserLatencyBadge from '../profile/JKUserLatencyBadge'; import JKSessionUser from './JKSessionUser'; +import useNativeAppCheck from '../../hooks/useNativeAppCheck'; +import { useNativeApp } from '../../context/NativeAppContext'; function JKSession({ session }) { const { currentUser } = useAuth(); const dispatch = useDispatch(); const { t } = useTranslation(); const { greaterThan } = useResponsive(); + const isNativeAppAvailable = useNativeAppCheck(); + const { setNativeAppUnavailable } = useNativeApp(); useEffect(() => { const participantIds = session.participants.map(p => p.user.id); @@ -26,14 +30,19 @@ function JKSession({ session }) { dispatch(fetchUserLatencies(options)); }, []); - function joinSession() { + async function joinSession() { if (session.musician_access && session.approval_required) { toast.info(t('list.alerts.join_request_sent', { ns: 'sessions' })); } else { - const q = `joinSessionId~${session.id}`; - const urlScheme = jkCustomUrlScheme('findSession', q); - window.open(urlScheme); - return; + try { + await isNativeAppAvailable() + const q = `joinSessionId~${session.id}`; + const urlScheme = jkCustomUrlScheme('findSession', q); + window.open(urlScheme, '_blank'); + return; + } catch (error) { + setNativeAppUnavailable(true); + } } } @@ -107,7 +116,7 @@ clicks this button, we open an audio stream using Icecast server to let the user
{session.participants.map(participant => ( - + ))}
diff --git a/jam-ui/src/context/NativeAppContext.js b/jam-ui/src/context/NativeAppContext.js new file mode 100644 index 000000000..3a03c4d62 --- /dev/null +++ b/jam-ui/src/context/NativeAppContext.js @@ -0,0 +1,16 @@ +import React from 'react'; + +const NativeAppContext = React.createContext(null) + +export const NativeAppProvider = ({ children}) => { + + const [nativeAppUnavailable, setNativeAppUnavailable] = React.useState(false) + + return( + + {children} + + ) +} + +export const useNativeApp = () => React.useContext(NativeAppContext) \ No newline at end of file diff --git a/jam-ui/src/helpers/jkCustomUrlScheme.js b/jam-ui/src/helpers/jkCustomUrlScheme.js index c8f6dc847..02c088237 100644 --- a/jam-ui/src/helpers/jkCustomUrlScheme.js +++ b/jam-ui/src/helpers/jkCustomUrlScheme.js @@ -1,6 +1,5 @@ - export default (section, queryStr) => { - const url = encodeURI(`${process.env.REACT_APP_CLIENT_BASE_URL}/client#/${section}/custom~yes|${queryStr}`) - const urlScheme = `jamkazam://url=${url}` - return urlScheme -} + const url = encodeURI(`${process.env.REACT_APP_CLIENT_BASE_URL}/client#/${section}/custom~yes|${queryStr}`); + const urlScheme = `jamkazam://url=${url}`; + return urlScheme; +}; \ No newline at end of file diff --git a/jam-ui/src/hooks/useNativeAppCheck.js b/jam-ui/src/hooks/useNativeAppCheck.js new file mode 100644 index 000000000..66942d953 --- /dev/null +++ b/jam-ui/src/hooks/useNativeAppCheck.js @@ -0,0 +1,24 @@ +import customProtocolCheck from 'custom-protocol-check'; + +const useNativeAppCheck = () => { + const isAvailable = () => { + const customUrlScheme = 'jamkazam://'; + return new Promise((resolve, reject) => { + customProtocolCheck( + customUrlScheme, + () => { + //Custom protocol NOT found + reject('JamKazam application cannot be found.'); + }, + () => { + //Custom protocol found and opened the file successfully + resolve(); + }, + 1000 + ); + }); + }; + return isAvailable; +}; + +export default useNativeAppCheck; diff --git a/jam-ui/src/i18n/locales/en/common.json b/jam-ui/src/i18n/locales/en/common.json index d34121ae8..f04c7a44b 100644 --- a/jam-ui/src/i18n/locales/en/common.json +++ b/jam-ui/src/i18n/locales/en/common.json @@ -5,6 +5,7 @@ "more": "More", "actions": "Actions", "no_records": "No Records!", + "close": "Close", "navigation": { "home": "Home", "friends": "Friends", @@ -15,5 +16,13 @@ "contact": "Contact", "privacy": "Privacy", "terms": "Terms of Service" + }, + "modals": { + "native_app_unavailable": { + "title": "JamKazam App Unavailable", + "body": "We’re sorry, but we cannot find the JamKazam application installed on your computer. If you don’t have the app on this computer, download and install the JamKazam app. If you’re sure the app is already installed on your computer, get help from our help desk.", + "download_button": "Download JamKazam App", + "help_button": "Get Help" + } } -} \ No newline at end of file +} diff --git a/jam-ui/src/layouts/JKDashboardLayout.js b/jam-ui/src/layouts/JKDashboardLayout.js index af7e9d543..566733e44 100644 --- a/jam-ui/src/layouts/JKDashboardLayout.js +++ b/jam-ui/src/layouts/JKDashboardLayout.js @@ -1,10 +1,12 @@ -import React, { useState, useEffect, createContext } from 'react'; +import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; import DashboardMain from '../components/dashboard/JKDashboardMain'; import UserAuth from '../context/UserAuth'; import { BrowserQueryProvider } from '../context/BrowserQuery'; +import { NativeAppProvider } from '../context/NativeAppContext'; + const DashboardLayout = ({ location }) => { useEffect(() => { window.scrollTo(0, 0); @@ -13,7 +15,9 @@ const DashboardLayout = ({ location }) => { return ( - + + + );