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';
|
||||
|
||||
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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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 { 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user