feat: Add support for participant name, metadata, and attributes (#142)

This commit is contained in:
Mahmoud Hemaid 2025-05-29 02:05:22 +03:00 committed by GitHub
parent 575da78aa1
commit 9e2b7fcc61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 204 additions and 11 deletions

View File

@ -0,0 +1,93 @@
import React from 'react';
import { ConnectionState } from 'livekit-client';
import { AttributeItem } from '@/lib/types';
interface AttributesEditorProps {
attributes: AttributeItem[];
onAttributesChange: (attributes: AttributeItem[]) => void;
themeColor: string;
disabled?: boolean;
connectionState?: ConnectionState;
}
export const AttributesEditor: React.FC<AttributesEditorProps> = ({
attributes,
onAttributesChange,
themeColor,
disabled = false,
connectionState
}) => {
const handleKeyChange = (id: string, newKey: string) => {
const updatedAttributes = attributes.map(attr =>
attr.id === id ? { ...attr, key: newKey } : attr
);
onAttributesChange(updatedAttributes);
};
const handleValueChange = (id: string, newValue: string) => {
const updatedAttributes = attributes.map(attr =>
attr.id === id ? { ...attr, value: newValue } : attr
);
onAttributesChange(updatedAttributes);
};
const handleRemoveAttribute = (id: string) => {
const updatedAttributes = attributes.filter(attr => attr.id !== id);
onAttributesChange(updatedAttributes);
};
const handleAddAttribute = () => {
const newId = `attr_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const updatedAttributes = [...attributes, { id: newId, key: "", value: "" }];
onAttributesChange(updatedAttributes);
};
return (
<div className="border border-gray-800 rounded-sm p-3 bg-gray-900/30">
{attributes.map((attribute) => (
<div key={attribute.id} className="flex items-center gap-2 mb-2">
<input
value={attribute.key}
onChange={(e) => handleKeyChange(attribute.id, e.target.value)}
className="flex-1 min-w-0 text-white text-sm bg-transparent border border-gray-800 rounded-sm px-3 py-1"
placeholder="Key"
disabled={disabled}
/>
<input
value={attribute.value}
onChange={(e) => handleValueChange(attribute.id, e.target.value)}
className="flex-1 min-w-0 text-white text-sm bg-transparent border border-gray-800 rounded-sm px-3 py-1"
placeholder="Value"
disabled={disabled}
/>
<button
onClick={() => handleRemoveAttribute(attribute.id)}
className="flex-shrink-0 w-6 h-6 flex items-center justify-center text-gray-400 hover:text-white"
disabled={disabled}
style={{ display: disabled ? 'none' : 'flex' }}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
<button
onClick={handleAddAttribute}
className={`text-xs py-1 px-2 rounded-sm flex items-center gap-1 ${
disabled
? "bg-gray-700 text-gray-500 cursor-not-allowed"
: `bg-${themeColor}-500 hover:bg-${themeColor}-600 text-white`
}`}
disabled={disabled}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add Attribute
</button>
</div>
);
};

View File

@ -30,6 +30,7 @@ import { QRCodeSVG } from "qrcode.react";
import { ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import tailwindTheme from "../../lib/tailwindTheme.preval"; import tailwindTheme from "../../lib/tailwindTheme.preval";
import { EditableNameValueRow } from "@/components/config/NameValueRow"; import { EditableNameValueRow } from "@/components/config/NameValueRow";
import { AttributesEditor } from "@/components/config/AttributesEditor";
export interface PlaygroundMeta { export interface PlaygroundMeta {
name: string; name: string;
@ -260,9 +261,23 @@ export default function Playground({
editable={roomState !== ConnectionState.Connected} editable={roomState !== ConnectionState.Connected}
/> />
<EditableNameValueRow <EditableNameValueRow
name="Participant" name="Participant ID"
value={roomState === ConnectionState.Connected ? value={roomState === ConnectionState.Connected ?
(localParticipant?.identity || '') : (localParticipant?.identity || '') :
(config.settings.participant_id || '')}
valueColor={`${config.settings.theme_color}-500`}
onValueChange={(value) => {
const newSettings = { ...config.settings };
newSettings.participant_id = value;
setUserSettings(newSettings);
}}
placeholder="Enter participant id"
editable={roomState !== ConnectionState.Connected}
/>
<EditableNameValueRow
name="Participant Name"
value={roomState === ConnectionState.Connected ?
(localParticipant?.name || '') :
(config.settings.participant_name || '')} (config.settings.participant_name || '')}
valueColor={`${config.settings.theme_color}-500`} valueColor={`${config.settings.theme_color}-500`}
onValueChange={(value) => { onValueChange={(value) => {
@ -270,10 +285,39 @@ export default function Playground({
newSettings.participant_name = value; newSettings.participant_name = value;
setUserSettings(newSettings); setUserSettings(newSettings);
}} }}
placeholder="Enter participant id" placeholder="Enter participant name"
editable={roomState !== ConnectionState.Connected} editable={roomState !== ConnectionState.Connected}
/> />
</div> </div>
<div className="flex flex-col gap-2 mt-4">
<div className="text-xs text-gray-500 mt-2">Metadata</div>
<textarea
value={config.settings.metadata || ""}
onChange={(e) => {
const newSettings = { ...config.settings };
newSettings.metadata = e.target.value;
setUserSettings(newSettings);
}}
className="w-full text-white text-sm bg-transparent border border-gray-800 rounded-sm px-3 py-2"
placeholder="Custom metadata..."
rows={2}
disabled={roomState === ConnectionState.Connected}
/>
<div className="text-xs text-gray-500 mt-2">Attributes</div>
<AttributesEditor
attributes={config.settings.attributes || []}
onAttributesChange={(newAttributes) => {
const newSettings = { ...config.settings };
newSettings.attributes = newAttributes;
setUserSettings(newSettings);
}}
themeColor={config.settings.theme_color}
disabled={roomState === ConnectionState.Connected}
/>
</div>
<div className="flex flex-col gap-2 mt-4"> <div className="flex flex-col gap-2 mt-4">
<div className="text-xs text-gray-500 mt-2">RPC Method</div> <div className="text-xs text-gray-500 mt-2">RPC Method</div>
<input <input

View File

@ -1,5 +1,6 @@
"use client"; "use client";
import { AttributeItem } from "@/lib/types";
import { getCookie, setCookie } from "cookies-next"; import { getCookie, setCookie } from "cookies-next";
import jsYaml from "js-yaml"; import jsYaml from "js-yaml";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@ -36,7 +37,10 @@ export type UserSettings = {
ws_url: string; ws_url: string;
token: string; token: string;
room_name: string; room_name: string;
participant_id: string;
participant_name: string; participant_name: string;
metadata?: string;
attributes?: AttributeItem[];
}; };
// Fallback if NEXT_PUBLIC_APP_CONFIG is not set // Fallback if NEXT_PUBLIC_APP_CONFIG is not set
@ -60,7 +64,10 @@ const defaultConfig: AppConfig = {
ws_url: "", ws_url: "",
token: "", token: "",
room_name: "", room_name: "",
participant_id: "",
participant_name: "", participant_name: "",
metadata: "",
attributes: [],
}, },
show_qr: false, show_qr: false,
}; };
@ -130,6 +137,7 @@ export const ConfigProvider = ({ children }: { children: React.ReactNode }) => {
ws_url: "", ws_url: "",
token: "", token: "",
room_name: "", room_name: "",
participant_id: "",
participant_name: "", participant_name: "",
} as UserSettings; } as UserSettings;
}, [appConfig]); }, [appConfig]);

View File

@ -54,14 +54,36 @@ export const ConnectionProvider = ({
throw new Error("NEXT_PUBLIC_LIVEKIT_URL is not set"); throw new Error("NEXT_PUBLIC_LIVEKIT_URL is not set");
} }
url = process.env.NEXT_PUBLIC_LIVEKIT_URL; url = process.env.NEXT_PUBLIC_LIVEKIT_URL;
const params = new URLSearchParams(); const body: Record<string, any> = {};
if (config.settings.room_name) { if (config.settings.room_name) {
params.append('roomName', config.settings.room_name); body.roomName = config.settings.room_name;
}
if (config.settings.participant_id) {
body.participantId = config.settings.participant_id;
} }
if (config.settings.participant_name) { if (config.settings.participant_name) {
params.append('participantName', config.settings.participant_name); body.participantName = config.settings.participant_name;
} }
const { accessToken } = await fetch(`/api/token?${params}`).then((res) => if (config.settings.metadata) {
body.metadata = config.settings.metadata;
}
const attributesArray = Array.isArray(config.settings.attributes) ? config.settings.attributes : [];
if (attributesArray?.length) {
const attributes = attributesArray.reduce((acc, attr) => {
if (attr.key){
acc[attr.key] = attr.value;
}
return acc;
}, {} as Record<string, string>);
body.attributes = attributes;
}
const { accessToken } = await fetch(`/api/token`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
}).then((res) =>
res.json() res.json()
); );
token = accessToken; token = accessToken;
@ -76,7 +98,9 @@ export const ConnectionProvider = ({
config.settings.token, config.settings.token,
config.settings.ws_url, config.settings.ws_url,
config.settings.room_name, config.settings.room_name,
config.settings.participant_name, config.settings.participant_id,
config.settings.metadata,
config.settings.attributes,
generateToken, generateToken,
setToastMessage, setToastMessage,
] ]

View File

@ -13,4 +13,10 @@ export interface SessionProps {
export interface TokenResult { export interface TokenResult {
identity: string; identity: string;
accessToken: string; accessToken: string;
}
export interface AttributeItem {
id: string;
key: string;
value: string;
} }

View File

@ -19,20 +19,38 @@ export default async function handleToken(
res: NextApiResponse res: NextApiResponse
) { ) {
try { try {
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
res.status(405).end("Method Not Allowed");
return;
}
if (!apiKey || !apiSecret) { if (!apiKey || !apiSecret) {
res.statusMessage = "Environment variables aren't set up correctly"; res.statusMessage = "Environment variables aren't set up correctly";
res.status(500).end(); res.status(500).end();
return; return;
} }
const {
roomName: roomNameFromBody,
participantName,
participantId: participantIdFromBody,
metadata: metadataFromBody,
attributes: attributesFromBody,
} = req.body;
// Get room name from query params or generate random one // Get room name from query params or generate random one
const roomName = req.query.roomName as string || const roomName = roomNameFromBody as string ||
`room-${generateRandomAlphanumeric(4)}-${generateRandomAlphanumeric(4)}`; `room-${generateRandomAlphanumeric(4)}-${generateRandomAlphanumeric(4)}`;
// Get participant name from query params or generate random one // Get participant name from query params or generate random one
const identity = req.query.participantName as string || const identity = participantIdFromBody as string ||
`identity-${generateRandomAlphanumeric(4)}`; `identity-${generateRandomAlphanumeric(4)}`;
// Get metadata and attributes from query params
const metadata = metadataFromBody as string | undefined;
const attributesStr = attributesFromBody as string | undefined;
const attributes = attributesStr || {};
const grant: VideoGrant = { const grant: VideoGrant = {
room: roomName, room: roomName,
roomJoin: true, roomJoin: true,
@ -41,7 +59,7 @@ export default async function handleToken(
canSubscribe: true, canSubscribe: true,
}; };
const token = await createToken({ identity }, grant); const token = await createToken({ identity, metadata, attributes, name: participantName }, grant);
const result: TokenResult = { const result: TokenResult = {
identity, identity,
accessToken: token, accessToken: token,