diff --git a/app/client/mobile/src/context/useRingContext.hook.ts b/app/client/mobile/src/context/useRingContext.hook.ts index 9ca009b1..82817be1 100644 --- a/app/client/mobile/src/context/useRingContext.hook.ts +++ b/app/client/mobile/src/context/useRingContext.hook.ts @@ -34,7 +34,7 @@ export function useRingContext() { const peerUpdate = useRef([] as {type: string, data?: any}[]); const connecting = useRef(false); const passive = useRef(false); - const passiveTrack = useRef([] as MediaStreamTrack); + const passiveTracks = useRef([] as MediaStreamTrack[]); const closing = useRef(false); const [ringing, setRinging] = useState([] as { cardId: string, callId: string }[]); const [cards, setCards] = useState([] as Card[]); @@ -131,9 +131,10 @@ export function useRingContext() { if (remoteStream.current) { remoteStream.current.addTrack(data); passive.current = false; - passiveTrack.current.forEach(track => { + passiveTracks.current.forEach(track => { peer.addTrack(track, sourceStream.current); }); + passiveTracks.current = []; if (data.kind === 'video') { InCallManager.setForceSpeakerphoneOn(true); updateState({ remoteVideo: true }); @@ -142,7 +143,7 @@ export function useRingContext() { break; case 'local_track': if (passive.current) { - passiveTrack.push(data); + passiveTracks.push(data); } else { peer.addTrack(data, sourceStream.current); } @@ -167,7 +168,7 @@ export function useRingContext() { const setup = async (link: Link, card: Card, polite: boolean) => { passive.current = polite; - passiveTrack.current = []; + passiveTracks.current = []; remoteStream.current = new MediaStream(); localStream.current = new MediaStream(); sourceStream.current = await mediaDevices.getUserMedia({ diff --git a/app/client/web/src/call/Call.module.css b/app/client/web/src/call/Call.module.css new file mode 100644 index 00000000..52e8604b --- /dev/null +++ b/app/client/web/src/call/Call.module.css @@ -0,0 +1,66 @@ +.active { + width: 100%; + height: 100%; + position: absolute; +} +.inactive { + display: none; +} +.call { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} +.controls { + display: flex; + flex-direction: row; + align-items: center; + position: absolute; + bottom: 10%; + padding: 8; + gap: 12; + border-radius: 8; + opacity: 0.7; +} +.full { + width: 100%; + height: 100%; +} +.box { + position: absolute; + top: 10%; + right: 10%; + width: 20%; + height: 20%; +} +.titleView { + position: absolute; + top: 0; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + paddingTop: 92; +} +.titleName { + font-size: 24; + padding-bottom: 32; +} +.titleImage { + width: 80%; + height: auto; + aspect-ratio: 1; + border-radius: 8; +} +.duration { + padding-top: 16; + font-size: 20; +} +.logoView { + height: 100%; + width: auto; + aspect-ratio: 1; +} diff --git a/app/client/web/src/call/Call.tsx b/app/client/web/src/call/Call.tsx new file mode 100644 index 00000000..7bb8d52c --- /dev/null +++ b/app/client/web/src/call/Call.tsx @@ -0,0 +1,82 @@ +import React, { useEffect, useState } from 'react'; +import { useCall } from './useCall.hook'; +import classes from './Call.module.css' +import { Card as Contact } from '../card/Card'; +import { Colors } from '../constants/Colors'; +import { modals } from '@mantine/modals' + +export function Call() { + const { state, actions } = useCall(); + const [ending, setEnding] = useState(false); + const [applyingAudio, setApplyingAudio] = useState(false); + const [applyingVideo, setApplyingVideo] = useState(false); + const [accepting, setAccepting] = useState(null as null|string); + const [ignoring, setIgnoring] = useState(null as null|string); + const [declining, setDeclining] = useState(null as null|string); + + const showError = () => { + modals.openConfirmModal({ + title: state.strings.operationFailed, + withCloseButton: true, + overlayProps: { + backgroundOpacity: 0.55, + blur: 3, + }, + children: {state.strings.tryAgain}, + cancelProps: { display: 'none' }, + confirmProps: { display: 'none' }, + }) + } + + const toggleAudio = async () => { + if (!applyingAudio) { + setApplyingAudio(true); + try { + if (state.audioEnabled) { + await actions.disableAudio(); + } else if (!state.audioEnabled) { + await actions.enableAudio(); + } + } catch (err) { + console.log(err); + showError(); + } + setApplyingAudio(false); + } + } + + const toggleVideo = async () => { + if (!applyingVideo) { + setApplyingVideo(true); + try { + if (state.videoEnabled) { + await actions.disableVideo(); + } else if (!state.videoEnabled) { + await actions.enableVideo(); + } + } catch (err) { + console.log(err); + showError(); + } + setApplyingVideo(false); + } + } + + const end = async () => { + if (!ending) { + setEnding(true); + try { + await actions.end(); + } catch (err) { + console.log(err); + showError(); + } + setEnding(false); + } + } + + return ( +
+ ); +} + diff --git a/app/client/web/src/call/useCall.hook.ts b/app/client/web/src/call/useCall.hook.ts new file mode 100644 index 00000000..dd9a6be6 --- /dev/null +++ b/app/client/web/src/call/useCall.hook.ts @@ -0,0 +1,63 @@ +import { useState, useContext, useEffect, useRef } from 'react' +import { RingContext } from '../context/RingContext' +import { DisplayContext } from '../context/DisplayContext' +import { ContextType } from '../context/ContextType' +import { Card } from 'databag-client-sdk'; + +export function useCall() { + const ring = useContext(RingContext) as ContextType; + const display = useContext(DisplayContext) as ContextType; + const offsetTime = useRef(0); + const offset = useRef(false); + + const [state, setState] = useState({ + strings: display.state.strings, + calls: [] as { callId: string, card: Card }[], + calling: null as null | Card, + localStream: null as null|MediaStream, + remoteStream: null as null|MediaStream, + remoteVideo: false, + localVideo: false, + audioEnabled: false, + videoEnabled: false, + connected: false, + duration: 0, + failed: false, + width: 0, + height: 0, + }) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const updateState = (value: any) => { + setState((s) => ({ ...s, ...value })) + } + + useEffect(() => { + const { width, height, strings } = display.state; + updateState({ width, height, strings }); + }, [display.state]); + + useEffect(() => { + const interval = setInterval(() => { + if (offset.current) { + const now = new Date(); + const duration = Math.floor((now.getTime() / 1000) - offsetTime.current); + updateState({ duration }); + } + }, 1000); + return () => { + clearInterval(interval); + } + }, []); + + useEffect(() => { + const { calls, calling, fullscreen, localStream, remoteStream, remoteVideo, localVideo, audioEnabled, videoEnabled, connected, connectedTime, failed } = ring.state; + offsetTime.current = connectedTime; + offset.current = connected; + const duration = connected ? Math.floor(((new Date()).getTime() / 1000) - connectedTime) : 0; + updateState({ calls, calling, fullscreen, duration, localStream, remoteStream, remoteVideo, localVideo, audioEnabled, videoEnabled, connected, failed }); + }, [ring.state]); + + const actions = ring.actions; + return { state, actions }; +} diff --git a/app/client/web/src/contacts/Contacts.tsx b/app/client/web/src/contacts/Contacts.tsx index cec3e93b..8b5f5ae1 100644 --- a/app/client/web/src/contacts/Contacts.tsx +++ b/app/client/web/src/contacts/Contacts.tsx @@ -38,7 +38,7 @@ function Action({ icon, color, strings, select }: { icon: ReactNode; color: stri ) } -export function Contacts({ openRegistry, openContact, textContact, callContact }: { openRegistry: ()=>void; openContact: (params: ProfileParams)=>void, textContact: (cardId: string)=>void, callContact: (card: Card)=>void }) { +export function Contacts({ openRegistry, openContact, textContact }: { openRegistry: ()=>void; openContact: (params: ProfileParams)=>void, textContact: (cardId: string)=>void }) { const { state, actions } = useContacts() const cards = state.filtered.map((card, idx) => { @@ -48,7 +48,7 @@ export function Contacts({ openRegistry, openContact, textContact, callContact } const phone = const text = return [ - callContact(card)} strings={state.strings} />, + actions.call(card)} strings={state.strings} />, textContact(card.cardId)} strings={state.strings} />, ] } else if (status === 'offsync') { diff --git a/app/client/web/src/contacts/useContacts.hook.ts b/app/client/web/src/contacts/useContacts.hook.ts index bb3e0f26..47bc8141 100644 --- a/app/client/web/src/contacts/useContacts.hook.ts +++ b/app/client/web/src/contacts/useContacts.hook.ts @@ -1,5 +1,6 @@ import { useState, useContext, useEffect } from 'react' import { AppContext } from '../context/AppContext' +import { RingContext } from '../context/RingContext' import { DisplayContext } from '../context/DisplayContext' import { ContextType } from '../context/ContextType' import { Card } from 'databag-client-sdk' @@ -7,6 +8,7 @@ import { Card } from 'databag-client-sdk' export function useContacts() { const app = useContext(AppContext) as ContextType const display = useContext(DisplayContext) as ContextType + const ring = useContext(RingContext) as ContextType const [state, setState] = useState({ strings: display.state.strings, cards: [] as Card[], @@ -64,6 +66,9 @@ export function useContacts() { }, [state.sortAsc, state.filter, state.cards]) const actions = { + call: async (card: Card) => { + await ring.actions.call(card); + }, toggleSort: () => { const sortAsc = !state.sortAsc updateState({ sortAsc }) diff --git a/app/client/web/src/context/useRingContext.hook.ts b/app/client/web/src/context/useRingContext.hook.ts index aa757b70..addc06ef 100644 --- a/app/client/web/src/context/useRingContext.hook.ts +++ b/app/client/web/src/context/useRingContext.hook.ts @@ -18,6 +18,8 @@ export function useRingContext() { const peerUpdate = useRef([] as {type: string, data?: any}[]); const connecting = useRef(false); const closing = useRef(false); + const passive = useRef(false); + const passiveTracks = useRef([] as { track: MediaStreamTrack, stream: MediaStream }[]); const [ringing, setRinging] = useState([] as { cardId: string, callId: string }[]); const [cards, setCards] = useState([] as Card[]); @@ -32,6 +34,7 @@ export function useRingContext() { videoEnabled: false, connected: false, failed: false, + fullscreen: false, }) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -124,13 +127,22 @@ export function useRingContext() { case 'remote_track': if (remoteStream.current) { remoteStream.current.addTrack(data); + passive.current = false; + passiveTracks.current.forEach(data => { + peer.addTrack(data.track, data.stream); + }); + passiveTracks.current = []; if (data.kind === 'video') { updateState({ remoteVideo: true }); } } break; case 'local_track': - peer.addTrack(data.track, data.stream); + if (passive.current) { + passiveTracks.current.push(data); + } else { + peer.addTrack(data.track, data.stream); + } if (data.track.kind === 'audio') { localAudio.current = data.track; } @@ -153,7 +165,8 @@ export function useRingContext() { } } - const setup = async (link: Link, card: Card) => { + const setup = async (link: Link, card: Card, polite: boolean) => { + passive.current = polite; localAudio.current = null; localVideo.current = null; localStream.current = null; @@ -242,6 +255,9 @@ export function useRingContext() { }, [app.state.session]); const actions = { + setFullscreen: (fullscreen: boolean) => { + updateState({ fullscreen }); + }, ignore: async (callId: string, card: Card) => { const ring = app.state.session.getRing(); await ring.ignore(card.cardId, callId); @@ -262,7 +278,7 @@ export function useRingContext() { const { cardId, node } = card; const ring = app.state.session.getRing(); const link = await ring.accept(cardId, callId, node); - await setup(link, card); + await setup(link, card, true); connecting.current = false; } catch (err) { connecting.current = false; @@ -277,7 +293,7 @@ export function useRingContext() { connecting.current = true; const contact = app.state.session.getContact(); const link = await contact.callCard(card.cardId); - await setup(link, card); + await setup(link, card, false); connecting.current = false; } catch (err) { connecting.current = false; diff --git a/app/client/web/src/ring/Ring.module.css b/app/client/web/src/ring/Ring.module.css new file mode 100644 index 00000000..50d83d6a --- /dev/null +++ b/app/client/web/src/ring/Ring.module.css @@ -0,0 +1,57 @@ +active { + width: 100%; + height: 64px; + max-width: 500px; + display: flex; + align-items: center; + justify-content: center; +} +inactive { + display: none; +} +ring { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + border-radius: 16px; + padding-left: 16px; + padding-right: 8px; +} +card { + padding: 8px; + width: 100%; +} +circleIcon { +} +flipIcon { + transform: rotate(135deg); +} +end { +} +name { + flex-grow: 1; + flex-shrink: 1; + display: flex; + flex-direction: row; + padding-left: 8px; +} +nameSet { + font-size: 20px; +} +nameUnset { + font-size: 20px; + font-style: italic; +} +status { + width: 64px; + display: flex; + align-items: center; + justify-content: center; +} +duration { + color: Colors.primary; + font-size: 20px; +} diff --git a/app/client/web/src/ring/Ring.tsx b/app/client/web/src/ring/Ring.tsx new file mode 100644 index 00000000..72092bbe --- /dev/null +++ b/app/client/web/src/ring/Ring.tsx @@ -0,0 +1,118 @@ +import React, { useEffect, useState } from 'react'; +import { useRing } from './useRing.hook'; +import classes from './Ring.module.css'; +import { Card as Contact } from '../card/Card'; +import { Colors } from '../constants/Colors'; +import { modals } from '@mantine/modals' +import { Loader, Image, Text, ActionIcon } from '@mantine/core' +import { IconEyeX, IconPhone, IconPhoneOff, IconMicrophone, IconMicrophoneOff } from '@tabler/icons-react' + +export function Ring() { + const { state, actions } = useRing(); + const [ending, setEnding] = useState(false); + const [applyingAudio, setApplyingAudio] = useState(false); + const [accepting, setAccepting] = useState(null as null|string); + const [ignoring, setIgnoring] = useState(null as null|string); + const [declining, setDeclining] = useState(null as null|string); + + const showError = () => { + modals.openConfirmModal({ + title: state.strings.operationFailed, + withCloseButton: true, + overlayProps: { + backgroundOpacity: 0.55, + blur: 3, + }, + children: {state.strings.tryAgain}, + cancelProps: { display: 'none' }, + confirmProps: { display: 'none' }, + }) + } + + const toggleAudio = async () => { + if (!applyingAudio) { + setApplyingAudio(true); + try { + if (state.audioEnabled) { + await actions.disableAudio(); + } else if (!state.audioEnabled) { + await actions.enableAudio(); + } + } catch (err) { + console.log(err); + showError(); + } + setApplyingAudio(false); + } + } + + const end = async () => { + if (!ending) { + setEnding(true); + try { + await actions.end(); + } catch (err) { + console.log(err); + showError(); + } + setEnding(false); + } + } + + const accept = async (callId, card) => { + if (!accepting) { + setAccepting(callId); + try { + await actions.accept(callId, card); + } catch (err) { + console.log(err); + showError(); + } + setAccepting(null); + } + } + + const ignore = async (callId, card) => { + if (!ignoring) { + setIgnoring(callId); + try { + await actions.ignore(callId, card); + } catch (err) { + console.log(err); + showError(); + } + setIgnoring(null); + } + } + + const decline = async (callId, card) => { + if (!declining) { + setDeclining(callId); + try { + await actions.decline(callId, card); + } catch (err) { + console.log(err); + showError(); + } + setDeclining(null); + } + } + + const calls = state.calls.map((ring, index) => { + const { name, handle, node, imageUrl } = ring.card; + const ignoreButton = ignore(ring)} color={Colors.pending}> + const declineButton =
decline(ring)} color={Colors.offsync}>
+ const acceptButton = accept(ring)} color={Colors.primary}> + + return ( +
+ +
+ ) + }); + + return ( +
+ ); +} + diff --git a/app/client/web/src/ring/useRing.hook.ts b/app/client/web/src/ring/useRing.hook.ts new file mode 100644 index 00000000..47aca1e2 --- /dev/null +++ b/app/client/web/src/ring/useRing.hook.ts @@ -0,0 +1,53 @@ +import { useState, useContext, useEffect, useRef } from 'react' +import { RingContext } from '../context/RingContext' +import { DisplayContext } from '../context/DisplayContext' +import { ContextType } from '../context/ContextType' +import { Card } from 'databag-client-sdk'; + +export function useRing() { + const ring = useContext(RingContext) as ContextType; + const display = useContext(DisplayContext) as ContextType; + const offsetTime = useRef(0); + const offset = useRef(false); + + const [state, setState] = useState({ + strings: display.state.strings, + calls: [] as { callId: string, card: Card }[], + calling: null as null | Card, + remoteVideo: false, + localVideo: false, + audioEnabled: false, + connected: false, + duration: 0, + failed: false, + }) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const updateState = (value: any) => { + setState((s) => ({ ...s, ...value })) + } + + useEffect(() => { + const interval = setInterval(() => { + if (offset.current) { + const now = new Date(); + const duration = Math.floor((now.getTime() / 1000) - offsetTime.current); + updateState({ duration }); + } + }, 1000); + return () => { + clearInterval(interval); + } + }, []); + + useEffect(() => { + const { calls, calling, localVideo, remoteVideo, audioEnabled, connected, connectedTime, failed } = ring.state; + offsetTime.current = connectedTime; + offset.current = connected; + const duration = connected ? Math.floor(((new Date()).getTime() / 1000) - connectedTime) : 0; + updateState({ calls, calling, duration, localVideo, remoteVideo, audioEnabled, connected, failed }); + }, [ring.state]); + + const actions = ring.actions; + return { state, actions }; +} diff --git a/app/client/web/src/session/Session.module.css b/app/client/web/src/session/Session.module.css index 1bdfda4a..2145b571 100644 --- a/app/client/web/src/session/Session.module.css +++ b/app/client/web/src/session/Session.module.css @@ -26,13 +26,21 @@ } } - .show { + .body { display: flex; width: 100%; height: calc(100% - 48px); position: absolute; top: 0; left: 0; + flex-direction: column; + } + + .show { + display: flex; + flex-grow: 1; + height: 10%; + position: relative; } .hide { @@ -63,10 +71,18 @@ .right { height: 100%; - display: flex; flex-grow: 1; min-width: 0; background: var(--mantine-color-surface-3); + display: flex; + flex-direction: column; + + .conversation { + display: flex; + height: 0; + flex-grow: 1; + min-width: 0; + } } } diff --git a/app/client/web/src/session/Session.tsx b/app/client/web/src/session/Session.tsx index e34dda11..5147ff9b 100644 --- a/app/client/web/src/session/Session.tsx +++ b/app/client/web/src/session/Session.tsx @@ -17,7 +17,8 @@ import { Conversation } from '../conversation/Conversation' import { Focus, Card } from 'databag-client-sdk' import { useDisclosure } from '@mantine/hooks' import { IconAlertCircle } from '@tabler/icons-react' -import { Calling } from '../calling/Calling'; +import { Ring } from '../ring/Ring'; +import { Call } from '../call/Call'; export function Session() { const { state } = useSession(); @@ -29,7 +30,6 @@ export function Session() { const [details, { open: openDetails, close: closeDetails }] = useDisclosure(false) const [profile, { open: openProfile, close: closeProfile }] = useDisclosure(false) const [textCard, setTextCard] = useState({ cardId: null} as {cardId: null|string}); - const [callCard, setCallCard] = useState({ card: null} as {card: null|Card}); const textContact = (cardId: string) => { console.log("MESSAGE: ", cardId); @@ -38,63 +38,61 @@ export function Session() { setTab('content'); } - const callContact = (card: Card) => { - setCallCard({ card }); - } - return (
{state.layout === 'small' && ( <> -
-
- -
- {state.focus && ( +
+ +
- +
- )} - {details && ( + {state.focus && ( +
+ +
+ )} + {details && ( +
+
+
+ )} +
+
-
+
- )} -
-
-
-
-
-
-
- { - setProfileParams(params) - openProfile() - }} - /> -
- {registry && ( +
- { setProfileParams(params) openProfile() }} />
- )} - {profile && ( -
- -
- )} + {registry && ( +
+ { + setProfileParams(params) + openProfile() + }} + /> +
+ )} + {profile && ( +
+ +
+ )} +
{tab === 'content' && ( @@ -140,11 +138,15 @@ export function Session() {
-
{state.focus && }
+
+ +
+ {state.focus && } +
+
{ @@ -189,7 +191,7 @@ export function Session() {
)} - +
)