feat: add Vite configuration with custom plugins and server settings
This commit is contained in:
@@ -4,10 +4,10 @@ import classNames from 'classnames';
|
|||||||
import * as css from './layout.css';
|
import * as css from './layout.css';
|
||||||
|
|
||||||
export const MessageBase = as<'div', css.MessageBaseVariants>(
|
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
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
css.MessageBase({ highlight, selected, collapse, autoCollapse, space }),
|
css.MessageBase({ highlight, selected, collapse, autoCollapse, space, newMessage }),
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -58,6 +58,17 @@ const highlightAnime = keyframes({
|
|||||||
backgroundColor: color.Primary.Container,
|
backgroundColor: color.Primary.Container,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const newMessageAnime = keyframes({
|
||||||
|
'0%': {
|
||||||
|
opacity: 0,
|
||||||
|
transform: 'translateY(8px)',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
opacity: 1,
|
||||||
|
transform: 'translateY(0)',
|
||||||
|
},
|
||||||
|
});
|
||||||
const HighlightVariant = styleVariants({
|
const HighlightVariant = styleVariants({
|
||||||
true: {
|
true: {
|
||||||
animation: `${highlightAnime} 2000ms ease-in-out`,
|
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({
|
const AutoCollapse = style({
|
||||||
selectors: {
|
selectors: {
|
||||||
[`&+&`]: {
|
[`&+&`]: {
|
||||||
@@ -86,6 +104,12 @@ export const MessageBase = recipe({
|
|||||||
marginTop: SpacingVar,
|
marginTop: SpacingVar,
|
||||||
padding: `${config.space.S100} ${config.space.S200} ${config.space.S100} ${config.space.S400}`,
|
padding: `${config.space.S100} ${config.space.S200} ${config.space.S100} ${config.space.S400}`,
|
||||||
borderRadius: `0 ${config.radii.R400} ${config.radii.R400} 0`,
|
borderRadius: `0 ${config.radii.R400} ${config.radii.R400} 0`,
|
||||||
|
transition: 'background-color 150ms ease-out',
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: color.Surface.ContainerHover,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
variants: {
|
variants: {
|
||||||
@@ -100,6 +124,7 @@ export const MessageBase = recipe({
|
|||||||
},
|
},
|
||||||
highlight: HighlightVariant,
|
highlight: HighlightVariant,
|
||||||
selected: SelectedVariant,
|
selected: SelectedVariant,
|
||||||
|
newMessage: NewMessageVariant,
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
space: '400',
|
space: '400',
|
||||||
@@ -165,6 +190,9 @@ export const Username = style({
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
|
// Allow text selection while keeping button clickable
|
||||||
|
userSelect: 'text',
|
||||||
|
WebkitUserSelect: 'text',
|
||||||
selectors: {
|
selectors: {
|
||||||
'button&': {
|
'button&': {
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { joinRuleToIconSrc } from '../../utils/room';
|
|||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
|
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache';
|
||||||
|
|
||||||
type RoomAvatarProps = {
|
type RoomAvatarProps = {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
@@ -15,13 +16,18 @@ type RoomAvatarProps = {
|
|||||||
};
|
};
|
||||||
export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps) {
|
export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps) {
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
const [loaded, setLoaded] = useState(() => isAvatarCached(src));
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
|
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
|
||||||
|
|
||||||
const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
|
const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
|
||||||
evt.currentTarget.setAttribute('data-image-loaded', 'true');
|
evt.currentTarget.setAttribute('data-image-loaded', 'true');
|
||||||
|
setLoaded(true);
|
||||||
|
cacheAvatar(src);
|
||||||
|
pruneAvatarCache();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// No src or error - show fallback only
|
||||||
if (!authenticatedSrc || error) {
|
if (!authenticatedSrc || error) {
|
||||||
return (
|
return (
|
||||||
<AvatarFallback
|
<AvatarFallback
|
||||||
@@ -33,6 +39,8 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If already cached, show image directly without fallback flash
|
||||||
|
if (loaded) {
|
||||||
return (
|
return (
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
className={css.RoomAvatar}
|
className={css.RoomAvatar}
|
||||||
@@ -41,8 +49,36 @@ export function RoomAvatar({ roomId, src, alt, renderFallback }: RoomAvatarProps
|
|||||||
onError={() => setError(true)}
|
onError={() => setError(true)}
|
||||||
onLoad={handleLoad}
|
onLoad={handleLoad}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
|
data-image-loaded="true"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state - render fallback with image overlay
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RoomIcon = forwardRef<
|
export const RoomIcon = forwardRef<
|
||||||
|
|||||||
77
src/app/components/title-bar/TitleBar.css.ts
Normal file
77
src/app/components/title-bar/TitleBar.css.ts
Normal 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,
|
||||||
|
});
|
||||||
244
src/app/components/title-bar/TitleBar.tsx
Normal file
244
src/app/components/title-bar/TitleBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/app/components/title-bar/WindowControls.css.ts
Normal file
45
src/app/components/title-bar/WindowControls.css.ts
Normal 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',
|
||||||
|
},
|
||||||
|
}]);
|
||||||
105
src/app/components/title-bar/WindowControls.tsx
Normal file
105
src/app/components/title-bar/WindowControls.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/app/components/title-bar/index.ts
Normal file
3
src/app/components/title-bar/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { TitleBar } from './TitleBar';
|
||||||
|
export { WindowControls } from './WindowControls';
|
||||||
|
export type { TitleBarProps } from './TitleBar';
|
||||||
@@ -5,6 +5,7 @@ import * as css from './UserAvatar.css';
|
|||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
|
import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache';
|
||||||
|
|
||||||
type UserAvatarProps = {
|
type UserAvatarProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -15,13 +16,18 @@ type UserAvatarProps = {
|
|||||||
};
|
};
|
||||||
export function UserAvatar({ className, userId, src, alt, renderFallback }: UserAvatarProps) {
|
export function UserAvatar({ className, userId, src, alt, renderFallback }: UserAvatarProps) {
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
const [loaded, setLoaded] = useState(() => isAvatarCached(src));
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
|
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
|
||||||
|
|
||||||
const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
|
const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
|
||||||
evt.currentTarget.setAttribute('data-image-loaded', 'true');
|
evt.currentTarget.setAttribute('data-image-loaded', 'true');
|
||||||
|
setLoaded(true);
|
||||||
|
cacheAvatar(src);
|
||||||
|
pruneAvatarCache();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// No src or error - show fallback only
|
||||||
if (!authenticatedSrc || error) {
|
if (!authenticatedSrc || error) {
|
||||||
return (
|
return (
|
||||||
<AvatarFallback
|
<AvatarFallback
|
||||||
@@ -33,6 +39,8 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If already cached, show image directly without fallback flash
|
||||||
|
if (loaded) {
|
||||||
return (
|
return (
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
className={classNames(css.UserAvatar, className)}
|
className={classNames(css.UserAvatar, className)}
|
||||||
@@ -41,6 +49,34 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
|
|||||||
onError={() => setError(true)}
|
onError={() => setError(true)}
|
||||||
onLoad={handleLoad}
|
onLoad={handleLoad}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
|
data-image-loaded="true"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state - render fallback with image overlay
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,36 @@ const MEMBERSHIP_REFRESH_INTERVAL_MS = 45 * 60 * 1000;
|
|||||||
/** Call membership expiry time (1 hour) */
|
/** Call membership expiry time (1 hour) */
|
||||||
const MEMBERSHIP_EXPIRY_MS = 60 * 60 * 1000;
|
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
|
* Service for managing Matrix RTC calls via LiveKit
|
||||||
* Handles LiveKit connections, media streams, call state, and Matrix room signaling
|
* 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('Got LiveKit JWT, connecting to:', this.livekitJwt.url);
|
||||||
console.log('LiveKit JWT response:', JSON.stringify(this.livekitJwt));
|
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
|
// Step 3: Create and connect to LiveKit room
|
||||||
this.livekitRoom = new Room({
|
this.livekitRoom = new Room({
|
||||||
adaptiveStream: true,
|
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 './EmbedFilters';
|
||||||
|
export * from './LivekitConfig';
|
||||||
export * from './RoomAddress';
|
export * from './RoomAddress';
|
||||||
export * from './RoomEncryption';
|
export * from './RoomEncryption';
|
||||||
export * from './RoomHistoryVisibility';
|
export * from './RoomHistoryVisibility';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { usePowerLevels } from '../../../hooks/usePowerLevels';
|
|||||||
import { useRoom } from '../../../hooks/useRoom';
|
import { useRoom } from '../../../hooks/useRoom';
|
||||||
import {
|
import {
|
||||||
EmbedFilters,
|
EmbedFilters,
|
||||||
|
LivekitConfig,
|
||||||
RoomProfile,
|
RoomProfile,
|
||||||
RoomEncryption,
|
RoomEncryption,
|
||||||
RoomHistoryVisibility,
|
RoomHistoryVisibility,
|
||||||
@@ -58,6 +59,10 @@ export function General({ requestClose }: GeneralProps) {
|
|||||||
<Text size="L400">Link Previews</Text>
|
<Text size="L400">Link Previews</Text>
|
||||||
<EmbedFilters />
|
<EmbedFilters />
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Voice & Video</Text>
|
||||||
|
<LivekitConfig />
|
||||||
|
</Box>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Addresses</Text>
|
<Text size="L400">Addresses</Text>
|
||||||
<RoomPublishedAddresses permissions={permissions} />
|
<RoomPublishedAddresses permissions={permissions} />
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useCallback } from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import { Box, Line } from 'folds';
|
import { Box, Line } from 'folds';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
import { RoomView } from './RoomView';
|
import { RoomView } from './RoomView';
|
||||||
import { MembersDrawer } from './MembersDrawer';
|
import { MembersDrawer } from './MembersDrawer';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
@@ -13,11 +14,13 @@ import { useKeyDown } from '../../hooks/useKeyDown';
|
|||||||
import { markAsRead } from '../../utils/notifications';
|
import { markAsRead } from '../../utils/notifications';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
import { useRoomMembers } from '../../hooks/useRoomMembers';
|
||||||
|
import { activeRoomIdAtom } from '../../state/activeRoom';
|
||||||
|
|
||||||
export function Room() {
|
export function Room() {
|
||||||
const { eventId } = useParams();
|
const { eventId } = useParams();
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
const setActiveRoomId = useSetAtom(activeRoomIdAtom);
|
||||||
|
|
||||||
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
@@ -25,6 +28,12 @@ export function Room() {
|
|||||||
const powerLevels = usePowerLevels(room);
|
const powerLevels = usePowerLevels(room);
|
||||||
const members = useRoomMembers(mx, room.roomId);
|
const members = useRoomMembers(mx, room.roomId);
|
||||||
|
|
||||||
|
// Update titlebar with current room ID
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveRoomId(room.roomId);
|
||||||
|
return () => setActiveRoomId(undefined);
|
||||||
|
}, [room.roomId, setActiveRoomId]);
|
||||||
|
|
||||||
useKeyDown(
|
useKeyDown(
|
||||||
window,
|
window,
|
||||||
useCallback(
|
useCallback(
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
selectedFiles.map((f) => f.file)
|
selectedFiles.map((f) => f.file)
|
||||||
);
|
);
|
||||||
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();
|
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();
|
||||||
|
const editorRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
|
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
|
||||||
|
|
||||||
@@ -384,7 +385,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
setReplyDraft(undefined);
|
setReplyDraft(undefined);
|
||||||
sendTypingStatus(false);
|
sendTypingStatus(false);
|
||||||
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
|
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, editorRef]);
|
||||||
|
|
||||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||||
(evt) => {
|
(evt) => {
|
||||||
@@ -544,6 +545,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<CustomEditor
|
<CustomEditor
|
||||||
|
ref={editorRef}
|
||||||
editableName="RoomInput"
|
editableName="RoomInput"
|
||||||
editor={editor}
|
editor={editor}
|
||||||
placeholder="Send a message..."
|
placeholder="Send a message..."
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|||||||
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||||
|
import { setupCopyHandler } from '../../utils/copyHandler';
|
||||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
@@ -906,6 +907,28 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
}
|
}
|
||||||
}, [scrollToElement, editId]);
|
}, [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 = () => {
|
const handleJumpToLatest = () => {
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
navigateRoom(room.roomId, undefined, { replace: true });
|
navigateRoom(room.roomId, undefined, { replace: true });
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import React, {
|
|||||||
MouseEventHandler,
|
MouseEventHandler,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import FocusTrap from 'focus-trap-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<
|
export const MessagePinItem = as<
|
||||||
'button',
|
'button',
|
||||||
{
|
{
|
||||||
@@ -742,6 +779,23 @@ export const Message = as<'div', MessageProps>(
|
|||||||
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
|
const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover });
|
||||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||||
const [emojiBoardAnchor, setEmojiBoardAnchor] = 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 =
|
const senderDisplayName =
|
||||||
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
|
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
|
||||||
@@ -986,6 +1040,36 @@ export const Message = as<'div', MessageProps>(
|
|||||||
<Icon src={Icons.Pencil} size="100" />
|
<Icon src={Icons.Pencil} size="100" />
|
||||||
</IconButton>
|
</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
|
<PopOut
|
||||||
anchor={menuAnchor}
|
anchor={menuAnchor}
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
@@ -1112,6 +1196,7 @@ export const Message = as<'div', MessageProps>(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||||
|
<MessageCopyRawTextItem mEvent={mEvent} onClose={closeMenu} />
|
||||||
{canPinEvent && (
|
{canPinEvent && (
|
||||||
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||||
)}
|
)}
|
||||||
@@ -1281,6 +1366,7 @@ export const Event = as<'div', EventProps>(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||||
|
<MessageCopyRawTextItem mEvent={mEvent} onClose={closeMenu} />
|
||||||
</Box>
|
</Box>
|
||||||
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||
|
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||
|
||||||
(mEvent.getSender() !== mx.getUserId() && !stateEvent)) && (
|
(mEvent.getSender() !== mx.getUserId() && !stateEvent)) && (
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export const MessageOptionsBase = style([
|
|||||||
top: toRem(-30),
|
top: toRem(-30),
|
||||||
right: 0,
|
right: 0,
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
|
// Extend hitbox to the left for easier hover targeting
|
||||||
|
paddingLeft: toRem(100),
|
||||||
|
marginLeft: toRem(-100),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
export const MessageOptionsBar = style([
|
export const MessageOptionsBar = style([
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
Icons,
|
Icons,
|
||||||
|
Input,
|
||||||
Menu,
|
Menu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
PopOut,
|
PopOut,
|
||||||
@@ -450,6 +451,7 @@ export function Audio({ requestClose }: AudioProps) {
|
|||||||
const [settings, setSettings] = useState<AudioSettings>(loadAudioSettings);
|
const [settings, setSettings] = useState<AudioSettings>(loadAudioSettings);
|
||||||
const [permissionStatus, setPermissionStatus] = useState<'granted' | 'denied' | 'prompt'>('prompt');
|
const [permissionStatus, setPermissionStatus] = useState<'granted' | 'denied' | 'prompt'>('prompt');
|
||||||
const [showRemoteCursor, setShowRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor');
|
const [showRemoteCursor, setShowRemoteCursor] = useSetting(settingsAtom, 'showRemoteCursor');
|
||||||
|
const [livekitServiceUrl, setLivekitServiceUrl] = useSetting(settingsAtom, 'livekitServiceUrl');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadDevices = async () => {
|
const loadDevices = async () => {
|
||||||
@@ -813,6 +815,33 @@ export function Audio({ requestClose }: AudioProps) {
|
|||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</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
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
className={SequenceCardStyle}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
|
|||||||
import { getFallbackSession } from '../../state/sessions';
|
import { getFallbackSession } from '../../state/sessions';
|
||||||
import { CallProviderWrapper } from '../../features/call/CallProviderWrapper';
|
import { CallProviderWrapper } from '../../features/call/CallProviderWrapper';
|
||||||
import { isTauriMobile, startBackgroundSync, stopBackgroundSync } from '../../utils/tauri';
|
import { isTauriMobile, startBackgroundSync, stopBackgroundSync } from '../../utils/tauri';
|
||||||
|
import { TitleBar } from '../../components/title-bar';
|
||||||
|
|
||||||
function ClientRootLoading() {
|
function ClientRootLoading() {
|
||||||
return (
|
return (
|
||||||
@@ -272,6 +273,7 @@ export function ClientRoot({ children }: ClientRootProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SpecVersions baseUrl={baseUrl!}>
|
<SpecVersions baseUrl={baseUrl!}>
|
||||||
|
<TitleBar mx={mx} />
|
||||||
{mx && <SyncStatus mx={mx} />}
|
{mx && <SyncStatus mx={mx} />}
|
||||||
{loading && <ClientRootOptions mx={mx} />}
|
{loading && <ClientRootOptions mx={mx} />}
|
||||||
{(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && (
|
{(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && (
|
||||||
|
|||||||
4
src/app/state/activeRoom.ts
Normal file
4
src/app/state/activeRoom.ts
Normal 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);
|
||||||
@@ -52,6 +52,8 @@ export interface Settings {
|
|||||||
developerTools: boolean;
|
developerTools: boolean;
|
||||||
|
|
||||||
autoJoinSpaceRooms: boolean;
|
autoJoinSpaceRooms: boolean;
|
||||||
|
|
||||||
|
livekitServiceUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultSettings: Settings = {
|
const defaultSettings: Settings = {
|
||||||
@@ -91,6 +93,8 @@ const defaultSettings: Settings = {
|
|||||||
developerTools: false,
|
developerTools: false,
|
||||||
|
|
||||||
autoJoinSpaceRooms: true,
|
autoJoinSpaceRooms: true,
|
||||||
|
|
||||||
|
livekitServiceUrl: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSettings = () => {
|
export const getSettings = () => {
|
||||||
|
|||||||
31
src/app/utils/avatarCache.ts
Normal file
31
src/app/utils/avatarCache.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/app/utils/copyHandler.ts
Normal file
102
src/app/utils/copyHandler.ts
Normal 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);
|
||||||
|
};
|
||||||
@@ -36,6 +36,17 @@ export const initClient = async (session: Session): Promise<MatrixClient> => {
|
|||||||
|
|
||||||
mx.setMaxListeners(50);
|
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;
|
return mx;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ html {
|
|||||||
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
|
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
--safe-area-inset-left: env(safe-area-inset-left, 0px);
|
--safe-area-inset-left: env(safe-area-inset-left, 0px);
|
||||||
--safe-area-inset-right: env(safe-area-inset-right, 0px);
|
--safe-area-inset-right: env(safe-area-inset-right, 0px);
|
||||||
|
/* Window rounding for borderless window */
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -85,6 +88,9 @@ body {
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
/* Default to dark theme background for safe areas */
|
/* Default to dark theme background for safe areas */
|
||||||
background-color: #1A1A1A;
|
background-color: #1A1A1A;
|
||||||
|
/* Window rounding */
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
/*Why font-variant-ligatures => https://github.com/rsms/inter/issues/222 */
|
/*Why font-variant-ligatures => https://github.com/rsms/inter/issues/222 */
|
||||||
font-variant-ligatures: no-contextual;
|
font-variant-ligatures: no-contextual;
|
||||||
@@ -108,6 +114,8 @@ body.butter-theme {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ export enum StateEvent {
|
|||||||
/** Room-wide link embed filter patterns (admin-controlled) */
|
/** Room-wide link embed filter patterns (admin-controlled) */
|
||||||
RoomEmbedFilters = 'im.paarrot.room.embed_filters',
|
RoomEmbedFilters = 'im.paarrot.room.embed_filters',
|
||||||
|
|
||||||
|
/** Room-specific LiveKit service URL (admin-controlled) */
|
||||||
|
RoomLivekitConfig = 'im.paarrot.room.livekit_config',
|
||||||
|
|
||||||
/** Matrix RTC call membership (MSC3401) */
|
/** Matrix RTC call membership (MSC3401) */
|
||||||
CallMember = 'org.matrix.msc3401.call.member',
|
CallMember = 'org.matrix.msc3401.call.member',
|
||||||
}
|
}
|
||||||
|
|||||||
165
vite.config.js.timestamp-1771641468415-046b313db8c4b.mjs
Normal file
165
vite.config.js.timestamp-1771641468415-046b313db8c4b.mjs
Normal file
File diff suppressed because one or more lines are too long
165
vite.config.js.timestamp-1771641863062-566dfacb5223d.mjs
Normal file
165
vite.config.js.timestamp-1771641863062-566dfacb5223d.mjs
Normal file
File diff suppressed because one or more lines are too long
165
vite.config.js.timestamp-1771642098574-00e5d9a55dc42.mjs
Normal file
165
vite.config.js.timestamp-1771642098574-00e5d9a55dc42.mjs
Normal file
File diff suppressed because one or more lines are too long
165
vite.config.js.timestamp-1771642338922-3a8734296d3bf.mjs
Normal file
165
vite.config.js.timestamp-1771642338922-3a8734296d3bf.mjs
Normal file
File diff suppressed because one or more lines are too long
165
vite.config.js.timestamp-1771642553007-3bf56500344a6.mjs
Normal file
165
vite.config.js.timestamp-1771642553007-3bf56500344a6.mjs
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user