diff --git a/jam-ui/cypress/e2e/sessions/browse-sessions-page.cy.js b/jam-ui/cypress/e2e/sessions/browse-sessions-page.cy.js new file mode 100644 index 000000000..635ac4637 --- /dev/null +++ b/jam-ui/cypress/e2e/sessions/browse-sessions-page.cy.js @@ -0,0 +1,33 @@ +/// + +describe('Browse sessions', () => { + beforeEach(() => { + cy.stubAuthenticate({ id: '6'}) + }); + + describe('when there are no active sessions', () => { + beforeEach(() => { + cy.intercept('GET', /\S+\/api\/sessions/, { + body: [] + }) + cy.visit('/sessions') + }); + + it("alerts when there is no records", () => { + cy.contains("No Records!") + }) + }) + + describe('when there are active sessions', () => { + beforeEach(() => { + cy.intercept('GET', /\S+\/api\/sessions/, { fixture: 'sessions' }) + cy.visit('/sessions') + }); + + it("lists the sessions", () => { + cy.get('[data-testid=sessionsListTable] tbody tr').should('have.length', 1) + }) + }) + + + }) diff --git a/jam-ui/cypress/fixtures/sessions.json b/jam-ui/cypress/fixtures/sessions.json new file mode 100644 index 000000000..30fccc621 --- /dev/null +++ b/jam-ui/cypress/fixtures/sessions.json @@ -0,0 +1,82 @@ +[ + { + "id": "df953ba-7c59-4762-8cc3-279db82e872a", + "name": "Open Session", + "description": "Feel free to join this session, it's open!", + "musician_access": true, + "approval_required": false, + "friends_can_join": false, + "fan_access": true, + "fan_chat": false, + "user_id": "29becbf4-8be5-4078-9405-0edadc9fa42d", + "claimed_recording_initiator_id": null, + "track_changes_counter": 0, + "max_score": 0, + "backing_track_path": null, + "metronome_active": false, + "jam_track_initiator_id": null, + "jam_track_id": null, + "music_session_id_int": 2210, + "use_video_conferencing_server": false, + "music_notations": [], + "participants": [ + { + "ip_address": "192.168.1.110", + "client_id": "63cdbcf7-a3c6-49bf-9412-0c09d4a6796b", + "joined_session_at": "2023-10-26T07:16:22.605Z", + "id": "3c1f2a74-0ccf-4ed1-9828-ce909edf61b7", + "metronome_open": false, + "is_jamblaster": false, + "client_role": "parent", + "parent_client_id": null, + "client_id_int": 78125, + "tracks": [ + { + "id": "833de71f-7bc0-4d8e-9ea6-8a695181960b", + "connection_id": "3c1f2a74-0ccf-4ed1-9828-ce909edf61b7", + "instrument_id": "electric guitar", + "sound": "stereo", + "client_track_id": "FWAPMulti_2_10200m", + "client_resource_id": "FWAPMulti_2_10200", + "updated_at": "2023-10-26T07:16:22.611Z", + "instrument": "Electric Guitar" + } + ], + "backing_tracks": [], + "user": { + "id": "29becbf4-8be5-4078-9405-0edadc9fa42d", + "photo_url": null, + "name": "Nuwan Chathuranga", + "is_friend": true, + "connection_state": "connected", + "subscription": null + } + } + ], + "invitations": [ + { + "id": "d49f3c07-7f49-4dad-8de1-2020046438de", + "sender_id": "29becbf4-8be5-4078-9405-0edadc9fa42d", + "receiver_id": "27bd4a30-d1b8-4eea-8454-01a104d59381" + } + ], + "lesson_session": null, + "mount": { + "id": "c3504b02-dc18-4d85-a562-117eaaffc136", + "name": "/5tZz2_G8kT0-8UlUAna_yQ.mp3", + "sourced": false, + "listeners": 0, + "bitrate": 128, + "subtype": null, + "url": "http://localhost:10000/5tZz2_G8kT0-8UlUAna_yQ.mp3", + "mime_type": "audio/mpeg" + }, + "can_join": true, + "genres": [ + "Pop" + ], + "recording": null, + "share_url": "http://www.jamkazam.local:3000/s/T50BWPH9ICC", + "session_controller_id": "29becbf4-8be5-4078-9405-0edadc9fa42d" + } +] \ No newline at end of file diff --git a/jam-ui/src/components/page/JKMusicSessions.js b/jam-ui/src/components/page/JKMusicSessions.js index 59b0d1bb3..19568bf73 100644 --- a/jam-ui/src/components/page/JKMusicSessions.js +++ b/jam-ui/src/components/page/JKMusicSessions.js @@ -1,9 +1,72 @@ -import React from 'react' +import React, { useEffect } from 'react'; +import { + Alert, + Col, + Row, + Button, + Card, + CardBody, + Table, + Form, + Modal, + ModalHeader, + ModalBody, + ModalFooter +} 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 SessionRow from '../sessions/SessionRow'; +import Loader from '../common/Loader'; +import { isIterableArray } from '../../helpers/utils'; function JKMusicSessions() { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const sessions = useSelector(state => state.session.sessions); + const loadingStatus = useSelector(state => state.session.status); + + useEffect(() => { + dispatch(fetchSessions()); + }, []); + return ( -
Music Sessions Listing
- ) + + + + + + + + + + + + + + + {loadingStatus === 'loading' && sessions.length === 0 ? ( + + ) : isIterableArray(sessions) ? ( + sessions.map(session => ) + ) : ( + + + + No Records! + + + + )} + +
{t('list.header.session', { ns: 'sessions' })} + {t('list.header.musicians', { ns: 'sessions' })} + {t('list.header.latency', { ns: 'sessions' })}{t('list.header.instruments', { ns: 'sessions' })}{t('actions', { ns: 'common' })}
+
+
+ ); } -export default JKMusicSessions \ No newline at end of file +export default JKMusicSessions; diff --git a/jam-ui/src/components/page/JKNewMusicSession.js b/jam-ui/src/components/page/JKNewMusicSession.js index ded7e0a3d..abf84fab0 100644 --- a/jam-ui/src/components/page/JKNewMusicSession.js +++ b/jam-ui/src/components/page/JKNewMusicSession.js @@ -8,6 +8,7 @@ import { useAuth } from '../../context/UserAuth'; import JKFriendsAutoComplete from '../people/JKFriendsAutoComplete'; import JKSessionInviteesChips from '../people/JKSessionInviteesChips'; import { getFriends } from '../../helpers/rest'; +import jkCustomUrlScheme from '../../helpers/jkCustomUrlScheme'; const privacyMap = { "public": 1, @@ -80,8 +81,9 @@ const JKNewMusicSession = () => { //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 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. diff --git a/jam-ui/src/components/sessions/SessionRow.js b/jam-ui/src/components/sessions/SessionRow.js new file mode 100644 index 000000000..ce1a19b22 --- /dev/null +++ b/jam-ui/src/components/sessions/SessionRow.js @@ -0,0 +1,99 @@ +import React, { useEffect } from 'react'; +import jkCustomUrlScheme from '../../helpers/jkCustomUrlScheme'; +import JKProfileAvatar from '../profile/JKProfileAvatar'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { useAuth } from '../../context/UserAuth'; +import SessionUserLatency from './SessionUserLatency'; +import { useDispatch, useSelector } from 'react-redux'; +import { fetchUserLatencies } from '../../store/features/latencySlice'; +import SessionUser from './SessionUser'; + +function SessionRow({session}) { + const { currentUser } = useAuth(); + const dispatch = useDispatch() + + useEffect(() => { + const participantIds = session.participants.map(p => p.user.id) + const options = { currentUserId: currentUser.id, participantIds } + dispatch(fetchUserLatencies(options)) + }, []) + + function joinSession(){ + const q = `joinSessionId~${session.id}` + const urlScheme = jkCustomUrlScheme('findSession', q) + window.open(urlScheme) + } + + const sessionDescription = (session) => { + if(session.description){ + return session.description; + }else if(session.musician_access && !session.approval_required){ + return "Public, open session. Feel free to join!" + }else if(session.musician_access && session.approval_required){ + return "Private session. Click the enter button in the right column to request to join" + }else if(!session.musician_access && !session.approval_required){ + return "Only RSVP musicians may join" + } + } + + const invitedNote = (session) => { + if(session.invitations.find(i => i.receiver_id === currentUser.id)){ + return "YOU WERE INVITED TO THIS SESSION" + } + } + + const hasFriendNote = session => { + if(session.participants.find(p => p.user.is_friend)){ + return "YOU HAVE A FRIEND IN THIS SESSION" + } + } + + return ( + + +
+ + + {invitedNote(session)} + + +
+
+ + {hasFriendNote(session)} + +
+
{sessionDescription(session)}
+ + + {session.participants.map(participant => ( + + ))} + + +
+ {session.participants.map(participant => ( + + ))} +
+ + +
+ {session.participants.map(participant => + participant.tracks.map(track =>
{track.instrument}
) + )} +
+ + + + + + + + + + + ); +} + +export default SessionRow; diff --git a/jam-ui/src/components/sessions/SessionUser.js b/jam-ui/src/components/sessions/SessionUser.js new file mode 100644 index 000000000..8afc72c43 --- /dev/null +++ b/jam-ui/src/components/sessions/SessionUser.js @@ -0,0 +1,37 @@ +import React, { useState } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import JKProfileSidePanel from '../profile/JKProfileSidePanel'; +import JKProfileAvatar from '../profile/JKProfileAvatar'; +import { fetchPerson } from '../../store/features/peopleSlice'; + +function SessionUser({ user }) { + const dispatch = useDispatch() + const latencyData = useSelector(state => state.latency.latencies.find(l => l.user_id === user.id)); + const [showSidePanel, setShowSidePanel] = useState(false); + + const toggleMoreDetails = async e => { + e.preventDefault(); + try { + await dispatch(fetchPerson({ userId: user.id })).unwrap(); + } catch (error) { + console.log(error); + } + + setShowSidePanel(prev => !prev); + }; + return ( + <> +
+
+ +
+
+ {user.name} +
+
+ + + ); +} + +export default SessionUser; diff --git a/jam-ui/src/components/sessions/SessionUserLatency.js b/jam-ui/src/components/sessions/SessionUserLatency.js new file mode 100644 index 000000000..5b9338db1 --- /dev/null +++ b/jam-ui/src/components/sessions/SessionUserLatency.js @@ -0,0 +1,12 @@ +import React, { useState, useEffect } from 'react'; +import JKLatencyBadge from '../profile/JKLatencyBadge'; +import { useSelector } from 'react-redux'; + +function SessionUserLatency({user}) { + + const latencyData = useSelector(state => state.latency.latencies.find(l => l.user_id === user.id)); + + return ; +} + +export default SessionUserLatency; diff --git a/jam-ui/src/helpers/initFA.js b/jam-ui/src/helpers/initFA.js index 41397a800..8e78331af 100644 --- a/jam-ui/src/helpers/initFA.js +++ b/jam-ui/src/helpers/initFA.js @@ -156,7 +156,8 @@ import { faCross, faMusic, faRecordVinyl, - faAddressCard + faAddressCard, + faVolumeUp } from '@fortawesome/free-solid-svg-icons'; library.add( @@ -279,6 +280,7 @@ library.add( faMusic, faRecordVinyl, faAddressCard, + faVolumeUp, // Brand faFacebook, diff --git a/jam-ui/src/helpers/jkCustomUrlScheme.js b/jam-ui/src/helpers/jkCustomUrlScheme.js new file mode 100644 index 000000000..c8f6dc847 --- /dev/null +++ b/jam-ui/src/helpers/jkCustomUrlScheme.js @@ -0,0 +1,6 @@ + +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 +} diff --git a/jam-ui/src/helpers/rest.js b/jam-ui/src/helpers/rest.js index a185c1118..92f9517e1 100644 --- a/jam-ui/src/helpers/rest.js +++ b/jam-ui/src/helpers/rest.js @@ -136,4 +136,21 @@ export const deleteNotification = (userId, notificationId) => { .then(response => resolve(response)) .catch(error => reject(error)) }) +} + +export const getSessions = () => { + return new Promise((resolve, reject) => { + apiFetch(`/sessions`) + .then(response => resolve(response)) + .catch(error => reject(error)) + }) +} + +export const getLatencyToUsers = (currentUserId, participantIds) => { + return new Promise((resolve, reject) => { + const options = { user_ids: [participantIds]} + apiFetch(`/users/${currentUserId}/latencies?${new URLSearchParams(options)}`) + .then(response => resolve(response)) + .catch(error => reject(error)) + }) } \ No newline at end of file diff --git a/jam-ui/src/i18n/locales/en/sessions.json b/jam-ui/src/i18n/locales/en/sessions.json index c1c407f42..7aae9c31b 100644 --- a/jam-ui/src/i18n/locales/en/sessions.json +++ b/jam-ui/src/i18n/locales/en/sessions.json @@ -16,5 +16,15 @@ "description_help": "If you’re creating a public session, we strongly recommend that you enter a description of your session – for example, what kinds of music you’re interested in playing. This description will be displayed next to your session in the Browse Sessions feature, which will help other musicians in the community understand if your session is a good fit for them.", "description_placeholder": "Enter session description. Recommended for public sessions to attract other musicians and them know what to expect in your session.", "create_session": "Create Session" + }, + "list": { + "page_title": "Browse Current Sessions", + "header": { + "session": "Session", + "musicians": "Musicians", + "latency": "Latency", + "instruments": "Instruments", + "actions": "Actions" + } } } \ No newline at end of file diff --git a/jam-ui/src/store/features/latencySlice.js b/jam-ui/src/store/features/latencySlice.js new file mode 100644 index 000000000..ea6aa6bf6 --- /dev/null +++ b/jam-ui/src/store/features/latencySlice.js @@ -0,0 +1,46 @@ +import { createSlice, createAsyncThunk } from "@reduxjs/toolkit" +import { getLatencyToUsers } from "../../helpers/rest" + +const initialState = { + status: 'idel', + latencies: [] +} + + +export const fetchUserLatencies = createAsyncThunk( + 'latency/fetchUserLatencies', + async (options, thunkAPI) => { + const { currentUserId, participantIds } = options + const response = await getLatencyToUsers(currentUserId, participantIds) + return response.json() + } +) + +export const latencySlice = createSlice({ + name: 'latency', + initialState, + reducers: { + add: (state, action) => { + state.latencies.push(action.payload) + } + }, + extraReducers: (builder) => { + builder + .addCase(fetchUserLatencies.pending, (state, action) => { + state.status = 'loading' + }) + .addCase(fetchUserLatencies.fulfilled, (state, action) => { + console.log("_DEBUG_ fetchUserLatencies", action.payload); + const records = new Set([...state.latencies, ...action.payload]); + const unique = []; + records.map(x => unique.filter(a => a.user_id === x.user_id).length > 0 ? null : unique.push(x)) + state.latencies = unique + }) + .addCase(fetchUserLatencies.rejected, (state, action) => { + console.log("_DEBUG_ fail fetchUserLatencies", action.payload); + state.status = 'failed' + }) + } +}) + +export default latencySlice.reducer; \ No newline at end of file diff --git a/jam-ui/src/store/features/sessionsSlice.js b/jam-ui/src/store/features/sessionsSlice.js new file mode 100644 index 000000000..9628c6596 --- /dev/null +++ b/jam-ui/src/store/features/sessionsSlice.js @@ -0,0 +1,43 @@ +import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"; +import { getSessions } from '../../helpers/rest' + +const initialState = { + sessions: [], + status: 'idel', + error: null, +} + +export const fetchSessions = createAsyncThunk( + "session/fetchSessions", + async (options, thunkAPI) => { + const response = await getSessions(); + return response.json(); + } + +) + +export const SessionSlice = createSlice({ + name: "session", + initialState, + reducers: { + addSession: (state) => {}, + updateSession: (state) => {}, + deleteSession: (state) => {}, + }, + extraReducers: (builder) => { + builder + .addCase(fetchSessions.pending, (state, action) => { + state.status = "loading"; + }) + .addCase(fetchSessions.fulfilled, (state, action) => { + state.status = "succeeded"; + state.sessions = action.payload; + }) + .addCase(fetchSessions.rejected, (state, action) => { + state.status = 'failed' + state.error = action.error.message + }) + } +}) + +export default SessionSlice.reducer; \ No newline at end of file diff --git a/jam-ui/src/store/store.js b/jam-ui/src/store/store.js index ddff302d8..113b40565 100644 --- a/jam-ui/src/store/store.js +++ b/jam-ui/src/store/store.js @@ -1,12 +1,16 @@ import { configureStore } from "@reduxjs/toolkit" import textMessageReducer from "./features/textMessagesSlice" import peopleSlice from "./features/peopleSlice" +import sessionSlice from "./features/sessionsSlice" import notificationSlice from './features/notificationSlice' +import latencySlice from "./features/latencySlice" export default configureStore({ reducer: { textMessage: textMessageReducer, people: peopleSlice, - notification: notificationSlice + notification: notificationSlice, + session: sessionSlice, + latency: latencySlice } }) \ No newline at end of file diff --git a/web/app/assets/javascripts/react-components/FindSessionScreen.js.jsx.coffee b/web/app/assets/javascripts/react-components/FindSessionScreen.js.jsx.coffee index 0edba8f2f..d7d38c576 100644 --- a/web/app/assets/javascripts/react-components/FindSessionScreen.js.jsx.coffee +++ b/web/app/assets/javascripts/react-components/FindSessionScreen.js.jsx.coffee @@ -1,5 +1,6 @@ context = window MIX_MODES = context.JK.MIX_MODES +AppStore = context.AppStore SessionsActions = @SessionsActions @@ -79,8 +80,33 @@ SessionsActions = @SessionsActions return beforeShow: () -> + @initCustomUrlScheme() return + ensuredCallback: (sessionId) -> + context.JK.SessionUtils.joinSession(sessionId) + + joinSession: (sessionId) -> + context.JK.SessionUtils.ensureValidClient(AppStore.app, context.JK.GearUtils, @ensuredCallback.bind(this, sessionId)) + + initCustomUrlScheme: () -> + hash = decodeURIComponent(context.location.hash) + qStr = hash.substring(hash.lastIndexOf('/') + 1) + qParamsArr = qStr.split('|') + isCustom = undefined + sessionId = undefined + qParamsArr.forEach (q) -> + qp = q.split('~') + if qp[0] == 'custom' + isCustom = qp[1] + if qp[0] == 'joinSessionId' + sessionId = qp[1] + if !isCustom || isCustom != 'yes' + return + unless sessionId + return + @joinSession(sessionId) + afterShow: () -> SessionsActions.watching.trigger(true)