diff --git a/sample-apps/react/react-dogfood/components/EndCallSummary.tsx b/sample-apps/react/react-dogfood/components/EndCallSummary.tsx new file mode 100644 index 0000000000..ee3cc435bf --- /dev/null +++ b/sample-apps/react/react-dogfood/components/EndCallSummary.tsx @@ -0,0 +1,234 @@ +import { Icon } from '@stream-io/video-react-sdk'; +import Link from 'next/link'; +import clsx from 'clsx'; +import { useState, useCallback } from 'react'; + +type NetworkStatusProps = { + status: number; + children: React.ReactNode; +}; + +export function NetworkStatus({ status, children }: NetworkStatusProps) { + const bars = Array.from({ length: 12 }, (_, index) => { + // Calculate if this bar should be active based on status + // Each bar represents ~8.33% (100/12) + const good = (index + 1) * 8.33 <= status; + const average = (index + 1) * 8.33 > status && (index + 1) * 8.33 < status; + const bad = (index + 1) * 8.33 > status; + + return ( +
+ ); + }); + + return ( +
+ {children} +
{bars}
+
+ ); +} + +type CardProps = { + title?: string; + tooltip?: string; + link?: string; + children: React.ReactNode; + variant?: 'parent' | 'child'; +}; + +export function Card({ + tooltip, + link, + title, + children, + variant = 'child', +}: CardProps) { + return ( +
+ {title &&

{title}

} + {tooltip && } + {link && ( + + {link} + + )} + {children} +
+ ); +} + +type BadgeProps = { + children: React.ReactNode; + status?: 'good' | 'average' | 'bad'; + variant?: 'small' | 'large'; + link?: string; +}; + +export function LinkBadge({ children, link, ...rest }: BadgeProps) { + if (!link) { + return {children}; + } + + return ( + + {children} + + ); +} + +export function Badge({ children, status, variant }: BadgeProps) { + return ( +
+ {status && ( + + )} + {children} +
+ ); +} + +type TooltipProps = { + explanation: string; +}; + +export const Tooltip = ({ explanation }: TooltipProps) => { + return ( +
+

{explanation}

+ +
+ ); +}; + +type RatingProps = { + rating: { current: number; maxAmount: number }; + handleSetRating: (value: number) => void; +}; + +export function Rating({ rating, handleSetRating }: RatingProps) { + return ( +
+ {[...new Array(rating.maxAmount)].map((_, index) => { + const grade = index + 1; + const active = grade <= rating.current; + const color = (v: number) => + v <= 2 ? 'bad' : v > 2 && v <= 4 ? 'good' : 'great'; + const modifier = color(grade); + const activeModifier = color(rating.current); + return ( +
handleSetRating(grade)}> + +
+ ); + })} +
+ ); +} + +export function EndCallSummary() { + const [rating, setRating] = useState({ current: 0, maxAmount: 5 }); + + const handleSetRating = useCallback((value: number) => { + setRating((currentRating) => ({ ...currentRating, current: value })); + }, []); + + return ( +
+
+ + + 36% + + + + + 5s + + + + + 145ms + + + + VP9 + VP8 + +
+ +
+ + + Network + + + Device + + + +
+ Video Calling + Live Stream + Audio Calling + Audio Rooms +
+
+ +

How Was your Call Experience?

+ +
+
+ +
+ + + + Amsterdam + + + Boston + + + + + Frankfurt + + + San Francisco + + + +
+
+ ); +} diff --git a/sample-apps/react/react-dogfood/components/EndCallSummary/Badge.tsx b/sample-apps/react/react-dogfood/components/EndCallSummary/Badge.tsx new file mode 100644 index 0000000000..4f17a383ba --- /dev/null +++ b/sample-apps/react/react-dogfood/components/EndCallSummary/Badge.tsx @@ -0,0 +1,72 @@ +import Link from 'next/link'; +import clsx from 'clsx'; + +export enum Status { + GOOD = 'good', + AVERAGE = 'average', + BAD = 'bad', +} + +type BadgeProps = { + children: React.ReactNode; + status?: Status; + variant?: 'small' | 'large'; + fit?: 'fill' | 'contain'; + link?: string; + className?: string; + hasLink?: boolean; +}; + +export function LinkBadge({ children, link, className, ...rest }: BadgeProps) { + if (!link) { + return ( + + {children} + + ); + } + + return ( + + + {children} + + + ); +} + +export function Badge({ + children, + status, + variant, + className, + fit = 'contain', + hasLink = false, +}: BadgeProps) { + return ( +
+ {status && ( + + )} + {children} +
+ ); +} diff --git a/sample-apps/react/react-dogfood/components/EndCallSummary/Card.tsx b/sample-apps/react/react-dogfood/components/EndCallSummary/Card.tsx new file mode 100644 index 0000000000..fd673753d1 --- /dev/null +++ b/sample-apps/react/react-dogfood/components/EndCallSummary/Card.tsx @@ -0,0 +1,63 @@ +import Link from 'next/link'; +import clsx from 'clsx'; +import { WithTooltip, Icon } from '@stream-io/video-react-sdk'; + +type CardProps = { + title?: string; + tooltip?: string; + link?: string; + children: React.ReactNode; + variant?: 'parent' | 'child'; + contentVariant?: 'row' | 'column'; + style?: React.CSSProperties; + className?: string; +}; + +export function Card({ + tooltip, + link, + title, + children, + variant = 'child', + contentVariant = 'column', + style, + className, +}: CardProps) { + return ( +
+ {title || tooltip || link ? ( +
+ {title &&

{title}

} + {tooltip && ( + + + + )} + {link && ( + + + + )} +
+ ) : null} +
+ {children} +
+
+ ); +} diff --git a/sample-apps/react/react-dogfood/components/EndCallSummary/EndCallSummaryView.tsx b/sample-apps/react/react-dogfood/components/EndCallSummary/EndCallSummaryView.tsx new file mode 100644 index 0000000000..5dc9c0f4b4 --- /dev/null +++ b/sample-apps/react/react-dogfood/components/EndCallSummary/EndCallSummaryView.tsx @@ -0,0 +1,478 @@ +import { Icon } from '@stream-io/video-react-sdk'; +import { useState, useCallback, useEffect, useMemo } from 'react'; +import { Feedback } from './Feedback'; +import clsx from 'clsx'; +import Link from 'next/link'; + +import { Card } from './Card'; +import { Badge, LinkBadge, Status } from './Badge'; +import { NetworkStatus } from './NetworkStatus'; +import { Rating } from './Rating'; +import { Header } from './Header'; +import { Recordings } from './Recordings'; +import { + useStreamVideoClient, + useCallStateHooks, + EdgeResponse, + useCall, +} from '@stream-io/video-react-sdk'; + +import { edges as edgeMap } from './edges'; + +import { Notification } from './Notification'; + +export interface EndCallSummaryViewProps { + rejoin: () => void; + startNewCall: () => void; + joinTime?: Date; +} + +const toStatus = (config: { + value: number; + lowBound: number; + highBound: number; +}): Status => { + const { value, lowBound, highBound } = config; + if (value <= lowBound) return Status.GOOD; + if (value >= lowBound && value <= highBound) return Status.AVERAGE; + if (value >= highBound) return Status.BAD; + return Status.GOOD; +}; + +const toCityName = (edgeId: string) => { + if (!edgeId) return 'Unknown'; + + let cityName = edgeId; + Object.keys(edgeMap).map((key) => { + if (key.includes(edgeId)) { + cityName = edgeMap[key as keyof typeof edgeMap]; + } + }); + return cityName; +}; + +const toDataCenterName = (datacenter: string) => { + const datacenterName = datacenter + .replace('.stream-io-video.com', '') + .split('.')[1]; + + let cityName = datacenterName; + Object.keys(edgeMap).map((key) => { + if (key.includes(datacenterName)) { + cityName = edgeMap[key as keyof typeof edgeMap]; + } + }); + return cityName; +}; + +const toNetworkStatus = (quality: number) => { + if (quality >= 80) return 'green'; + if (quality >= 40) return 'yellow'; + + return 'red'; +}; + +const toNetworkNotification = ( + quality: number, +): { + message: string; + type: 'success' | 'info' | 'error' | 'caution'; +} => { + if (quality >= 80) + return { + message: 'Your Network is Stable.', + type: 'success', + }; + if (quality >= 40) + return { + message: 'Your Network is Average.', + type: 'caution', + }; + + return { + message: 'Your Network is Poor.', + type: 'error', + }; +}; + +export function EndCallSummaryView({ + rejoin, + startNewCall, + joinTime, +}: EndCallSummaryViewProps) { + const [rating, setRating] = useState<{ + current: number; + maxAmount: number; + success: boolean; + }>({ current: 0, maxAmount: 5, success: false }); + const [callStats, setCallStats] = useState(undefined); + const [showRecordings, setShowRecordings] = useState(false); + const [edges, setEdges] = useState(undefined); + + const { useCallStatsReport, useCallStartedAt } = useCallStateHooks(); + const callStatsReport = useCallStatsReport(); + const startedAt = useCallStartedAt(); + const call = useCall(); + + const client = useStreamVideoClient(); + const { publisherStats } = callStatsReport || {}; + + const handleSetRating = useCallback( + (value: { current: number; maxAmount: number }) => { + setRating((currentRating) => ({ + ...currentRating, + current: value.current, + })); + }, + [], + ); + + useEffect(() => { + if (!client) return; + client.edges().then((response) => { + setEdges(response.edges); + }); + }, [client]); + + useEffect(() => { + if (!client || !call) return; + + async function fetchCallStats() { + const res = await client?.queryCallStats({ + filter_conditions: { + call_cid: call?.cid, + }, + limit: 1, + }); + return res; + } + + const response = fetchCallStats(); + response.then((res) => { + setCallStats(res?.reports[0]); + }); + }, [client, call]); + + const timeToConnect = useMemo(() => { + if (!joinTime || !startedAt) return null; + const timeDifference = startedAt.getTime() - joinTime.getTime(); + const differenceDate = new Date(timeDifference); + return differenceDate.getMilliseconds(); + }, [joinTime, startedAt]); + + const handleSubmitSuccess = useCallback(() => { + setRating({ current: 0, maxAmount: 5, success: true }); + }, []); + + const networkNotification = toNetworkNotification( + callStats?.quality_score || 0, + ); + + return ( +
+
setShowRecordings(true)} + /> +
+ + + {callStats?.quality_score || 0}% + + + + + {timeToConnect}ms + + + + + {publisherStats?.averageRoundTripTimeInMs || 0}ms + + + + + {publisherStats?.codec?.replace('video/', '') || 'Unknown'} + + +
+ +
+ + + + + Network + + + + + + + {toDataCenterName(callStatsReport?.datacenter || '')} + + + + +
+ {edges?.map((edge) => ( + + + + + {toCityName(edge.id)} + + + + ))} +
+
+
+ +
+ +
+ + + Video Calling + + + + Live Stream + + + + Audio Calling + + + + Audio Rooms + +
+
+ +
+

+ {rating.success + ? 'Thank you for your feedback!' + : 'How Was your Call Experience?'} +

+ {rating.success ? null : ( + + )} +
+
+
+ +
+ +
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+
+ +
+
+

+ Build and Ship With Confidence +

+

+ Join industry leaders who trust Stream’s SDKs to power + connections for billions of users. +

+
+
+
+ + Contact Us + +
+
+
+
+ {rating.current !== 0 && rating.current < 3 && ( +
+
+ setRating({ current: 0, maxAmount: 5, success: false }) + } + /> + + setRating({ current: 0, maxAmount: 5, success: false }) + } + /> +
+ )} + {showRecordings && ( +
+
setShowRecordings(false)} + /> +
+ setShowRecordings(false)} /> +
+
+ )} +
+ ); +} diff --git a/sample-apps/react/react-dogfood/components/EndCallSummary/Feedback.tsx b/sample-apps/react/react-dogfood/components/EndCallSummary/Feedback.tsx new file mode 100644 index 0000000000..a79b5161f4 --- /dev/null +++ b/sample-apps/react/react-dogfood/components/EndCallSummary/Feedback.tsx @@ -0,0 +1,213 @@ +import { HTMLInputTypeAttribute, useMemo, useState } from 'react'; +import clsx from 'clsx'; +import { useField, useForm } from 'react-form'; +import { useCall, Icon } from '@stream-io/video-react-sdk'; +import { getCookie } from '../../helpers/getCookie'; +import { FeedbackType } from './FeedbackType'; + +export type Props = { + className?: string; + callId?: string; + rating?: number; + submitSuccess: () => void; + callData?: any; + onClose: () => void; +}; + +function required(value: string | number, name: string) { + if (!value) { + return `Please enter a ${name}`; + } + return false; +} + +const Input = (props: { + className?: string; + type: HTMLInputTypeAttribute; + placeholder: string; + name: string; + required?: boolean; +}) => { + const { name, className, ...rest } = props; + const { + meta: { error, isTouched }, + getInputProps, + } = useField(name, { + validate: props.required ? (value) => required(value, name) : undefined, + }); + + return ( + + ); +}; + +const TextArea = (props: { + className?: string; + placeholder: string; + name: string; + required?: boolean; +}) => { + const { name, className, ...rest } = props; + const { + meta: { error, isTouched }, + getInputProps, + } = useField(name, { + validate: props.required ? (value) => required(value, name) : undefined, + }); + + return ( +