🐛(frontend) rework the calendar integration approach
Use a totally different strategy, which hopefully works in prod env. Actually broadcast channel cannot be shared between two different browsing context, even on a same domain, an iFrame and a pop up cannot communicate. Moreover, in an iFrame session cookie are unavailable. Rely on a newly created backend endpoint to pass room data.
This commit is contained in:
committed by
aleb_the_flash
parent
506b3978e1
commit
66f307b7e8
@@ -5,7 +5,7 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLang } from 'hoofd'
|
||||
import { Switch, Route, useLocation } from 'wouter'
|
||||
import { Switch, Route } from 'wouter'
|
||||
import { I18nProvider } from 'react-aria-components'
|
||||
import { Layout } from './layout/Layout'
|
||||
import { NotFoundScreen } from './components/NotFoundScreen'
|
||||
@@ -13,35 +13,11 @@ import { routes } from './routes'
|
||||
import './i18n/init'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { AppInitialization } from '@/components/AppInitialization'
|
||||
import { SdkCreateButton } from './features/sdk/routes/CreateButton'
|
||||
|
||||
const SDK_BASE_ROUTE = '/sdk'
|
||||
|
||||
function App() {
|
||||
const { i18n } = useTranslation()
|
||||
useLang(i18n.language)
|
||||
|
||||
const [location] = useLocation()
|
||||
const isSDKRoute = location.startsWith(SDK_BASE_ROUTE)
|
||||
|
||||
if (isSDKRoute) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Suspense fallback={null}>
|
||||
<I18nProvider locale={i18n.language}>
|
||||
<Switch>
|
||||
<Route path={SDK_BASE_ROUTE} nest>
|
||||
<Route path="/create-button">
|
||||
<SdkCreateButton />
|
||||
</Route>
|
||||
</Route>
|
||||
</Switch>
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppInitialization />
|
||||
|
||||
@@ -4,4 +4,5 @@ export const keys = {
|
||||
config: 'config',
|
||||
requestEntry: 'requestEntry',
|
||||
waitingParticipants: 'waitingParticipants',
|
||||
roomCreationCallback: 'roomCreationCallback',
|
||||
}
|
||||
|
||||
@@ -2,9 +2,13 @@ import { silenceLiveKitLogs } from '@/utils/livekit'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { useAnalytics } from '@/features/analytics/hooks/useAnalytics'
|
||||
import { useSupport } from '@/features/support/hooks/useSupport'
|
||||
import { useLocation } from 'wouter'
|
||||
|
||||
const SDK_BASE_ROUTE = '/sdk'
|
||||
|
||||
export const AppInitialization = () => {
|
||||
const { data } = useConfig()
|
||||
const [location] = useLocation()
|
||||
|
||||
const {
|
||||
analytics = {},
|
||||
@@ -12,8 +16,11 @@ export const AppInitialization = () => {
|
||||
silence_livekit_debug_logs = false,
|
||||
} = data || {}
|
||||
|
||||
useAnalytics(analytics)
|
||||
useSupport(support)
|
||||
const isSDKContext = location.includes(SDK_BASE_ROUTE)
|
||||
|
||||
useAnalytics({ ...analytics, isDisabled: isSDKContext })
|
||||
useSupport({ ...support, isDisabled: isSDKContext })
|
||||
|
||||
silenceLiveKitLogs(silence_livekit_debug_logs)
|
||||
|
||||
return null
|
||||
|
||||
@@ -17,18 +17,19 @@ export const terminateAnalyticsSession = () => {
|
||||
export type useAnalyticsProps = {
|
||||
id?: string
|
||||
host?: string
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
export const useAnalytics = ({ id, host }: useAnalyticsProps) => {
|
||||
export const useAnalytics = ({ id, host, isDisabled }: useAnalyticsProps) => {
|
||||
const [location] = useLocation()
|
||||
useEffect(() => {
|
||||
if (!id || !host) return
|
||||
if (!id || !host || isDisabled) return
|
||||
if (posthog.__loaded) return
|
||||
posthog.init(id, {
|
||||
api_host: host,
|
||||
person_profiles: 'always',
|
||||
})
|
||||
}, [id, host])
|
||||
}, [id, host, isDisabled])
|
||||
|
||||
// From PostHog tutorial on PageView tracking in a Single Page Application (SPA) context.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -13,12 +13,11 @@ import { RiAddLine, RiLink } from '@remixicon/react'
|
||||
import { LaterMeetingDialog } from '@/features/home/components/LaterMeetingDialog'
|
||||
import { IntroSlider } from '@/features/home/components/IntroSlider'
|
||||
import { MoreLink } from '@/features/home/components/MoreLink'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
import { ReactNode, useState } from 'react'
|
||||
|
||||
import { css } from '@/styled-system/css'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
|
||||
import { SdkReverseClient } from '@/features/sdk/SdkReverseClient'
|
||||
|
||||
const Columns = ({ children }: { children?: ReactNode }) => {
|
||||
return (
|
||||
@@ -156,18 +155,6 @@ export const Home = () => {
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
const [laterRoomId, setLaterRoomId] = useState<null | string>(null)
|
||||
|
||||
const { user } = useUser()
|
||||
|
||||
/**
|
||||
* Used for SDK popup to close automatically.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
return
|
||||
}
|
||||
SdkReverseClient.broadcastAuthentication()
|
||||
}, [user])
|
||||
|
||||
return (
|
||||
<UserAware>
|
||||
<Screen>
|
||||
|
||||
@@ -5,17 +5,20 @@ import { ApiRoom } from './ApiRoom'
|
||||
|
||||
export interface CreateRoomParams {
|
||||
slug: string
|
||||
callbackId?: string
|
||||
username?: string
|
||||
}
|
||||
|
||||
const createRoom = ({
|
||||
slug,
|
||||
callbackId,
|
||||
username = '',
|
||||
}: CreateRoomParams): Promise<ApiRoom> => {
|
||||
return fetchApi(`rooms/?username=${encodeURIComponent(username)}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: slug,
|
||||
callback_id: callbackId,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { authUrl, useUser } from '../auth'
|
||||
|
||||
export enum ClientMessageType {
|
||||
ROOM_CREATED = 'ROOM_CREATED',
|
||||
}
|
||||
|
||||
export class SdkReverseClient {
|
||||
/**
|
||||
* IDEA: Use API Key. Must be based on some sort of credentials? No needs for now as there are no security
|
||||
* plausible at the moment.
|
||||
*/
|
||||
static getAllowTargetOrigin() {
|
||||
return '*'
|
||||
}
|
||||
|
||||
static post(type: ClientMessageType, data: unknown = {}) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type,
|
||||
data,
|
||||
},
|
||||
SdkReverseClient.getAllowTargetOrigin()
|
||||
)
|
||||
}
|
||||
|
||||
static broadcastAuthentication() {
|
||||
const bc = new BroadcastChannel('APP_CHANNEL')
|
||||
bc.postMessage({ type: 'AUTHENTICATED' })
|
||||
|
||||
/**
|
||||
* This means the parent window has authenticated has successfully refetched user, then we can close the popup.
|
||||
*/
|
||||
bc.onmessage = (event) => {
|
||||
if (event.data.type === 'AUTHENTICATED_ACK') {
|
||||
window.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static waitForAuthenticationAck() {
|
||||
return new Promise<void>((resolve) => {
|
||||
const bc = new BroadcastChannel('APP_CHANNEL')
|
||||
bc.onmessage = async (event) => {
|
||||
if (event.data.type === 'AUTHENTICATED') {
|
||||
resolve()
|
||||
bc.postMessage({ type: 'AUTHENTICATED_ACK' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a function to be awaited in order to make sure the user is logged in.
|
||||
* If not logged-in it opens a popup with the connection flow, the promise returned is resolved
|
||||
* once logged-in.
|
||||
*
|
||||
* To be used in SDK scope.
|
||||
*/
|
||||
export function useEnsureAuth() {
|
||||
const { isLoggedIn, refetch } = useUser({
|
||||
fetchUserOptions: { attemptSilent: false },
|
||||
})
|
||||
|
||||
const startSSO = () => {
|
||||
return new Promise<void>((resolve) => {
|
||||
SdkReverseClient.waitForAuthenticationAck().then(async () => {
|
||||
await refetch()
|
||||
resolve()
|
||||
})
|
||||
const params = `scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no,
|
||||
width=400,height=900,left=100,top=100`
|
||||
window.open(new URL('authenticate/', authUrl()).href, '', params)
|
||||
})
|
||||
}
|
||||
|
||||
const ensureAuth = async () => {
|
||||
if (!isLoggedIn) {
|
||||
await startSSO()
|
||||
}
|
||||
}
|
||||
|
||||
return { ensureAuth }
|
||||
}
|
||||
35
src/frontend/src/features/sdk/api/useRoomCreationCallback.ts
Normal file
35
src/frontend/src/features/sdk/api/useRoomCreationCallback.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { CallbackCreationRoomData } from '../utils/types'
|
||||
|
||||
export type CallbackResponse = {
|
||||
status: string
|
||||
room: CallbackCreationRoomData
|
||||
}
|
||||
|
||||
export const fetchRoomGenerationState = async ({
|
||||
callbackId,
|
||||
}: {
|
||||
callbackId: string
|
||||
}) => {
|
||||
return fetchApi<CallbackResponse>(`/rooms/creation-callback/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
callback_id: callbackId,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export const useRoomCreationCallback = ({
|
||||
callbackId = '',
|
||||
}: {
|
||||
callbackId?: string
|
||||
}) => {
|
||||
return useQuery({
|
||||
queryKey: [keys.roomCreationCallback, callbackId],
|
||||
queryFn: () => fetchRoomGenerationState({ callbackId }),
|
||||
enabled: !!callbackId,
|
||||
refetchInterval: 1000,
|
||||
})
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { Button } from '@/primitives/Button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePersistentUserChoices } from '@livekit/components-react'
|
||||
import { useState } from 'react'
|
||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'
|
||||
import { VisioIcon } from '@/assets/VisioIcon'
|
||||
import { generateRoomId, useCreateRoom } from '../../rooms'
|
||||
import {
|
||||
ClientMessageType,
|
||||
SdkReverseClient,
|
||||
useEnsureAuth,
|
||||
} from '../SdkReverseClient'
|
||||
|
||||
export const SdkCreateButton = () => {
|
||||
const { t } = useTranslation('sdk', { keyPrefix: 'createButton' })
|
||||
const [roomUrl, setRoomUrl] = useState<string>()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const {
|
||||
userChoices: { username },
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
const { ensureAuth } = useEnsureAuth()
|
||||
|
||||
const submitCreateRoom = async () => {
|
||||
setIsLoading(true)
|
||||
const slug = generateRoomId()
|
||||
const data = await createRoom({ slug, username })
|
||||
const roomUrlTmp = getRouteUrl('room', data.slug)
|
||||
setRoomUrl(roomUrlTmp)
|
||||
setIsLoading(false)
|
||||
SdkReverseClient.post(ClientMessageType.ROOM_CREATED, {
|
||||
url: roomUrlTmp,
|
||||
})
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
await ensureAuth()
|
||||
submitCreateRoom()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
paddingTop: '3px',
|
||||
paddingLeft: '3px',
|
||||
})}
|
||||
>
|
||||
{roomUrl ? (
|
||||
<RoomUrl roomUrl={roomUrl} />
|
||||
) : (
|
||||
<Button
|
||||
variant="primaryDark"
|
||||
aria-label={t('label')}
|
||||
onPress={submit}
|
||||
data-attr="sdk-create"
|
||||
loading={isLoading}
|
||||
icon={<VisioIcon />}
|
||||
>
|
||||
{t('label')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RoomUrl = ({ roomUrl }: { roomUrl: string }) => {
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard.writeText(roomUrl!)
|
||||
setIsCopied(true)
|
||||
setTimeout(() => setIsCopied(false), 1000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
color: 'greyscale.600',
|
||||
})}
|
||||
>
|
||||
{roomUrl}
|
||||
</span>
|
||||
<Button
|
||||
variant={isCopied ? 'success' : 'quaternaryText'}
|
||||
data-attr="sdk-create-copy"
|
||||
onPress={copy}
|
||||
square
|
||||
>
|
||||
{isCopied ? <RiCheckLine /> : <RiFileCopyLine />}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
164
src/frontend/src/features/sdk/routes/CreateMeetingButton.tsx
Normal file
164
src/frontend/src/features/sdk/routes/CreateMeetingButton.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Button } from '@/primitives/Button'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HStack, VStack } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { RiCloseLine, RiFileCopyLine } from '@remixicon/react'
|
||||
import { Text } from '@/primitives'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { buttonRecipe } from '@/primitives/buttonRecipe'
|
||||
import { VisioIcon } from '@/assets/VisioIcon'
|
||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||
import { useRoomCreationCallback } from '../api/useRoomCreationCallback'
|
||||
import { PopupManager } from '../utils/PopupManager'
|
||||
import { CallbackCreationRoomData } from '../utils/types'
|
||||
import { useSearchParams } from 'wouter'
|
||||
|
||||
const popupManager = new PopupManager()
|
||||
|
||||
export const CreateMeetingButton = () => {
|
||||
const { t } = useTranslation('sdk', { keyPrefix: 'createMeeting' })
|
||||
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const [callbackId, setCallbackId] = useState<string | undefined>(undefined)
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
const initialRoom = useMemo(() => {
|
||||
const roomSlug = searchParams.get('slug')
|
||||
if (!roomSlug) return undefined
|
||||
return {
|
||||
slug: roomSlug.trim(), // Trim whitespace for safety
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const [room, setRoom] = useState<CallbackCreationRoomData | undefined>(
|
||||
initialRoom
|
||||
)
|
||||
|
||||
const { data } = useRoomCreationCallback({ callbackId })
|
||||
|
||||
const roomUrl = useMemo(() => {
|
||||
if (room?.slug) return getRouteUrl('room', room.slug)
|
||||
}, [room])
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.room?.slug) return
|
||||
setRoom(data.room)
|
||||
setCallbackId(undefined)
|
||||
setIsPending(false)
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
popupManager.setupMessageListener(
|
||||
(id) => setCallbackId(id),
|
||||
(data) => {
|
||||
setRoom(data)
|
||||
setIsPending(false)
|
||||
}
|
||||
)
|
||||
|
||||
return () => popupManager.cleanup()
|
||||
}, [])
|
||||
|
||||
const resetState = () => {
|
||||
setRoom(undefined)
|
||||
setCallbackId(undefined)
|
||||
setIsPending(false)
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div>
|
||||
<Spinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-6"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'start',
|
||||
alignItems: 'start',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
{roomUrl && room?.slug ? (
|
||||
<VStack justify={'start'} alignItems={'start'} gap={0.25}>
|
||||
<HStack>
|
||||
<Link
|
||||
className={buttonRecipe({ size: 'sm' })}
|
||||
href={roomUrl}
|
||||
target="_blank"
|
||||
style={{
|
||||
textWrap: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<VisioIcon />
|
||||
{t('joinButton')}
|
||||
</Link>
|
||||
<HStack gap={0}>
|
||||
<Button
|
||||
variant="quaternaryText"
|
||||
square
|
||||
icon={<RiFileCopyLine />}
|
||||
tooltip={t('copyLinkTooltip')}
|
||||
onPress={() => {
|
||||
navigator.clipboard.writeText(roomUrl)
|
||||
}}
|
||||
/>
|
||||
{searchParams.get('readOnly') === 'false' && (
|
||||
<Button
|
||||
variant="quaternaryText"
|
||||
square
|
||||
icon={<RiCloseLine />}
|
||||
onPress={resetState}
|
||||
aria-label={t('resetLabel')}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
<VStack justify={'start'} alignItems="start" gap={0.25}>
|
||||
<Text variant={'smNote'} margin={false} centered={false}>
|
||||
{roomUrl.replace('https://', '')}
|
||||
</Text>
|
||||
<Text variant={'smNote'} margin={false} centered={false}>
|
||||
{t('participantLimit')}
|
||||
</Text>
|
||||
</VStack>
|
||||
</VStack>
|
||||
) : (
|
||||
<div
|
||||
className={css({
|
||||
minHeight: '46px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
{/*
|
||||
* Using popup for Visio to access session cookies (blocked in iframes).
|
||||
* If authenticated: Popup creates room and returns data directly.
|
||||
* If not: Popup sends callbackId, redirects to login, then backend
|
||||
* associates new room with callbackId after authentication.
|
||||
*/}
|
||||
<Button
|
||||
onPress={() => {
|
||||
setIsPending(true)
|
||||
popupManager.createPopupWindow(() => {
|
||||
setIsPending(false)
|
||||
})
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
<VisioIcon />
|
||||
{t('createButton')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
src/frontend/src/features/sdk/routes/CreatePopup.tsx
Normal file
76
src/frontend/src/features/sdk/routes/CreatePopup.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { generateRoomId, useCreateRoom } from '../../rooms'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { CallbackIdHandler } from '../utils/CallbackIdHandler'
|
||||
import { PopupWindow } from '../utils/PopupWindow'
|
||||
|
||||
const callbackIdHandler = new CallbackIdHandler()
|
||||
const popupWindow = new PopupWindow()
|
||||
|
||||
export const CreatePopup = () => {
|
||||
const { isLoggedIn } = useUser({ fetchUserOptions: { attemptSilent: false } })
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
|
||||
const callbackId = useMemo(() => callbackIdHandler.getOrCreate(), [])
|
||||
|
||||
/**
|
||||
* Handle unauthenticated users by redirecting to login
|
||||
*
|
||||
* When redirecting to authentication, the window.location change breaks the connection
|
||||
* between this popup and its parent window. We need to send the callbackId to the parent
|
||||
* before redirecting so it can re-establish connection after authentication completes.
|
||||
* This prevents the popup from becoming orphaned and ensures state consistency.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isLoggedIn === false) {
|
||||
// redirection loses the connection to the manager
|
||||
// prevent it passing an async callback id
|
||||
popupWindow.sendCallbackId(callbackId, () => {
|
||||
popupWindow.navigateToAuthentication()
|
||||
})
|
||||
}
|
||||
}, [isLoggedIn, callbackId])
|
||||
|
||||
/**
|
||||
* Automatically create meeting room once user is authenticated
|
||||
* This effect will trigger either immediately if the user is already logged in,
|
||||
* or after successful authentication and return to this popup
|
||||
*/
|
||||
useEffect(() => {
|
||||
const createMeetingRoom = async () => {
|
||||
try {
|
||||
const slug = generateRoomId()
|
||||
const roomData = await createRoom({
|
||||
slug,
|
||||
callbackId,
|
||||
})
|
||||
// Send room data back to parent window and clean up resources
|
||||
popupWindow.sendRoomData(roomData, () => {
|
||||
callbackIdHandler.clear()
|
||||
popupWindow.close()
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to create meeting room:', error)
|
||||
}
|
||||
}
|
||||
if (isLoggedIn && callbackId) {
|
||||
createMeetingRoom()
|
||||
}
|
||||
}, [isLoggedIn, callbackId, createRoom])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
src/frontend/src/features/sdk/utils/CallbackIdHandler.ts
Normal file
45
src/frontend/src/features/sdk/utils/CallbackIdHandler.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export class CallbackIdHandler {
|
||||
private storageKey = 'popup_callback_id'
|
||||
|
||||
private generateId(): string {
|
||||
return (
|
||||
Math.random().toString(36).substring(2, 15) +
|
||||
Math.random().toString(36).substring(2, 15)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an existing callback ID or creates a new one
|
||||
*/
|
||||
public getOrCreate(): string {
|
||||
const existingId = this.get()
|
||||
if (existingId) {
|
||||
return existingId
|
||||
}
|
||||
|
||||
const newId = this.generateId()
|
||||
this.set(newId)
|
||||
return newId
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current callback ID if one exists
|
||||
*/
|
||||
public get(): string | null {
|
||||
return sessionStorage.getItem(this.storageKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a callback ID
|
||||
*/
|
||||
private set(id: string): void {
|
||||
sessionStorage.setItem(this.storageKey, id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the current callback ID
|
||||
*/
|
||||
public clear(): void {
|
||||
sessionStorage.removeItem(this.storageKey)
|
||||
}
|
||||
}
|
||||
63
src/frontend/src/features/sdk/utils/PopupManager.ts
Normal file
63
src/frontend/src/features/sdk/utils/PopupManager.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||
import {
|
||||
CallbackCreationRoomData,
|
||||
ClientMessageType,
|
||||
PopupMessageData,
|
||||
PopupMessageType,
|
||||
} from './types'
|
||||
|
||||
export class PopupManager {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private messageHandler: (event: MessageEvent<any>) => void = () => {}
|
||||
|
||||
public createPopupWindow(onFailure: () => void) {
|
||||
const popupWindow = window.open(
|
||||
`${window.location.origin}/sdk/create-popup`,
|
||||
'CreatePopupWindow',
|
||||
`status=no,location=no,toolbar=no,menubar=no,width=600,height=800,left=100,top=100, resizable=yes,scrollbars=yes`
|
||||
)
|
||||
|
||||
if (popupWindow) {
|
||||
popupWindow.focus()
|
||||
} else {
|
||||
onFailure()
|
||||
}
|
||||
}
|
||||
|
||||
public setupMessageListener(
|
||||
onCallbackId: (id: string) => void,
|
||||
onRoomData: (data: CallbackCreationRoomData) => void
|
||||
) {
|
||||
this.messageHandler = (event) => {
|
||||
const data = event.data as PopupMessageData
|
||||
// Skip messages from untrusted sources
|
||||
if (data.source !== window.location.origin) return
|
||||
switch (data.type) {
|
||||
case PopupMessageType.CALLBACK_ID:
|
||||
onCallbackId(data.callbackId as string)
|
||||
return
|
||||
case PopupMessageType.ROOM_DATA:
|
||||
if (!data?.room) return
|
||||
onRoomData(data.room)
|
||||
window?.parent.postMessage(
|
||||
{
|
||||
type: ClientMessageType.ROOM_CREATED,
|
||||
data: {
|
||||
room: {
|
||||
url: getRouteUrl('room', data.room.slug),
|
||||
...data.room,
|
||||
},
|
||||
},
|
||||
},
|
||||
'*'
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', this.messageHandler)
|
||||
}
|
||||
|
||||
public cleanup() {
|
||||
window.removeEventListener('message', this.messageHandler)
|
||||
}
|
||||
}
|
||||
50
src/frontend/src/features/sdk/utils/PopupWindow.ts
Normal file
50
src/frontend/src/features/sdk/utils/PopupWindow.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { authUrl } from '@/features/auth'
|
||||
import { PopupMessageType, CallbackCreationRoomData } from './types'
|
||||
|
||||
export class PopupWindow {
|
||||
private sendMessageToManager(
|
||||
type: PopupMessageType,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: any,
|
||||
callback?: () => void
|
||||
) {
|
||||
if (!window.opener) {
|
||||
console.error('No manager window found')
|
||||
window.close()
|
||||
return
|
||||
}
|
||||
window.opener.postMessage(
|
||||
{
|
||||
source: window.location.origin,
|
||||
type,
|
||||
...data,
|
||||
},
|
||||
window.location.origin
|
||||
)
|
||||
callback?.()
|
||||
}
|
||||
|
||||
public sendRoomData(data: CallbackCreationRoomData, callback?: () => void) {
|
||||
this.sendMessageToManager(
|
||||
PopupMessageType.ROOM_DATA,
|
||||
{ room: { slug: data.slug } },
|
||||
callback
|
||||
)
|
||||
}
|
||||
|
||||
public sendCallbackId(callbackId: string, callback?: () => void) {
|
||||
this.sendMessageToManager(
|
||||
PopupMessageType.CALLBACK_ID,
|
||||
{ callbackId },
|
||||
callback
|
||||
)
|
||||
}
|
||||
|
||||
public close() {
|
||||
window.close()
|
||||
}
|
||||
|
||||
public navigateToAuthentication() {
|
||||
window.location.href = authUrl({})
|
||||
}
|
||||
}
|
||||
19
src/frontend/src/features/sdk/utils/types.ts
Normal file
19
src/frontend/src/features/sdk/utils/types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export type CallbackCreationRoomData = {
|
||||
slug: string
|
||||
}
|
||||
|
||||
export enum ClientMessageType {
|
||||
ROOM_CREATED = 'ROOM_CREATED',
|
||||
}
|
||||
|
||||
export interface PopupMessageData {
|
||||
type: PopupMessageType
|
||||
source: string
|
||||
callbackId?: string
|
||||
room?: CallbackCreationRoomData
|
||||
}
|
||||
|
||||
export enum PopupMessageType {
|
||||
CALLBACK_ID,
|
||||
ROOM_DATA,
|
||||
}
|
||||
@@ -18,15 +18,16 @@ export const terminateSupportSession = () => {
|
||||
|
||||
export type useSupportProps = {
|
||||
id?: string
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
// Configure Crisp chat for real-time support across all pages.
|
||||
export const useSupport = ({ id }: useSupportProps) => {
|
||||
export const useSupport = ({ id, isDisabled }: useSupportProps) => {
|
||||
useEffect(() => {
|
||||
if (!id || Crisp.isCrispInjected()) return
|
||||
if (!id || Crisp.isCrispInjected() || isDisabled) return
|
||||
Crisp.configure(id)
|
||||
Crisp.setHideOnMobile(true)
|
||||
}, [id])
|
||||
}, [id, isDisabled])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"createButton": {
|
||||
"label": ""
|
||||
"createMeeting": {
|
||||
"createButton": "",
|
||||
"joinButton": "",
|
||||
"copyLinkTooltip": "",
|
||||
"resetLabel": "",
|
||||
"participantLimit": "",
|
||||
"popupBlocked": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"createButton": {
|
||||
"label": "Create a Visio link"
|
||||
"createMeeting": {
|
||||
"createButton": "Create",
|
||||
"joinButton": "Join with Visio",
|
||||
"copyLinkTooltip": "Copy link",
|
||||
"resetLabel": "Reset",
|
||||
"participantLimit": "Up to 150 participants.",
|
||||
"popupBlocked": "Popup was blocked. Please allow popups for this site."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"createButton": {
|
||||
"label": "Créer un lien Visio"
|
||||
"createMeeting": {
|
||||
"createButton": "Créer un lien",
|
||||
"joinButton": "Participer avec Visio",
|
||||
"copyLinkTooltip": "Copier le lien",
|
||||
"resetLabel": "Réinitialiser",
|
||||
"participantLimit": "Jusqu'à 150 participants.",
|
||||
"popupBlocked": "La fenêtre pop-up a été bloquée. Veuillez autoriser les pop-ups pour ce site."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"createButton": {
|
||||
"label": "Maak een Visio link"
|
||||
"createMeeting": {
|
||||
"createButton": "Aanmaken",
|
||||
"joinButton": "Deelnemen met Visio",
|
||||
"copyLinkTooltip": "Link kopiëren",
|
||||
"resetLabel": "Resetten",
|
||||
"participantLimit": "Tot 150 deelnemers.",
|
||||
"popupBlocked": "Pop-up werd geblokkeerd. Sta pop-ups toe voor deze site."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { HomeRoute } from '@/features/home'
|
||||
import { LegalTermsRoute } from '@/features/legalsTerms/LegalTermsRoute'
|
||||
import { AccessibilityRoute } from '@/features/legalsTerms/Accessibility'
|
||||
import { TermsOfServiceRoute } from '@/features/legalsTerms/TermsOfService'
|
||||
import { CreatePopup } from '@/features/sdk/routes/CreatePopup'
|
||||
import { CreateMeetingButton } from '@/features/sdk/routes/CreateMeetingButton'
|
||||
|
||||
export const routes: Record<
|
||||
| 'home'
|
||||
@@ -10,7 +12,9 @@ export const routes: Record<
|
||||
| 'feedback'
|
||||
| 'legalTerms'
|
||||
| 'accessibility'
|
||||
| 'termsOfService',
|
||||
| 'termsOfService'
|
||||
| 'sdkCreatePopup'
|
||||
| 'sdkCreateButton',
|
||||
{
|
||||
name: RouteName
|
||||
path: RegExp | string
|
||||
@@ -50,6 +54,16 @@ export const routes: Record<
|
||||
path: '/conditions-utilisation',
|
||||
Component: TermsOfServiceRoute,
|
||||
},
|
||||
sdkCreatePopup: {
|
||||
name: 'sdkCreatePopup',
|
||||
path: '/sdk/create-popup',
|
||||
Component: CreatePopup,
|
||||
},
|
||||
sdkCreateButton: {
|
||||
name: 'sdkCreateButton',
|
||||
path: '/sdk/create-button',
|
||||
Component: CreateMeetingButton,
|
||||
},
|
||||
}
|
||||
|
||||
export type RouteName = keyof typeof routes
|
||||
|
||||
@@ -21,7 +21,7 @@ function App() {
|
||||
</div>
|
||||
<div className="group">
|
||||
<label>Visioconference</label>
|
||||
<VisioCreateButton onRoomCreated={setRoomUrl} />
|
||||
<VisioCreateButton onRoomCreated={(data) => setRoomUrl(data.url)} />
|
||||
</div>
|
||||
<div className="group">
|
||||
<label>Description</label>
|
||||
|
||||
@@ -72,3 +72,56 @@ button {
|
||||
font-weight: 300;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.create-meeting {
|
||||
background-color: rgb(45, 45, 70);
|
||||
color: white;
|
||||
height: 46px;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 0.75rem;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.create-meeting svg path {
|
||||
fill: white !important;
|
||||
}
|
||||
|
||||
|
||||
.icon {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.join-button {
|
||||
background-color: rgb(45, 45, 70);
|
||||
color: white;
|
||||
height: 46px;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: fit-content;
|
||||
padding: 0 1rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.join-button svg path {
|
||||
fill: white !important;
|
||||
}
|
||||
|
||||
.join-link {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.join-link svg path {
|
||||
fill: white !important;
|
||||
}
|
||||
|
||||
1
src/sdk/library/.env.development
Normal file
1
src/sdk/library/.env.development
Normal file
@@ -0,0 +1 @@
|
||||
VITE_VISIO_SDK_URL=https://meet.127.0.0.1.nip.io/sdk
|
||||
@@ -5,3 +5,10 @@ export type ConfigType = typeof DEFAULT_CONFIG
|
||||
export enum ClientMessageType {
|
||||
ROOM_CREATED = 'ROOM_CREATED',
|
||||
}
|
||||
|
||||
export type RoomData = {
|
||||
slug: string
|
||||
url: string
|
||||
phone?: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { DEFAULT_CONFIG } from '@/Config'
|
||||
import { ClientMessageType } from '@/Types'
|
||||
import { useEffect } from 'react'
|
||||
import { ClientMessageType, RoomData } from '@/Types'
|
||||
import { DEFAULT_CONFIG } from '@/Config'
|
||||
|
||||
export const VisioCreateButton = ({
|
||||
onRoomCreated,
|
||||
readOnly = false,
|
||||
slug,
|
||||
}: {
|
||||
onRoomCreated: (roomUrl: string) => void
|
||||
onRoomCreated: (roomData: RoomData) => void
|
||||
readOnly?: boolean
|
||||
slug?: string
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
@@ -13,13 +17,11 @@ export const VisioCreateButton = ({
|
||||
if (event.origin !== new URL(DEFAULT_CONFIG.url).origin) {
|
||||
return
|
||||
}
|
||||
if (event.data.type === ClientMessageType.ROOM_CREATED) {
|
||||
const data = event.data.data
|
||||
const roomUrl = data.url
|
||||
onRoomCreated(roomUrl)
|
||||
const { type, data } = event.data
|
||||
if (type == ClientMessageType.ROOM_CREATED && data?.room) {
|
||||
onRoomCreated(data.room)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', onMessage)
|
||||
return () => {
|
||||
window.removeEventListener('message', onMessage)
|
||||
@@ -30,10 +32,13 @@ export const VisioCreateButton = ({
|
||||
// eslint-disable-next-line jsx-a11y/iframe-has-title
|
||||
<iframe
|
||||
allow="clipboard-read; clipboard-write"
|
||||
src={DEFAULT_CONFIG.url + '/create-button'}
|
||||
src={
|
||||
DEFAULT_CONFIG.url +
|
||||
`/create-button?readOnly=${readOnly}${slug ? '&slug=' + slug : ''}`
|
||||
}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '52px',
|
||||
height: '100px',
|
||||
border: 'none',
|
||||
}}
|
||||
></iframe>
|
||||
|
||||
Reference in New Issue
Block a user