preparing for updated call screen in webapp

This commit is contained in:
Roland Osborne 2025-02-06 13:11:56 -08:00
parent fc0ba2a187
commit a717a6d88e
12 changed files with 536 additions and 57 deletions

View File

@ -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({

View File

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

View File

@ -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: <Text>{state.strings.tryAgain}</Text>,
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 (
<div />
);
}

View File

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

View File

@ -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 = <IconPhone size={24} />
const text = <IconMessage2 size={24} />
return [
<Action key="phone" icon={phone} color={Colors.connected} select={async () => callContact(card)} strings={state.strings} />,
<Action key="phone" icon={phone} color={Colors.connected} select={async () => actions.call(card)} strings={state.strings} />,
<Action key="text" icon={text} color={Colors.connected} select={async () => textContact(card.cardId)} strings={state.strings} />,
]
} else if (status === 'offsync') {

View File

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

View File

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

View File

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

View File

@ -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: <Text>{state.strings.tryAgain}</Text>,
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 = <ActionIcon key="ignore" variant="subtle" loading={ignoring===ring.callId} onClick={()=>ignore(ring)} color={Colors.pending}><IconEyeX /></ActionIcon>
const declineButton = <div key="decline" className={classes.space}><ActionIcon variant="subtle" loading={declining===ring.callId} onClick={()=>decline(ring)} color={Colors.offsync}><IconPhone className={classes.off} /></ActionIcon></div>
const acceptButton = <ActionIcon key="accept" variant="subtle" loading={accepting===ring.callId} onClick={()=>accept(ring)} color={Colors.primary}><IconPhone /></ActionIcon>
return (
<div key={index} className={classes.caller}>
<Contact className={classes.card} placeholder={''} imageUrl={imageUrl} name={name} node={node} handle={handle} actions={[ignoreButton, declineButton, acceptButton]} />
</div>
)
});
return (
<div style={{ width: '100%', height: 14, backgroundColor: 'yellow' }} />
);
}

View File

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

View File

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

View File

@ -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 (
<RingContextProvider>
<div className={classes.session}>
{state.layout === 'small' && (
<>
<div className={tab === 'content' ? classes.show : classes.hide}>
<div className={classes.screen}>
<Content textCard={textCard} />
</div>
{state.focus && (
<div className={classes.body}>
<Ring />
<div className={tab === 'content' ? classes.show : classes.hide}>
<div className={classes.screen}>
<Conversation openDetails={openDetails} />
<Content textCard={textCard} />
</div>
)}
{details && (
{state.focus && (
<div className={classes.screen}>
<Conversation openDetails={openDetails} />
</div>
)}
{details && (
<div className={classes.screen}>
<Details showClose={true} close={closeDetails} />
</div>
)}
</div>
<div className={tab === 'settings' ? classes.show : classes.hide}>
<div className={classes.screen}>
<Details showClose={true} close={closeDetails} />
<Settings showLogout={true} />
</div>
)}
</div>
<div className={tab === 'settings' ? classes.show : classes.hide}>
<div className={classes.screen}>
<Settings showLogout={true} />
</div>
</div>
<div className={tab === 'contacts' ? classes.show : classes.hide}>
<div className={classes.screen}>
<Contacts
callContact={callContact}
textContact={textContact}
openRegistry={openRegistry}
openContact={(params) => {
setProfileParams(params)
openProfile()
}}
/>
</div>
{registry && (
<div className={tab === 'contacts' ? classes.show : classes.hide}>
<div className={classes.screen}>
<Registry
close={closeRegistry}
<Contacts
textContact={textContact}
openRegistry={openRegistry}
openContact={(params) => {
setProfileParams(params)
openProfile()
}}
/>
</div>
)}
{profile && (
<div className={classes.screen}>
<Profile params={profileParams} showClose={true} close={closeProfile} />
</div>
)}
{registry && (
<div className={classes.screen}>
<Registry
close={closeRegistry}
openContact={(params) => {
setProfileParams(params)
openProfile()
}}
/>
</div>
)}
{profile && (
<div className={classes.screen}>
<Profile params={profileParams} showClose={true} close={closeProfile} />
</div>
)}
</div>
</div>
<div className={classes.tabs}>
{tab === 'content' && (
@ -140,11 +138,15 @@ export function Session() {
<Content textCard={textCard} />
</div>
</div>
<div className={classes.right}>{state.focus && <Conversation openDetails={openDetails} />}</div>
<div className={classes.right}>
<Ring />
<div className={classes.conversation}>
{state.focus && <Conversation openDetails={openDetails} />}
</div>
</div>
<Drawer opened={contacts} onClose={closeContacts} withCloseButton={false} size="md" padding="0" position="right">
<div style={{ height: '100vh' }}>
<Contacts
callContact={callContact}
textContact={textContact}
openRegistry={openRegistry}
openContact={(params) => {
@ -189,7 +191,7 @@ export function Session() {
</div>
</div>
)}
<Calling callCard={callCard} />
<Call />
</div>
</RingContextProvider>
)