feat: add Vite configuration with custom plugins and server settings

This commit is contained in:
2026-02-21 16:16:15 +11:00
parent 576e6d77c1
commit 6c741577ce
32 changed files with 1937 additions and 20 deletions

View File

@@ -4,10 +4,10 @@ import classNames from 'classnames';
import * as css from './layout.css';
export const MessageBase = as<'div', css.MessageBaseVariants>(
({ className, highlight, selected, collapse, autoCollapse, space, ...props }, ref) => (
({ className, highlight, selected, collapse, autoCollapse, space, newMessage, ...props }, ref) => (
<div
className={classNames(
css.MessageBase({ highlight, selected, collapse, autoCollapse, space }),
css.MessageBase({ highlight, selected, collapse, autoCollapse, space, newMessage }),
className
)}
{...props}

View File

@@ -58,6 +58,17 @@ const highlightAnime = keyframes({
backgroundColor: color.Primary.Container,
},
});
export const newMessageAnime = keyframes({
'0%': {
opacity: 0,
transform: 'translateY(8px)',
},
'100%': {
opacity: 1,
transform: 'translateY(0)',
},
});
const HighlightVariant = styleVariants({
true: {
animation: `${highlightAnime} 2000ms ease-in-out`,
@@ -71,6 +82,13 @@ const SelectedVariant = styleVariants({
},
});
const NewMessageVariant = styleVariants({
true: {
animation: `${newMessageAnime} 300ms ease-out`,
animationFillMode: 'backwards',
},
});
const AutoCollapse = style({
selectors: {
[`&+&`]: {
@@ -86,6 +104,12 @@ export const MessageBase = recipe({
marginTop: SpacingVar,
padding: `${config.space.S100} ${config.space.S200} ${config.space.S100} ${config.space.S400}`,
borderRadius: `0 ${config.radii.R400} ${config.radii.R400} 0`,
transition: 'background-color 150ms ease-out',
selectors: {
'&:hover': {
backgroundColor: color.Surface.ContainerHover,
},
},
},
],
variants: {
@@ -100,6 +124,7 @@ export const MessageBase = recipe({
},
highlight: HighlightVariant,
selected: SelectedVariant,
newMessage: NewMessageVariant,
},
defaultVariants: {
space: '400',
@@ -165,6 +190,9 @@ export const Username = style({
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
// Allow text selection while keeping button clickable
userSelect: 'text',
WebkitUserSelect: 'text',
selectors: {
'button&': {
cursor: 'pointer',

View File

@@ -6,6 +6,7 @@ import { joinRuleToIconSrc } from '../../utils/room';
import colorMXID from '../../../util/colorMXID';
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache';
type RoomAvatarProps = {
roomId: string;
@@ -15,13 +16,18 @@ type RoomAvatarProps = {
};
export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps) {
const [error, setError] = useState(false);
const [loaded, setLoaded] = useState(() => isAvatarCached(src));
const useAuthentication = useMediaAuthentication();
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
evt.currentTarget.setAttribute('data-image-loaded', 'true');
setLoaded(true);
cacheAvatar(src);
pruneAvatarCache();
};
// No src or error - show fallback only
if (!authenticatedSrc || error) {
return (
<AvatarFallback
@@ -33,15 +39,45 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
);
}
// If already cached, show image directly without fallback flash
if (loaded) {
return (
<AvatarImage
className={css.RoomAvatar}
src={authenticatedSrc}
alt={alt}
onError={() => setError(true)}
onLoad={handleLoad}
draggable={false}
data-image-loaded="true"
/>
);
}
// Loading state - render fallback with image overlay
return (
<AvatarImage
className={css.RoomAvatar}
src={authenticatedSrc}
alt={alt}
onError={() => setError(true)}
onLoad={handleLoad}
draggable={false}
/>
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<AvatarFallback
style={{
backgroundColor: colorMXID(roomId ?? ''),
color: color.Surface.Container,
position: 'absolute',
inset: 0,
}}
className={css.RoomAvatar}
>
{renderFallback()}
</AvatarFallback>
<AvatarImage
className={css.RoomAvatar}
style={{ position: 'relative', zIndex: 1 }}
src={authenticatedSrc}
alt={alt}
onError={() => setError(true)}
onLoad={handleLoad}
draggable={false}
/>
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { style, keyframes } from '@vanilla-extract/css';
import { color, config } from 'folds';
const wipeIn = keyframes({
'0%': {
clipPath: 'inset(0 100% 0 0)',
opacity: 0,
},
'100%': {
clipPath: 'inset(0 0 0 0)',
opacity: 1,
},
});
const wipeOut = keyframes({
'0%': {
clipPath: 'inset(0 0 0 0)',
opacity: 1,
},
'100%': {
clipPath: 'inset(0 100% 0 0)',
opacity: 0,
},
});
export const NewMessagePill = style({
display: 'flex',
alignItems: 'center',
gap: config.space.S100,
backgroundColor: '#2E7D32',
color: '#ffffff',
padding: `${config.space.S100} ${config.space.S200}`,
borderRadius: '999px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: 500,
whiteSpace: 'nowrap',
overflow: 'hidden',
maxWidth: '200px',
animation: `${wipeIn} 0.3s ease-out forwards`,
transition: 'background-color 0.15s ease',
WebkitAppRegion: 'no-drag',
selectors: {
'&:hover': {
backgroundColor: '#388E3C',
},
},
});
export const NewMessagePillExiting = style({
animation: `${wipeOut} 0.2s ease-in forwards`,
});
export const TitleBar = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
height: '32px',
minHeight: '32px',
backgroundColor: color.Surface.Container,
borderBottom: `1px solid ${color.Surface.ContainerLine}`,
position: 'relative',
zIndex: 1000,
userSelect: 'none',
WebkitUserSelect: 'none',
});
export const TitleBarDragRegion = style({
display: 'flex',
alignItems: 'center',
height: '100%',
flexGrow: 1,
paddingLeft: config.space.S200,
cursor: 'default',
WebkitAppRegion: 'drag',
color: color.Surface.OnContainer,
});

View File

@@ -0,0 +1,244 @@
import React, { ReactNode, useMemo, useEffect, useState, useCallback, useRef } from 'react';
import { Box, Text } from 'folds';
import { invoke } from '@tauri-apps/api/core';
import { MatrixClient, RoomEvent, ClientEvent, MatrixEvent, Room } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { useNavigate } from 'react-router-dom';
import { WindowControls } from './WindowControls';
import * as css from './TitleBar.css';
import { getUnreadInfos, getOrphanParents, guessPerfectParent } from '../../utils/room';
import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
import { activeRoomIdAtom } from '../../state/activeRoom';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { mDirectAtom } from '../../state/mDirectList';
import { getDirectRoomPath, getHomeRoomPath, getSpaceRoomPath } from '../../pages/pathUtils';
type NewMessageNotification = {
roomId: string;
eventId: string;
senderName: string;
preview: string;
};
export type TitleBarProps = {
mx?: MatrixClient;
children?: ReactNode;
};
export function TitleBar({ mx, children }: TitleBarProps) {
const [updateCounter, setUpdateCounter] = useState(0);
const roomId = useAtomValue(activeRoomIdAtom);
const [newMessage, setNewMessage] = useState<NewMessageNotification | null>(null);
const [isExiting, setIsExiting] = useState(false);
const dismissTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const navigate = useNavigate();
const roomToParents = useAtomValue(roomToParentsAtom);
const mDirects = useAtomValue(mDirectAtom);
// Clear dismiss timer on unmount
useEffect(() => {
return () => {
if (dismissTimerRef.current) {
clearTimeout(dismissTimerRef.current);
}
};
}, []);
// Auto-dismiss notification after 10 seconds
const scheduleDismiss = useCallback(() => {
if (dismissTimerRef.current) {
clearTimeout(dismissTimerRef.current);
}
dismissTimerRef.current = setTimeout(() => {
setIsExiting(true);
setTimeout(() => {
setNewMessage(null);
setIsExiting(false);
}, 200); // Wait for exit animation
}, 10000);
}, []);
// Navigate to the new message
const handleNotificationClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
if (!newMessage || !mx) return;
const targetRoomId = newMessage.roomId;
const eventId = newMessage.eventId;
// Dismiss notification
setIsExiting(true);
setTimeout(() => {
setNewMessage(null);
setIsExiting(false);
}, 200);
if (dismissTimerRef.current) {
clearTimeout(dismissTimerRef.current);
}
// Simple navigation approach - just try all paths
try {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, targetRoomId);
// Check if it's a direct message
if (mDirects.has(targetRoomId)) {
const path = getDirectRoomPath(roomIdOrAlias, eventId);
console.log('Navigating to direct room:', path);
navigate(path);
return;
}
// Check if it belongs to a space
const orphanParents = getOrphanParents(roomToParents, targetRoomId);
if (orphanParents.length > 0) {
const parentSpace = guessPerfectParent(mx, targetRoomId, orphanParents) ?? orphanParents[0];
const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace);
const path = getSpaceRoomPath(pSpaceIdOrAlias, roomIdOrAlias, eventId);
console.log('Navigating to space room:', path);
navigate(path);
return;
}
// Default to home room
const path = getHomeRoomPath(roomIdOrAlias, eventId);
console.log('Navigating to home room:', path);
navigate(path);
} catch (error) {
console.error('Error navigating to room:', error);
// Last resort - navigate without event ID
try {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, targetRoomId);
navigate(getHomeRoomPath(roomIdOrAlias));
} catch (fallbackError) {
console.error('Fallback navigation also failed:', fallbackError);
}
}
}, [newMessage, mx, roomToParents, mDirects, navigate]);
// Subscribe to room changes for unread updates and new messages
useEffect(() => {
if (!mx) return;
const update = () => setUpdateCounter(c => c + 1);
const handleTimelineEvent = (event: MatrixEvent, room: Room | undefined) => {
update();
// Check if this is a new message we should notify about
if (!room || !event) return;
// Ignore if it's the currently active room
if (room.roomId === roomId) return;
// Ignore if it's our own message
if (event.getSender() === mx.getUserId()) return;
// Only notify for actual messages
if (event.getType() !== 'm.room.message') return;
// Get content
const content = event.getContent();
if (!content.body) return;
// Get sender display name
const sender = room.getMember(event.getSender() || '');
const senderName = sender?.name || event.getSender() || 'Unknown';
// Create preview (truncate if needed)
const preview = content.body.length > 30
? content.body.substring(0, 30) + '...'
: content.body;
setNewMessage({
roomId: room.roomId,
eventId: event.getId() || '',
senderName,
preview,
});
setIsExiting(false);
scheduleDismiss();
};
mx.on(RoomEvent.Timeline, handleTimelineEvent);
mx.on(RoomEvent.Receipt, update);
mx.on(ClientEvent.Room, update);
return () => {
mx.off(RoomEvent.Timeline, handleTimelineEvent);
mx.off(RoomEvent.Receipt, update);
mx.off(ClientEvent.Room, update);
};
}, [mx, roomId, scheduleDismiss]);
// Get room name
const roomName = useMemo(() => {
if (!mx || !roomId) return null;
const room = mx.getRoom(roomId);
return room?.name || null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mx, roomId, updateCounter]);
// Get total unread count
const totalUnread = useMemo(() => {
if (!mx) return 0;
const unreadInfos = getUnreadInfos(mx);
return unreadInfos.reduce((sum, info) => sum + info.total, 0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mx, updateCounter]);
// Build title parts
const titleParts = useMemo(() => {
let suffix = '';
if (roomName) {
suffix += ` - ${roomName}`;
}
if (totalUnread > 0) {
suffix += ` (${totalUnread})`;
}
return { suffix };
}, [roomName, totalUnread]);
const handleDragStart = async () => {
try {
await invoke('window_start_drag');
} catch (error) {
console.error('Failed to start window drag:', error);
}
};
// Only render on desktop (when Tauri is available)
if (typeof (window as any).__TAURI__ === 'undefined') {
return null;
}
return (
<Box className={css.TitleBar} alignItems="Center" justifyContent="SpaceBetween">
<Box
className={css.TitleBarDragRegion}
alignItems="Center"
gap="200"
grow="Yes"
onMouseDown={handleDragStart}
>
<Text size="T200" truncate style={{ color: 'inherit' }}>
<b>Paarrot</b>{titleParts.suffix}
</Text>
{newMessage && (
<div
className={`${css.NewMessagePill} ${isExiting ? css.NewMessagePillExiting : ''}`}
onClick={handleNotificationClick}
onMouseDown={(e) => e.stopPropagation()}
title={`${newMessage.senderName}: ${newMessage.preview}`}
>
<span>{newMessage.senderName}: {newMessage.preview}</span>
</div>
)}
{children}
</Box>
<WindowControls />
</Box>
);
}

View File

@@ -0,0 +1,45 @@
import { style } from '@vanilla-extract/css';
import { color } from 'folds';
export const WindowControls = style({
display: 'flex',
alignItems: 'center',
height: '100%',
paddingRight: '8px',
gap: '8px',
flexShrink: 0,
});
export const WindowButton = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '46px',
height: '32px',
border: 'none',
background: 'transparent',
color: color.Surface.OnContainer,
cursor: 'pointer',
transition: 'background-color 0.1s ease',
outline: 'none',
':hover': {
backgroundColor: 'rgba(255, 255, 255, 0.1)',
},
':active': {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
},
});
export const WindowButtonClose = style([WindowButton, {
':hover': {
backgroundColor: '#e81123',
color: '#ffffff',
},
':active': {
backgroundColor: '#c50f1f',
color: '#ffffff',
},
}]);

View File

@@ -0,0 +1,105 @@
import React, { useState, useEffect } from 'react';
import { Box, IconButton, Icon } from 'folds';
import { invoke } from '@tauri-apps/api/core';
import * as css from './WindowControls.css';
export function WindowControls() {
const [isMaximized, setIsMaximized] = useState(false);
// Check initial maximized state
useEffect(() => {
const checkMaximized = async () => {
try {
const maximized = await invoke<boolean>('window_is_maximized');
setIsMaximized(maximized);
} catch (error) {
console.error('Failed to check window state:', error);
}
};
checkMaximized();
}, []);
const handleMinimize = async () => {
try {
await invoke('window_minimize');
} catch (error) {
console.error('Failed to minimize window:', error);
}
};
const handleMaximize = async () => {
try {
if (isMaximized) {
await invoke('window_unmaximize');
} else {
await invoke('window_maximize');
}
setIsMaximized(!isMaximized);
} catch (error) {
console.error('Failed to toggle maximize:', error);
}
};
const handleClose = async () => {
try {
await invoke('window_close');
} catch (error) {
console.error('Failed to close window:', error);
}
};
// Only render on desktop (when Tauri is available)
if (typeof (window as any).__TAURI__ === 'undefined') {
return null;
}
return (
<Box className={css.WindowControls} alignItems="Center" gap="100">
<button
className={css.WindowButton}
onClick={handleMinimize}
aria-label="Minimize"
type="button"
>
<svg width="12" height="12" viewBox="0 0 12 12">
<rect y="5" width="12" height="2" fill="currentColor" />
</svg>
</button>
<button
className={css.WindowButton}
onClick={handleMaximize}
aria-label={isMaximized ? 'Restore' : 'Maximize'}
type="button"
>
{isMaximized ? (
<svg width="12" height="12" viewBox="0 0 12 12">
<rect x="2" y="0" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="1.5" />
<rect x="0" y="2" width="10" height="10" fill="currentColor" />
<rect x="1.5" y="3.5" width="7" height="7" fill="var(--bg-surface)" />
</svg>
) : (
<svg width="12" height="12" viewBox="0 0 12 12">
<rect x="0" y="0" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.5" />
</svg>
)}
</button>
<button
className={css.WindowButtonClose}
onClick={handleClose}
aria-label="Close"
type="button"
>
<svg width="12" height="12" viewBox="0 0 12 12">
<path
d="M 1 1 L 11 11 M 11 1 L 1 11"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
</Box>
);
}

View File

@@ -0,0 +1,3 @@
export { TitleBar } from './TitleBar';
export { WindowControls } from './WindowControls';
export type { TitleBarProps } from './TitleBar';

View File

@@ -5,6 +5,7 @@ import * as css from './UserAvatar.css';
import colorMXID from '../../../util/colorMXID';
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache';
type UserAvatarProps = {
className?: string;
@@ -15,13 +16,18 @@ type UserAvatarProps = {
};
export function UserAvatar({ className, userId, src, alt, renderFallback }: UserAvatarProps) {
const [error, setError] = useState(false);
const [loaded, setLoaded] = useState(() => isAvatarCached(src));
const useAuthentication = useMediaAuthentication();
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
evt.currentTarget.setAttribute('data-image-loaded', 'true');
setLoaded(true);
cacheAvatar(src);
pruneAvatarCache();
};
// No src or error - show fallback only
if (!authenticatedSrc || error) {
return (
<AvatarFallback
@@ -33,14 +39,44 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
);
}
// If already cached, show image directly without fallback flash
if (loaded) {
return (
<AvatarImage
className={classNames(css.UserAvatar, className)}
src={authenticatedSrc}
alt={alt}
onError={() => setError(true)}
onLoad={handleLoad}
draggable={false}
data-image-loaded="true"
/>
);
}
// Loading state - render fallback with image overlay
return (
<AvatarImage
className={classNames(css.UserAvatar, className)}
src={authenticatedSrc}
alt={alt}
onError={() => setError(true)}
onLoad={handleLoad}
draggable={false}
/>
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<AvatarFallback
style={{
backgroundColor: colorMXID(userId),
color: color.Surface.Container,
position: 'absolute',
inset: 0,
}}
className={classNames(css.UserAvatar, className)}
>
{renderFallback()}
</AvatarFallback>
<AvatarImage
className={classNames(css.UserAvatar, className)}
style={{ position: 'relative', zIndex: 1 }}
src={authenticatedSrc}
alt={alt}
onError={() => setError(true)}
onLoad={handleLoad}
draggable={false}
/>
</div>
);
}

View File

@@ -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,

View 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>
);
}

View File

@@ -1,4 +1,5 @@
export * from './EmbedFilters';
export * from './LivekitConfig';
export * from './RoomAddress';
export * from './RoomEncryption';
export * from './RoomHistoryVisibility';

View File

@@ -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} />

View File

@@ -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(

View File

@@ -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..."

View File

@@ -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 });

View File

@@ -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)) && (

View File

@@ -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([

View File

@@ -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"

View File

@@ -37,6 +37,7 @@ import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession } from '../../state/sessions';
import { CallProviderWrapper } from '../../features/call/CallProviderWrapper';
import { isTauriMobile, startBackgroundSync, stopBackgroundSync } from '../../utils/tauri';
import { TitleBar } from '../../components/title-bar';
function ClientRootLoading() {
return (
@@ -272,6 +273,7 @@ export function ClientRoot({ children }: ClientRootProps) {
return (
<SpecVersions baseUrl={baseUrl!}>
<TitleBar mx={mx} />
{mx && <SyncStatus mx={mx} />}
{loading && <ClientRootOptions mx={mx} />}
{(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && (

View File

@@ -0,0 +1,4 @@
import { atom } from 'jotai';
// Atom to track the currently active room ID for the titlebar
export const activeRoomIdAtom = atom<string | undefined>(undefined);

View File

@@ -52,6 +52,8 @@ export interface Settings {
developerTools: boolean;
autoJoinSpaceRooms: boolean;
livekitServiceUrl?: string;
}
const defaultSettings: Settings = {
@@ -91,6 +93,8 @@ const defaultSettings: Settings = {
developerTools: false,
autoJoinSpaceRooms: true,
livekitServiceUrl: undefined,
};
export const getSettings = () => {

View File

@@ -0,0 +1,31 @@
// Simple in-memory cache for avatar URLs that have been successfully loaded
// This prevents the flicker when avatars re-render
const loadedAvatars = new Set<string>();
export function isAvatarCached(url: string | undefined): boolean {
if (!url) return false;
return loadedAvatars.has(url);
}
export function cacheAvatar(url: string | undefined): void {
if (url) {
loadedAvatars.add(url);
}
}
// Optional: limit cache size to prevent memory issues
const MAX_CACHE_SIZE = 1000;
export function pruneAvatarCache(): void {
if (loadedAvatars.size > MAX_CACHE_SIZE) {
const iterator = loadedAvatars.values();
// Remove oldest entries (first added)
for (let i = 0; i < loadedAvatars.size - MAX_CACHE_SIZE; i++) {
const result = iterator.next();
if (!result.done) {
loadedAvatars.delete(result.value);
}
}
}
}

View File

@@ -0,0 +1,102 @@
import { timeHourMinute } from './time';
/**
* Processes selected text from the chat timeline and formats it as:
* "Username - Time: Message"
*
* This intercepts the copy event on the timeline and reformats the copied text.
*/
export const setupCopyHandler = (
containerRef: React.RefObject<HTMLElement>,
getMessageData: (eventId: string) => { sender: string; ts: number; content: string } | null,
hour24Clock: boolean
): (() => void) => {
const container = containerRef.current;
if (!container) return () => {};
const handleCopy = (event: ClipboardEvent) => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed) return;
const range = selection.getRangeAt(0);
const selectedContainer = range.commonAncestorContainer;
// Only process if selection is within our container
if (!container.contains(selectedContainer)) return;
// Find all message elements within the selection
const messages: Array<{ sender: string; time: string; content: string }> = [];
// Get all message elements in the container
const messageElements = container.querySelectorAll('[data-message-id]');
messageElements.forEach((msgEl) => {
// Check if this message element intersects with the selection
if (!selection.containsNode(msgEl, true)) return;
const eventId = msgEl.getAttribute('data-message-id');
if (!eventId) return;
const data = getMessageData(eventId);
if (!data) return;
// Get the selected text content within this message
const tempRange = document.createRange();
tempRange.selectNodeContents(msgEl);
// Check if the selection overlaps with this message
const intersects = range.compareBoundaryPoints(Range.END_TO_START, tempRange) <= 0 &&
range.compareBoundaryPoints(Range.START_TO_END, tempRange) >= 0;
if (!intersects) return;
// Format the time
const timeStr = timeHourMinute(data.ts, hour24Clock);
// Get the text content that was selected from this message
// Use the original content if the message is fully selected, otherwise get selected portion
let selectedText = '';
// Find the message content element (the actual text, not header)
const contentElement = msgEl.querySelector('[class*="MessageTextBody"], [class*="message-content"]');
if (contentElement) {
const clonedRange = range.cloneRange();
clonedRange.selectNodeContents(contentElement);
// Intersect with selection
if (range.compareBoundaryPoints(Range.START_TO_START, clonedRange) > 0) {
clonedRange.setStart(range.startContainer, range.startOffset);
}
if (range.compareBoundaryPoints(Range.END_TO_END, clonedRange) < 0) {
clonedRange.setEnd(range.endContainer, range.endOffset);
}
selectedText = clonedRange.toString().trim();
} else {
// Fallback: use all selected text in this message
selectedText = range.toString().trim();
}
if (selectedText) {
messages.push({
sender: data.sender,
time: timeStr,
content: selectedText,
});
}
});
// If we found formatted messages, use our format
if (messages.length > 0) {
const formattedText = messages
.map((msg) => `${msg.sender} - ${msg.time}: ${msg.content}`)
.join('\n');
event.preventDefault();
event.clipboardData?.setData('text/plain', formattedText);
}
};
container.addEventListener('copy', handleCopy);
return () => container.removeEventListener('copy', handleCopy);
};

View File

@@ -35,6 +35,17 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
await mx.initRustCrypto();
mx.setMaxListeners(50);
// Increase max listeners for User objects to prevent warnings when many components
// subscribe to presence events (e.g., in member lists)
const originalGetUser = mx.getUser.bind(mx);
mx.getUser = (userId: string) => {
const user = originalGetUser(userId);
if (user) {
user.setMaxListeners(50);
}
return user;
};
return mx;
};

View File

@@ -69,6 +69,9 @@ html {
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-inset-left: env(safe-area-inset-left, 0px);
--safe-area-inset-right: env(safe-area-inset-right, 0px);
/* Window rounding for borderless window */
border-radius: 4px;
overflow: hidden;
}
body {
@@ -85,6 +88,9 @@ body {
font-weight: 400;
/* Default to dark theme background for safe areas */
background-color: #1A1A1A;
/* Window rounding */
border-radius: 4px;
overflow: hidden;
/*Why font-variant-ligatures => https://github.com/rsms/inter/issues/222 */
font-variant-ligatures: no-contextual;
@@ -108,6 +114,8 @@ body.butter-theme {
height: 100%;
display: flex;
flex-direction: column;
border-radius: 4px;
overflow: hidden;
}
*,

View File

@@ -42,6 +42,9 @@ export enum StateEvent {
/** Room-wide link embed filter patterns (admin-controlled) */
RoomEmbedFilters = 'im.paarrot.room.embed_filters',
/** Room-specific LiveKit service URL (admin-controlled) */
RoomLivekitConfig = 'im.paarrot.room.livekit_config',
/** Matrix RTC call membership (MSC3401) */
CallMember = 'org.matrix.msc3401.call.member',
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long