show native app unavailable modal

show this modal if the native JamKazam app is not available on user's computer.
occurs on submission of new session form and on clicking join session in
browse session page.
This commit is contained in:
Nuwan 2023-11-09 16:19:15 +05:30
parent b7615c3fcd
commit fcd9dd15f4
13 changed files with 333 additions and 115 deletions

View File

@ -1,5 +1,7 @@
/// <reference types="cypress" />
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')
})
})

View File

@ -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",

View File

@ -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",

View File

@ -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 (
<Modal isOpen={modal} toggle={toggle}>
<ModalHeader toggle={toggle}>{title}</ModalHeader>
<ModalBody>{children}</ModalBody>
<ModalFooter>
<Button onClick={toggle}>{t('close', { ns: 'common' })}</Button>
</ModalFooter>
</Modal>
);
};
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;

View File

@ -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 (
<Card>
<FalconCardHeader title={t('list.page_title', { ns: 'sessions' })} titleClass="font-weight-bold" />
<CardBody className="pt-0">
{loadingStatus === 'loading' && sessions.length === 0 ? (
<Loader />
) : isIterableArray(sessions) ? (
<>
{greaterThan.sm ? (
<Row className="mb-3 justify-content-between d-none d-md-block">
<div className="table-responsive-xl px-2">
<JKSessionList sessions={sessions} />
</div>
</Row>
) : (
<Row className="swiper-container d-block d-md-none" data-testid="sessionsSwiper">
<JKSessionSwiper sessions={sessions} />
</Row>
)}
</>
) : (
<Row className="p-card">
<Col>
<Alert color="info" className="mb-0">
{t('no_records', { ns: 'common' })}
</Alert>
</Col>
</Row>
)}
</CardBody>
</Card>
<>
<Card>
<FalconCardHeader title={t('list.page_title', { ns: 'sessions' })} titleClass="font-weight-bold" />
<CardBody className="pt-0">
{loadingStatus === 'loading' && sessions.length === 0 ? (
<Loader />
) : isIterableArray(sessions) ? (
<>
{greaterThan.sm ? (
<Row className="mb-3 justify-content-between d-none d-md-block">
<div className="table-responsive-xl px-2">
<JKSessionList sessions={sessions} />
</div>
</Row>
) : (
<Row className="swiper-container d-block d-md-none" data-testid="sessionsSwiper">
<JKSessionSwiper sessions={sessions} />
</Row>
)}
</>
) : (
<Row className="p-card">
<Col>
<Alert color="info" className="mb-0">
{t('no_records', { ns: 'common' })}
</Alert>
</Col>
</Row>
)}
</CardBody>
</Card>
<JKModalDialog
show={nativeAppUnavailable}
onToggle={toggleAppUnavilableModel}
title={t('new.app_unavailable_modal.title', { ns: 'sessions' })}
>
<p>{t('new.app_unavailable_modal.body', { ns: 'sessions' })}</p>
<div className="d-flex flex-row">
<a
href="https://www.jamkazam.com/downloads"
onClick={toggleAppUnavilableModel}
target="_blank"
className="btn btn-primary mr-2"
>
Download JamKazam App
</a>
<a
href="https://www.jamkazam.com/help_desk"
onClick={toggleAppUnavilableModel}
target="_blank"
className="btn btn-light"
>
Get Help
</a>
</div>
</JKModalDialog>
</>
);
}

View File

