Skip to content
9 changes: 8 additions & 1 deletion src/app/components/image-viewer/ImageViewer.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const ImageViewer = style([
DefaultReset,
{
height: '100%',
width: '100%',
},
]);

Expand All @@ -16,6 +17,8 @@ export const ImageViewerHeader = style([
borderBottomWidth: config.borderWidth.B300,
flexShrink: 0,
gap: config.space.S200,
zIndex: 1,
backgroundColor: color.Background.Container,
},
]);

Expand All @@ -25,6 +28,9 @@ export const ImageViewerContent = style([
backgroundColor: color.Background.Container,
color: color.Background.OnContainer,
overflow: 'hidden',
position: 'relative',
flexGrow: 1,
height: '100%',
},
]);

Expand All @@ -37,6 +43,7 @@ export const ImageViewerImg = style([
maxWidth: '100%',
maxHeight: '100%',
backgroundColor: color.Surface.Container,
transition: 'transform 100ms linear',
willChange: 'transform',
userSelect: 'none',
},
]);
102 changes: 95 additions & 7 deletions src/app/components/image-viewer/ImageViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import React from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import FileSaver from 'file-saver';
import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
import * as css from './ImageViewer.css';
import { useZoom } from '../../hooks/useZoom';
import { usePan } from '../../hooks/usePan';
import { downloadMedia } from '../../utils/matrix';

