Skip to content

Commit

Permalink
[Feature]: Integrate LiveKit for meetings (#2818)
Browse files Browse the repository at this point in the history
* feat: livekit integration

* fix: remove value for livekit in .env

* fix: ensure consistent hook usage and optimize state handling

* fix: deep scan

* fix: Codacy Static Code Analysis

* feat: meet type for use

* Update .env

* Update useCollaborative.ts

---------

Co-authored-by: DevOps <[email protected]>
  • Loading branch information
Innocent-Akim and EverTechDevOps authored Aug 2, 2024
1 parent 6cd2d96 commit 1a72239
Show file tree
Hide file tree
Showing 16 changed files with 854 additions and 170 deletions.
2 changes: 2 additions & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,8 @@
"longpress",
"Lorem",
"lucide",
"livekit",
"livekitroom",
"mappagination",
"mathieudutour",
"Mazen",
Expand Down
7 changes: 7 additions & 0 deletions apps/web/.env
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ NEXT_PUBLIC_TWITTER_APP_NAME=ever-twitter
TWITTER_CLIENT_ID=
TWITTER_CLIENT_SECRET=

# MEET_TYPE: LiveKit | Jitsi
NEXT_PUBLIC_MEET_TYPE=Jitsi

#Livekit configuration
LIVEKIT_API_SECRET=
LIVEKIT_API_KEY=
NEXT_PUBLIC_LIVEKIT_URL=

# Invite Callback URL
INVITE_CALLBACK_URL=https://app.ever.team/auth/passcode
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ function MeetPage() {
}, [pathname]);