@ -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 (
<div>
<Card>
@ -128,10 +145,21 @@ const JKNewMusicSession = () => {
{t('new.privacy', { ns: 'sessions' })}{' '}
<JKTooltip title={t('new.privacy_help', { ns: 'sessions' })} />
</Label>
<Input type="select" aria-label="Session Privacy" name="privacy" value={privacy} onChange={(e) => setPrivacy(e.target.value)} data-testid="session-privacy">
<option value={privacyMap["public"]}>{t('new.privacy_opt_public', { ns: 'sessions' })}</option>
<option value={privacyMap["private_invite"]}>{t('new.privacy_opt_private_invite', { ns: 'sessions' })}</option>
<option value={privacyMap["private_approve"]}>{t('new.privacy_opt_private_approve', { ns: 'sessions' })}</option>
<Input
type="select"
aria-label="Session Privacy"
name="privacy"
value={privacy}
onChange={e => setPrivacy(e.target.value)}
data-testid="session-privacy"
>
<option value={privacyMap['public']}>{t('new.privacy_opt_public', { ns: 'sessions' })}</option>
<option value={privacyMap['private_invite']}>
{t('new.privacy_opt_private_invite', { ns: 'sessions' })}
</option>
<option value={privacyMap['private_approve']}>
{t('new.privacy_opt_private_approve', { ns: 'sessions' })}
</option>
</Input>
</FormGroup>
@ -141,9 +169,7 @@ const JKNewMusicSession = () => {
<JKTooltip title={t('new.invitations_help', { ns: 'sessions' })} />
</Label>
<JKFriendsAutoComplete friends={friends} onSelect={handleOnSelect} />
{ invitees.length > 0 &&
<JKSessionInviteesChips invitees={invitees} removeInvitee={removeInvitee} />
}
{invitees.length > 0 && <JKSessionInviteesChips invitees={invitees} removeInvitee={removeInvitee} />}
</FormGroup>
<FormGroup className="mb-3">
<Label>
@ -152,7 +178,7 @@ const JKNewMusicSession = () => {
</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
onChange={e => setDescription(e.target.value)}
name="description"
type="textarea"
data-testid="session-description"
@ -161,7 +187,9 @@ const JKNewMusicSession = () => {
</FormGroup>
<FormGroup className="mb-3">
<Button color="primary" data-testid="btn-create-session" disabled={submitted}>{t('new.create_session', { ns: 'sessions' })}</Button >
<Button color="primary" data-testid="btn-create-session" disabled={submitted}>
{t('new.create_session', { ns: 'sessions' })}
</Button>
</FormGroup>
</Form>
</Col>
@ -169,6 +197,31 @@ const JKNewMusicSession = () => {
</Row>
</CardBody>
</Card>
<JKModalDialog
show={nativeAppUnavailable}
onToggle={toggleAppUnavilableModel}
title={t('modals.native_app_unavailable.title', { ns: 'common' })}
>
<p>{t('modals.native_app_unavailable.body', { ns: 'common' })}</p>
<div className="d-flex flex-row">
<a
href="https://www.jamkazam.com/downloads"
onClick={() => toggleAppUnavilableModel()}
target="_blank"
className="btn btn-primary mr-2"
>
{t('modals.native_app_unavailable.download_button', { ns: 'common' })}
</a>
<a
href="https://www.jamkazam.com/help_desk"
onClick={() => toggleAppUnavilableModel()}
target="_blank"
className="btn btn-light"
>
{t('modals.native_app_unavailable.help_button', { ns: 'common' })}
</a>
</div>
</JKModalDialog>
</div>
);
};

View File

@ -66,25 +66,37 @@ function JKFriendsAutoComplete({ friends, onSelect }) {
onSelect(selectedFriends);
};
const toggleVisibility = (isVisible) => {
setShowModal(isVisible)
}
const toggleVisibility = isVisible => {
setShowModal(isVisible);
};
return (
<div>
<Row className="mb-2">
<Col md={9}>
<InputGroup>
<InputGroupText>
<FontAwesomeIcon icon="search" transform="shrink-4 down-1" />
</InputGroupText>
<Input onChange={handleInputChange} onKeyDown={handleOnKeyDown} value={inputValue} innerRef={inputRef} placeholder={t('new.invitations_filter_placeholder', { ns: 'sessions' })} data-testid="autocomplete-text" />
<InputGroupText>
<FontAwesomeIcon icon="search" transform="shrink-4 down-1" />
</InputGroupText>
<Input
onChange={handleInputChange}
onKeyDown={handleOnKeyDown}
value={inputValue}
innerRef={inputRef}
placeholder={t('new.invitations_filter_placeholder', { ns: 'sessions' })}
data-testid="autocomplete-text"
/>
</InputGroup>
</Col>
<Col md={3}>
<Button variant="outline-info" outline onClick={() => setShowModal(!showModal)} data-testid="btn-choose-friends">
{t('new.choose_friends', { ns: 'sessions' })}
<Button
variant="outline-info"
outline
onClick={() => setShowModal(!showModal)}
data-testid="btn-choose-friends"
>
{t('new.choose_friends', { ns: 'sessions' })}
</Button>
</Col>
</Row>
@ -93,14 +105,19 @@ function JKFriendsAutoComplete({ friends, onSelect }) {
{filteredFriends.map(f => (
<ListGroupItem key={f.id} onMouseOver={highlight} onMouseOut={unhighlight} onClick={() => handleOnClick(f)}>
<Avatar src={f.photo_url} size="m" />
<span className='ml-1'>
{f.first_name} {f.last_name}
<span className="ml-1">
{f.first_name} {f.last_name}
</span>
</ListGroupItem>
))}
</ListGroup>
<JKSelectFriendsModal friends={friends} show={showModal} toggleVisibility={toggleVisibility} onSubmit={onSubmitFriendsModal} />
<JKSelectFriendsModal
friends={friends}
show={showModal}
toggleVisibility={toggleVisibility}
onSubmit={onSubmitFriendsModal}
/>
</div>
);
}
@ -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;

View File

@ -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
<td>
<div>
{session.participants.map(participant => (
<JKUserLatencyBadge user={participant.user} />
<JKUserLatencyBadge key={participant.id} user={participant.user} />
))}
</div>
</td>

View File

@ -0,0 +1,16 @@
import React from 'react';
const NativeAppContext = React.createContext(null)
export const NativeAppProvider = ({ children}) => {
const [nativeAppUnavailable, setNativeAppUnavailable] = React.useState(false)
return(
<NativeAppContext.Provider value={ {nativeAppUnavailable, setNativeAppUnavailable} }>
{children}
</NativeAppContext.Provider>
)
}
export const useNativeApp = () => React.useContext(NativeAppContext)

View File

@ -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;
};

View File

@ -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;

View File

@ -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": "Were sorry, but we cannot find the JamKazam application installed on your computer. If you dont have the app on this computer, download and install the JamKazam app. If youre sure the app is already installed on your computer, get help from our help desk.",
"download_button": "Download JamKazam App",
"help_button": "Get Help"
}
}
}
}

View File

@ -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 (
<UserAuth path={location.pathname}>
<BrowserQueryProvider>
<DashboardMain />
<NativeAppProvider>
<DashboardMain />
</NativeAppProvider>
</BrowserQueryProvider>
</UserAuth>
);