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