useEffect(() => {
if (!room && pathname?.startsWith('/meet') && !replaced.current) {
if (!room && pathname?.startsWith('/meet/jitsi') && !replaced.current) {
const url = new URL(window.location.href);
url.searchParams.set('room', btoa(randomMeetName()));

Expand Down
File renamed without changes.
61 changes: 61 additions & 0 deletions apps/web/app/[locale]/meet/livekit/component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"use client";

import { useAuthenticateUser } from '@app/hooks';
import { withAuthentication } from 'lib/app/authenticator';
import { BackdropLoader, Meta } from 'lib/components';
import dynamic from 'next/dynamic';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { useTokenLiveKit } from '@app/hooks/useLiveKit';

const LiveKit = dynamic(() => import('lib/features/integrations/livekit'), {
ssr: false,
loading: () => <BackdropLoader show />
});

function LiveKitPage() {
const router = useRouter();
const { user } = useAuthenticateUser();
const [roomName, setRoomName] = useState<string | undefined>(undefined);
const params = useSearchParams();

const onLeave = useCallback(() => {
router.push('/');
}, [router]);

useEffect(() => {
const room = params.get("roomName");
if (room) {
setRoomName(room);
}
}, [params]);

const { token } = useTokenLiveKit({
roomName: roomName || '',
username: user?.email || '',
});

return (
<>
<Meta title="Meet" />
{token && roomName && <LiveKit
token={token!}
roomName={roomName}
liveKitUrl={process.env.NEXT_PUBLIC_LIVEKIT_URL || ''}
onLeave={onLeave}
userChoices={{
videoEnabled: true,
audioEnabled: true,
audioDeviceId: '',
username: user?.email || '',
videoDeviceId: ''
}}
/>}
</>
);
}

export default withAuthentication(LiveKitPage, {
displayName: 'LiveKitPage',
showPageSkeleton: false
});
9 changes: 9 additions & 0 deletions apps/web/app/[locale]/meet/livekit/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import LiveKitPage from './component'
import React from 'react'

function Page() {
return <LiveKitPage />

}

export default Page
46 changes: 46 additions & 0 deletions apps/web/app/api/livekit/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { AccessToken } from "livekit-server-sdk";
import { NextRequest, NextResponse } from "next/server";

export async function GET(req: NextRequest) {
const room = req.nextUrl.searchParams.get("roomName");
const username = req.nextUrl.searchParams.get("username");

if (!room || typeof room !== 'string' || room.trim() === '') {
return NextResponse.json(
{ error: 'Missing or invalid "roomName" query parameter' },
{ status: 400 }
);
}

if (!username || typeof username !== 'string' || username.trim() === '') {
return NextResponse.json(
{ error: 'Missing or invalid "username" query parameter' },
{ status: 400 }
);
}

const apiKey = process.env.LIVEKIT_API_KEY;
const apiSecret = process.env.LIVEKIT_API_SECRET;
const wsUrl = process.env.NEXT_PUBLIC_LIVEKIT_URL;

if (!apiKey || !apiSecret || !wsUrl) {
console.error("Server misconfigured: missing environment variables.");
return NextResponse.json(
{ error: "Server misconfigured" },
{ status: 500 }
);
}

try {
const at = new AccessToken(apiKey, apiSecret, { identity: username });
at.addGrant({ room, roomJoin: true, canPublish: true, canSubscribe: true, roomRecord: true });
const token = await at.toJwt();
return NextResponse.json({ token: token });
} catch (error) {
console.error("Failed to generate token:", error);
return NextResponse.json(
{ error: "Failed to generate token" },
{ status: 500 }
);
}
}
14 changes: 11 additions & 3 deletions apps/web/app/hooks/useCollaborative.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import { useRouter } from 'next/navigation';
import { nanoid } from 'nanoid';
import capitalize from 'lodash/capitalize';



export function useCollaborative(user?: IUser) {
const meetType = process.env.NEXT_PUBLIC_MEET_TYPE || 'Jitsi';

const { activeTeam } = useOrganizationTeams();
const { user: authUser } = useAuthenticateUser();
const [collaborativeSelect, setCollaborativeSelect] = useRecoilState(collaborativeSelectState);
Expand Down Expand Up @@ -57,10 +61,14 @@ export function useCollaborative(user?: IUser) {
}, [authUser, randomMeetName, activeTeam, collaborativeMembers]);

const onMeetClick = useCallback(() => {
// LiveKit | Jitsi
const meetName = getMeetRoomName();

router.push(`/meet?room=${btoa(meetName)}`);
}, [getMeetRoomName, router]);
const encodedName = Buffer.from(meetName).toString('base64');
const path = meetType === 'Jitsi'
? `/meet/jitsi?room=${encodedName}`
: `/meet/livekit?roomName=${encodedName}`;
router.push(path);
}, [getMeetRoomName, router, meetType]);

const onBoardClick = useCallback(() => {
const members = collaborativeMembers.map((m) => m.id).join(',');
Expand Down
33 changes: 33 additions & 0 deletions apps/web/app/hooks/useLiveKit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"use client";
import { tokenLiveKitRoom } from "@app/services/server/livekitroom";
import { useEffect, useState } from "react";

interface ITokenLiveKitProps {
roomName: string;
username: string;
}

export function useTokenLiveKit({ roomName, username }: ITokenLiveKitProps) {

const [token, setToken] = useState<string | null>(() => {
if (typeof window !== 'undefined') {
return window.localStorage.getItem('token-live-kit');
}
return null;
});

useEffect(() => {
const fetchToken = async () => {
try {
const response = await tokenLiveKitRoom({ roomName, username });
window.localStorage.setItem('token-live-kit', response.token);
setToken(response.token);
} catch (error) {
console.error('Failed to fetch token:', error);
}
};
fetchToken();
}, [roomName, username, token]);

return { token };
}
42 changes: 42 additions & 0 deletions apps/web/app/interfaces/ILiveKiteCredentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { JwtPayload } from "jsonwebtoken";
import { LocalAudioTrack, LocalVideoTrack } from 'livekit-client';

export interface ILiveKiteCredentials {
ttl?:number|string
roomName?: string,
identity?: string,
username?: string,
metadata?: string
}

export interface CustomJwtPayload extends JwtPayload {
exp?: number;
iss?: string;
nbf?: number;
sub?: string;
video?: {
canPublish: boolean;
canPublishData: boolean;
canSubscribe: boolean;
room: string;
roomJoin: boolean;
};
}




export interface SessionProps {
roomName: string;
identity: string;
audioTrack?: LocalAudioTrack;
videoTrack?: LocalVideoTrack;
region?: string;
turnServer?: RTCIceServer;
forceRelay?: boolean;
}

export interface TokenResult {
identity: string;
accessToken: string;
}
1 change: 1 addition & 0 deletions apps/web/app/interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export * from './ITheme';
export * from './IRolePermissions';
export * from './ITimer';
export * from './IProject';
export * from './ILiveKiteCredentials'

export * from './integrations/IGithubRepositories';
export * from './integrations/IGithubMetadata';
Expand Down
10 changes: 10 additions & 0 deletions apps/web/app/services/server/livekitroom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ILiveKiteCredentials } from "@app/interfaces";

export async function tokenLiveKitRoom({ roomName, username }: ILiveKiteCredentials) {
try {
const response = await fetch(`/api/livekit?roomName=${roomName ?? 'default'}&username=${username ?? 'employee'}`);
return await response.json();
} catch (e) {
console.error(e)
}
}
12 changes: 6 additions & 6 deletions apps/web/components/shared/collaborate/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,11 @@ const Collaborate = () => {
{(member?.image?.thumbUrl ||
member?.image?.fullUrl ||
member?.imageUrl) &&
isValidUrl(
member?.image?.thumbUrl ||
isValidUrl(
member?.image?.thumbUrl ||
member?.image?.fullUrl ||
member?.imageUrl
) ? (
) ? (
<Avatar
size={36}
className="relative cursor-pointer dark:border-[0.25rem] dark:border-[#26272C]"
Expand Down Expand Up @@ -167,9 +167,9 @@ const Collaborate = () => {
}}
>
{(member?.image?.thumbUrl || member?.image?.fullUrl || member?.imageUrl) &&
isValidUrl(
member?.image?.thumbUrl || member?.image?.fullUrl || member?.imageUrl
) ? (
isValidUrl(
member?.image?.thumbUrl || member?.image?.fullUrl || member?.imageUrl
) ? (
<Avatar
size={32}
className="relative cursor-pointer dark:border-[0.25rem] dark:border-[#26272C]"
Expand Down
50 changes: 50 additions & 0 deletions apps/web/lib/features/integrations/livekit/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use client';
import React from 'react';
import {
LiveKitRoom,
VideoConference,
formatChatMessageLinks,
LocalUserChoices,
} from '@livekit/components-react';
import { RoomConnectOptions } from 'livekit-client';
import "@livekit/components-styles";

type ActiveRoomProps = {
userChoices: LocalUserChoices;
roomName?: string;
region?: string;
token?: string;
liveKitUrl?: string;
onLeave?: () => void;
};

export default function LiveKitPage({
userChoices,
onLeave,
token,
liveKitUrl,
}: ActiveRoomProps) {
const connectOptions = React.useMemo((): RoomConnectOptions => ({
autoSubscribe: true,
}), []);
const LiveKitRoomComponent = LiveKitRoom as React.ElementType;
return (
<LiveKitRoomComponent
className='!bg-dark--theme-light'
connectOptions={connectOptions}
audio={userChoices.audioEnabled}
video={userChoices.videoEnabled}
token={token}
serverUrl={liveKitUrl}
connect={true}
data-lk-theme="default"
style={{ height: '100dvh' }}
onDisconnected={onLeave}
>
<VideoConference
chatMessageFormatter={formatChatMessageLinks}
SettingsComponent={undefined} // or provide an actual component if needed
/>
</LiveKitRoomComponent>
);
}
4 changes: 4 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
"@heroicons/react": "^2.0.12",
"@jitsi/react-sdk": "^1.3.0",
"@jitsu/jitsu-react": "^1.3.0",
"@livekit/components-react": "^2.4.1",
"@livekit/components-styles": "^1.0.12",
"@nivo/calendar": "^0.87.0",
"@nivo/core": "^0.87.0",
"@opentelemetry/api": "^1.7.0",
Expand Down Expand Up @@ -78,6 +80,8 @@
"js-cookie": "^3.0.1",
"jsonwebtoken": "^9.0.2",
"jwt-decode": "^3.1.2",
"livekit-client": "^2.4.1",
"livekit-server-sdk": "^2.6.0",
"lodash": "^4.17.21",
"lucide-react": "^0.263.1",
"moment": "^2.29.4",
Expand Down
Loading

0 comments on commit 1a72239

Please sign in to comment.