feat: add Vite configuration with custom plugins and server settings
This commit is contained in:
@@ -41,6 +41,36 @@ const MEMBERSHIP_REFRESH_INTERVAL_MS = 45 * 60 * 1000;
|
||||
/** Call membership expiry time (1 hour) */
|
||||
const MEMBERSHIP_EXPIRY_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Check WebRTC capabilities and log diagnostic information
|
||||
*/
|
||||
function checkWebRTCSupport(): { supported: boolean; details: Record<string, unknown> } {
|
||||
const details: Record<string, unknown> = {
|
||||
userAgent: navigator.userAgent,
|
||||
platform: navigator.platform,
|
||||
};
|
||||
|
||||
// Check for key WebRTC APIs
|
||||
details.hasGetUserMedia = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
|
||||
details.hasRTCPeerConnection = !!(window.RTCPeerConnection);
|
||||
details.hasRTCDataChannel = !!(window.RTCPeerConnection && window.RTCPeerConnection.prototype.createDataChannel);
|
||||
|
||||
// Check WebKit-specific APIs
|
||||
details.hasWebkitGetUserMedia = !!(navigator.getUserMedia || (navigator as any).webkitGetUserMedia);
|
||||
details.hasWebkitRTCPeerConnection = !!((window as any).webkitRTCPeerConnection);
|
||||
|
||||
// Check if mediaDevices API exists
|
||||
details.hasMediaDevices = !!navigator.mediaDevices;
|
||||
details.hasEnumerateDevices = !!(navigator.mediaDevices && navigator.mediaDevices.enumerateDevices);
|
||||
|
||||
const supported = !!(
|
||||
(navigator.mediaDevices && navigator.mediaDevices.getUserMedia) &&
|
||||
(window.RTCPeerConnection || (window as any).webkitRTCPeerConnection)
|
||||
);
|
||||
|
||||
return { supported, details };
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for managing Matrix RTC calls via LiveKit
|
||||
* Handles LiveKit connections, media streams, call state, and Matrix room signaling
|
||||
@@ -283,6 +313,15 @@ export class CallService {
|
||||
console.log('Got LiveKit JWT, connecting to:', this.livekitJwt.url);
|
||||
console.log('LiveKit JWT response:', JSON.stringify(this.livekitJwt));
|
||||
|
||||
// Check WebRTC support before attempting connection
|
||||
const webrtcCheck = checkWebRTCSupport();
|
||||
console.log('WebRTC support check:', webrtcCheck);
|
||||
|
||||
if (!webrtcCheck.supported) {
|
||||
console.warn('WebRTC may not be fully supported. Attempting connection anyway...');
|
||||
console.warn('WebRTC details:', JSON.stringify(webrtcCheck.details, null, 2));
|
||||
}
|
||||
|
||||
// Step 3: Create and connect to LiveKit room
|
||||
this.livekitRoom = new Room({
|
||||
adaptiveStream: true,
|
||||
|
||||
156
src/app/features/common-settings/general/LivekitConfig.tsx
Normal file
156
src/app/features/common-settings/general/LivekitConfig.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React, { FormEventHandler, useCallback, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
color,
|
||||
Input,
|
||||
Spinner,
|
||||
Text,
|
||||
} from 'folds';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../../room-settings/styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { usePowerLevels } from '../../../hooks/usePowerLevels';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
|
||||
interface LivekitConfigContent {
|
||||
service_url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Room-specific LiveKit configuration component.
|
||||
* Allows room admins to override the LiveKit service URL for calls in this room.
|
||||
*/
|
||||
export function LivekitConfig() {
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const creators = useRoomCreators(room);
|
||||
const permissions = useRoomPermissions(creators, powerLevels);
|
||||
|
||||
const stateEvent = useStateEvent(room, StateEvent.RoomLivekitConfig);
|
||||
const currentConfig = stateEvent?.getContent<LivekitConfigContent>();
|
||||
const currentUrl = currentConfig?.service_url || '';
|
||||
|
||||
const [urlValue, setUrlValue] = useState(currentUrl);
|
||||
const [urlError, setUrlError] = useState<string>();
|
||||
|
||||
const [saveState, saveConfig] = useAsyncCallback(
|
||||
useCallback(
|
||||
async (serviceUrl: string) => {
|
||||
const content: LivekitConfigContent = serviceUrl
|
||||
? { service_url: serviceUrl }
|
||||
: {};
|
||||
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.RoomLivekitConfig as any, content, '');
|
||||
},
|
||||
[mx, room]
|
||||
)
|
||||
);
|
||||
|
||||
const canEdit = permissions.stateEvent(StateEvent.RoomLivekitConfig, mx.getUserId()!);
|
||||
|
||||
const handleSave: FormEventHandler<HTMLFormElement> = async (evt) => {
|
||||
evt.preventDefault();
|
||||
const serviceUrl = urlValue.trim();
|
||||
|
||||
// Basic URL validation
|
||||
if (serviceUrl && !serviceUrl.startsWith('http://') && !serviceUrl.startsWith('https://')) {
|
||||
setUrlError('URL must start with http:// or https://');
|
||||
return;
|
||||
}
|
||||
|
||||
setUrlError(undefined);
|
||||
await saveConfig(serviceUrl);
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
setUrlValue('');
|
||||
setUrlError(undefined);
|
||||
await saveConfig('');
|
||||
};
|
||||
|
||||
const hasChanges = urlValue !== currentUrl;
|
||||
const isSaving = saveState.status === AsyncStatus.Loading;
|
||||
|
||||
return (
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="LiveKit Service URL"
|
||||
description="Override the LiveKit JWT service endpoint for Matrix RTC calls in this room. Leave empty to use the server default or user preference."
|
||||
/>
|
||||
<Box as="form" onSubmit={handleSave} direction="Column" gap="200" style={{ padding: '0 16px' }}>
|
||||
<Input
|
||||
value={urlValue}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setUrlValue(e.target.value);
|
||||
setUrlError(undefined);
|
||||
}}
|
||||
placeholder="https://example.com/livekit/jwt"
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
disabled={!canEdit || isSaving}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
{urlError && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{urlError}
|
||||
</Text>
|
||||
)}
|
||||
{saveState.status === AsyncStatus.Error && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
Failed to save: {saveState.error instanceof Error ? saveState.error.message : 'Unknown error'}
|
||||
</Text>
|
||||
)}
|
||||
{saveState.status === AsyncStatus.Success && !hasChanges && (
|
||||
<Text size="T200" style={{ color: color.Success.Main }}>
|
||||
Configuration saved
|
||||
</Text>
|
||||
)}
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Button
|
||||
type="submit"
|
||||
size="300"
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
disabled={!canEdit || isSaving || !hasChanges}
|
||||
before={isSaving && <Spinner size="100" variant="Primary" fill="Solid" />}
|
||||
>
|
||||
<Text size="B300">Save</Text>
|
||||
</Button>
|
||||
{currentUrl && (
|
||||
<Button
|
||||
type="button"
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
radii="300"
|
||||
disabled={!canEdit || isSaving}
|
||||
onClick={handleClear}
|
||||
>
|
||||
<Text size="B300">Clear</Text>
|
||||
</Button>
|
||||
)}
|
||||
{!canEdit && (
|
||||
<Text size="T200" priority="300">
|
||||
You don't have permission to change this setting
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</SequenceCard>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './EmbedFilters';
|
||||
export * from './LivekitConfig';
|
||||
export * from './RoomAddress';
|
||||
export * from './RoomEncryption';
|
||||
export * from './RoomHistoryVisibility';
|
||||
|
||||
@@ -5,6 +5,7 @@ import { usePowerLevels } from '../../../hooks/usePowerLevels';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import {
|
||||
EmbedFilters,
|
||||
LivekitConfig,
|
||||
RoomProfile,
|
||||
RoomEncryption,
|
||||
RoomHistoryVisibility,
|
||||
@@ -58,6 +59,10 @@ export function General({ requestClose }: GeneralProps) {
|
||||
<Text size="L400">Link Previews</Text>
|
||||
<EmbedFilters />
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Voice & Video</Text>
|
||||
<LivekitConfig />
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Addresses</Text>
|
||||
<RoomPublishedAddresses permissions={permissions} />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { Box, Line } from 'folds';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { RoomView } from './RoomView';
|
||||
import { MembersDrawer } from './MembersDrawer';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
@@ -13,11 +14,13 @@ import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import { markAsRead } from '../../utils/notifications';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||
import { activeRoomIdAtom } from '../../state/activeRoom';
|
||||
|
||||
export function Room() {
|
||||
const { eventId } = useParams();
|
||||
const room = useRoom();
|
||||
const mx = useMatrixClient();
|
||||
const setActiveRoomId = useSetAtom(activeRoomIdAtom);
|
||||
|
||||
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
@@ -25,6 +28,12 @@ export function Room() {
|
||||
const powerLevels = usePowerLevels(room);
|
||||
const members = useRoomMembers(mx, room.roomId);
|
||||
|
||||
// Update titlebar with current room ID
|
||||
useEffect(() => {
|
||||
setActiveRoomId(room.roomId);
|
||||
return () => setActiveRoomId(undefined);
|
||||
}, [room.roomId, setActiveRoomId]);
|
||||
|
||||
useKeyDown(
|
||||
window,
|
||||
useCallback(
|
||||
|
||||
@@ -174,6 +174,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
selectedFiles.map((f) => f.file)
|
||||
);
|
||||
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
|
||||
|
||||
@@ -384,7 +385,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
resetEditorHistory(editor);
|
||||
setReplyDraft(undefined);
|
||||
sendTypingStatus(false);
|
||||
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
|
||||
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, editorRef]);
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||
(evt) => {
|
||||
@@ -544,6 +545,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
/>
|
||||
)}
|
||||
<CustomEditor
|
||||
ref={editorRef}
|
||||
editableName="RoomInput"
|
||||
editor={editor}
|
||||
placeholder="Send a message..."
|
||||
|
||||
@@ -118,6 +118,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||
import { setupCopyHandler } from '../../utils/copyHandler';
|
||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
@@ -906,6 +907,28 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
}
|
||||
}, [scrollToElement, editId]);
|
||||
|
||||
// Setup copy handler for better copy formatting (Name - Time: Message)
|
||||
useEffect(() => {
|
||||
const getMessageData = (eventId: string) => {
|
||||
const timelineSet = room.getUnfilteredTimelineSet();
|
||||
const evtTimeline = timelineSet.getTimelineForEvent(eventId);
|
||||
if (!evtTimeline) return null;
|
||||
const mEvent = evtTimeline.getEvents().find((e) => e.getId() === eventId);
|
||||
if (!mEvent) return null;
|
||||
const sender = getMemberDisplayName(room, mEvent.getSender() ?? '')
|
||||
?? getMxIdLocalPart(mEvent.getSender() ?? '')
|
||||
?? mEvent.getSender()
|
||||
?? 'Unknown';
|
||||
const content = mEvent.getContent().body ?? '';
|
||||
return {
|
||||
sender,
|
||||
ts: mEvent.getTs(),
|
||||
content,
|
||||
};
|
||||
};
|
||||
return setupCopyHandler(scrollRef, getMessageData, hour24Clock);
|
||||
}, [room, hour24Clock]);
|
||||
|
||||
const handleJumpToLatest = () => {
|
||||
if (eventId) {
|
||||
navigateRoom(room.roomId, undefined, { replace: true });
|
||||
|
||||
@@ -28,6 +28,7 @@ import React, {
|
||||
MouseEventHandler,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
@@ -353,6 +354,42 @@ export const MessageCopyLinkItem = as<
|
||||
);
|
||||
});
|
||||
|
||||
export const MessageCopyRawTextItem = as<
|
||||
'button',
|
||||
{
|
||||
mEvent: MatrixEvent;
|
||||
onClose?: () => void;
|
||||
}
|
||||
>(({ mEvent, onClose, ...props }, ref) => {
|
||||
const handleCopy = () => {
|
||||
const content = mEvent.getContent();
|
||||
const rawText = content.body ?? '';
|
||||
if (rawText) {
|
||||
copyToClipboard(rawText);
|
||||
}
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
// Only show for text messages
|
||||
const content = mEvent.getContent();
|
||||
if (!content.body || mEvent.isRedacted()) return null;
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.File} />}
|
||||
radii="300"
|
||||
onClick={handleCopy}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
|
||||
Copy Raw Text
|
||||
</Text>
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
export const MessagePinItem = as<
|
||||
'button',
|
||||
{
|
||||
@@ -742,6 +779,23 @@ export const Message = as<'div', MessageProps>(
|
||||
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [emojiBoardAnchor, setEmojiBoardAnchor] = useState<RectCords>();
|
||||
const [shiftHeld, setShiftHeld] = useState(false);
|
||||
|
||||
// Track shift key state
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') setShiftHeld(true);
|
||||
};
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Shift') setShiftHeld(false);
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const senderDisplayName =
|
||||
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
|
||||
@@ -986,6 +1040,36 @@ export const Message = as<'div', MessageProps>(
|
||||
<Icon src={Icons.Pencil} size="100" />
|
||||
</IconButton>
|
||||
)}
|
||||
{mEvent.getContent().body && !mEvent.isRedacted() && (
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
const rawText = mEvent.getContent().body ?? '';
|
||||
if (rawText) copyToClipboard(rawText);
|
||||
}}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
title="Copy Raw Text"
|
||||
>
|
||||
<Icon src={Icons.File} size="100" />
|
||||
</IconButton>
|
||||
)}
|
||||
{shiftHeld && canDelete && (
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
const eventId = mEvent.getId();
|
||||
if (eventId) {
|
||||
mx.redactEvent(room.roomId, eventId);
|
||||
}
|
||||
}}
|
||||
variant="Critical"
|
||||
size="300"
|
||||
radii="300"
|
||||
title="Delete Message (Shift held)"
|
||||
>
|
||||
<Icon src={Icons.Delete} size="100" />
|
||||
</IconButton>
|
||||
)}
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
position="Bottom"
|
||||
@@ -1112,6 +1196,7 @@ export const Message = as<'div', MessageProps>(
|
||||
/>
|
||||
)}
|
||||
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||
<MessageCopyRawTextItem mEvent={mEvent} onClose={closeMenu} />
|
||||
{canPinEvent && (
|
||||
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||
)}
|
||||
@@ -1281,6 +1366,7 @@ export const Event = as<'div', EventProps>(
|
||||
/>
|
||||
)}
|
||||
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||
<MessageCopyRawTextItem mEvent={mEvent} onClose={closeMenu} />
|
||||
</Box>
|
||||
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||
|
||||
(mEvent.getSender() !== mx.getUserId() && !stateEvent)) && (
|
||||
|
||||
@@ -15,6 +15,9 @@ export const MessageOptionsBase = style([
|
||||
top: toRem(-30),
|
||||
right: 0,
|
||||
zIndex: 1,
|
||||
// Extend hitbox to the left for easier hover targeting
|
||||
paddingLeft: toRem(100),
|
||||
marginLeft: toRem(-100),
|
||||
},
|
||||
]);
|
||||
export const MessageOptionsBar = style([
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Input,
|
||||
Menu,
|
||||
MenuItem,
|
||||
PopOut,
|
||||
@@ -450,6 +451,7 @@ export function Audio({ requestClose }: AudioProps) {
|
||||
const [settings, setSettings] = useState<AudioSettings>(loadAudioSettings);
|
||||
const [permissionStatus, setPermissionStatus] = useState<'granted' | 'denied' | 'prompt'>('prompt');
|
||||
const [showRemoteCursor, setShowRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor');
|
||||
const [livekitServiceUrl, setLivekitServiceUrl] = useSetting(settingsAtom, 'livekitServiceUrl');
|
||||
|
||||
useEffect(() => {
|
||||
const loadDevices = async () => {
|
||||
@@ -813,6 +815,33 @@ export function Audio({ requestClose }: AudioProps) {
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<Header size="400">
|
||||
<Box gap="200" grow="Yes">
|
||||
<Text size="H4">LiveKit Configuration</Text>
|
||||
</Box>
|
||||
</Header>
|
||||
<SettingTile
|
||||
title="LiveKit Service URL"
|
||||
description="Custom LiveKit JWT service endpoint for Matrix RTC calls. Leave empty to use the default configured server."
|
||||
after={
|
||||
<Input
|
||||
size="300"
|
||||
variant="Background"
|
||||
value={livekitServiceUrl ?? ''}
|
||||
onChange={(e) => setLivekitServiceUrl(e.target.value || undefined)}
|
||||
placeholder="https://example.com/livekit/jwt"
|
||||
style={{ minWidth: toRem(300) }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
|
||||
Reference in New Issue
Block a user