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',
}