export type ImageViewerProps = {
Expand All @@ -16,14 +15,93 @@ export type ImageViewerProps = {

export const ImageViewer = as<'div', ImageViewerProps>(
({ className, alt, src, requestClose, ...props }, ref) => {
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(1);
const [pan, setPan] = useState({ x: 0, y: 0 });

const isDragging = useRef(false);
const lastMouse = useRef({ x: 0, y: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(null);

const handleDownload = async () => {
const fileContent = await downloadMedia(src);
FileSaver.saveAs(fileContent, alt);
};

const getClampedPosition = useCallback((x: number, y: number, currentZoom: number) => {
if (!containerRef.current || !imgRef.current) return { x, y };

const container = containerRef.current.getBoundingClientRect();
const imgWidth = imgRef.current.offsetWidth * currentZoom;
const imgHeight = imgRef.current.offsetHeight * currentZoom;

const maxX = Math.max(0, (imgWidth - container.width) / 2);
const maxY = Math.max(0, (imgHeight - container.height) / 2);

return {
x: Math.min(Math.max(x, -maxX), maxX),
y: Math.min(Math.max(y, -maxY), maxY),
};
}, []);

useEffect(() => {
setPan((p) => getClampedPosition(p.x, p.y, zoom));
}, [zoom, getClampedPosition]);

const handleWheel = useCallback(
(e: React.WheelEvent) => {
e.stopPropagation();
const delta = e.deltaY * -0.001;
const newZoom = Math.min(Math.max(0.1, zoom + delta), 5);
setZoom(newZoom);
},
[zoom, setZoom]
);

const handleMouseDown = (e: React.MouseEvent) => {
if (zoom <= 1) return;
e.preventDefault();
isDragging.current = true;
lastMouse.current = { x: e.clientX, y: e.clientY };
};

const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDragging.current) return;

const deltaX = e.clientX - lastMouse.current.x;
const deltaY = e.clientY - lastMouse.current.y;
lastMouse.current = { x: e.clientX, y: e.clientY };

setPan((prev) => {
const nextX = prev.x + deltaX * 0.8;
const nextY = prev.y + deltaY * 0.8;
return getClampedPosition(nextX, nextY, zoom);
});
},
[zoom, getClampedPosition]
);

const handleMouseUp = useCallback(() => {
isDragging.current = false;
}, []);

useEffect(() => {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [handleMouseMove, handleMouseUp]);

let cursor = 'grab';
if (zoom <= 1) {
cursor = 'default';
} else if (isDragging.current) {
cursor = 'grabbing';
}

return (
<Box
className={classNames(css.ImageViewer, className)}
Expand Down Expand Up @@ -51,7 +129,12 @@ export const ImageViewer = as<'div', ImageViewerProps>(
>
<Icon size="50" src={Icons.Minus} />
</IconButton>
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
<Chip
variant="SurfaceVariant"
radii="Pill"
onClick={() => setZoom(zoom === 1 ? 2 : 1)}
style={{ minWidth: '4rem', justifyContent: 'center' }}
>
<Text size="B300">{Math.round(zoom * 100)}%</Text>
</Chip>
<IconButton
Expand Down Expand Up @@ -79,16 +162,21 @@ export const ImageViewer = as<'div', ImageViewerProps>(
className={css.ImageViewerContent}
justifyContent="Center"
alignItems="Center"
onWheel={handleWheel}
ref={containerRef}
>
<img
ref={imgRef}
className={css.ImageViewerImg}
style={{
cursor,
transform: `scale(${zoom}) translate(${pan.translateX}px, ${pan.translateY}px)`,
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
userSelect: 'none',
}}
src={src}
alt={alt}
onMouseDown={onMouseDown}
onMouseDown={handleMouseDown}
draggable={false}
/>
</Box>
</Box>
Expand Down
16 changes: 16 additions & 0 deletions src/app/components/url-preview/UrlPreviewCard.css.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { recipe } from '@vanilla-extract/recipes';
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, toRem } from 'folds';

export const UrlPreviewHolderGradient = recipe({
Expand All @@ -24,6 +25,7 @@ export const UrlPreviewHolderGradient = recipe({
},
},
});

export const UrlPreviewHolderBtn = recipe({
base: [
DefaultReset,
Expand All @@ -45,3 +47,17 @@ export const UrlPreviewHolderBtn = recipe({
},
},
});

export const UrlPreviewModal = style([
DefaultReset,
{
width: '90vw',
height: '85vh',
maxWidth: toRem(1200),
maxHeight: toRem(1000),
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
backgroundColor: color.Background.Container,
},
]);
78 changes: 71 additions & 7 deletions src/app/components/url-preview/UrlPreviewCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,42 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(

const renderContent = (prev: IPreviewUrlResponse) => {
const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication, 256, 256, 'scale', false);
const rawImgUrl = prev['og:image'] ? mxcUrlToHttp(mx, prev['og:image'], useAuthentication) : null;

const title = prev['og:title'];
const description = prev['og:description'];
const siteName = prev['og:site_name'];

const isHeroImage =
rawImgUrl &&
(prev['og:type']?.startsWith('image') || !description || title === url);

if (isHeroImage) {
return (
<>
<UrlPreviewHeroImg
src={rawImgUrl ?? ''}
alt={title}
title={title}
onClick={() => setViewImage(rawImgUrl)}
/>
<UrlPreviewContent>
<Text
style={linkStyles}
truncate
as="a"
href={url}
target="_blank"
rel="no-referrer"
size="T200"
priority="300"
>
{tryDecodeURIComponent(url)}
</Text>
</UrlPreviewContent>
</>
);
}

return (
<>
Expand Down Expand Up @@ -61,13 +97,41 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
};

return (
<UrlPreview {...props} ref={ref}>
{previewStatus.status === AsyncStatus.Success ? (
renderContent(previewStatus.data)
) : (
<Box grow="Yes" alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" size="400" />
</Box>
<>
<UrlPreview {...props} ref={ref}>
{previewStatus.status === AsyncStatus.Success ? (
renderContent(previewStatus.data)
) : (
<Box grow="Yes" alignItems="Center" justifyContent="Center" style={{ minHeight: '102px' }}>
<Spinner variant="Secondary" size="400" />
</Box>
)}
</UrlPreview>

{viewImage && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setViewImage(null),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal
className={css.UrlPreviewModal}
onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}
>
<ImageViewer
src={viewImage}
alt="Image Preview"
requestClose={() => setViewImage(null)}
/>
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
</UrlPreview>
);
Expand Down
13 changes: 9 additions & 4 deletions src/app/features/common-settings/general/RoomUpgrade.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,18 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
const alive = useAlive();
const creators = useRoomCreators(room);

const createEvent = useStateEvent(room, StateEvent.RoomCreate);
const createContent = createEvent?.getContent() as IRoomCreateContent | undefined;
const currentRoomVersion = createContent?.room_version ?? '1';
const capabilities = useCapabilities();
const roomVersions = capabilities['m.room_versions'];
const [selectedRoomVersion, selectRoomVersion] = useState(roomVersions?.default ?? '1');
const [selectedRoomVersion, selectRoomVersion] = useState(currentRoomVersion);

useEffect(() => {
// capabilities load async
selectRoomVersion(roomVersions?.default ?? '1');
}, [roomVersions?.default]);
if (currentRoomVersion) {
selectRoomVersion(currentRoomVersion);
}
}, [currentRoomVersion]);

const allowAdditionalCreators = creatorsSupported(selectedRoomVersion);
const { additionalCreators, addAdditionalCreator, removeAdditionalCreator } =
Expand Down