Compare commits
7
Commits
dcfee9f1df
...
09f37f890f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09f37f890f | ||
|
|
154e35ef9f | ||
|
|
36fdbdd399 | ||
|
|
09415f95c0 | ||
|
|
4c298a36b4 | ||
|
|
836e4a6679 | ||
|
|
d615999737 |
@@ -1,9 +1,10 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
|
||||
import { IGif } from '@giphy/js-types';
|
||||
import { Box, color, config } from 'folds';
|
||||
import { useElementSizeObserver } from '../hooks/useElementSizeObserver';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { addRecentGif, RecentGif, recentGifsAtom } from '../state/recentGifs';
|
||||
@@ -146,8 +147,18 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
||||
|
||||
const showRecents = recents.length > 0 && !(term ?? '').trim();
|
||||
|
||||
// The container is min(312px, 100vw-16); feed the Grid the live pixel width
|
||||
// (minus the inner 8px padding on each side) so it doesn't overflow a phone
|
||||
// narrower than 312px with a fixed 296px grid.
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [gridWidth, setGridWidth] = useState(PICKER_WIDTH - 16);
|
||||
useElementSizeObserver(
|
||||
useCallback(() => containerRef.current, []),
|
||||
useCallback((w) => setGridWidth(Math.max(1, Math.floor(w) - 16)), []),
|
||||
);
|
||||
|
||||
return (
|
||||
<Box direction="Column" style={{ width: PICKER_WIDTH_CSS }}>
|
||||
<Box direction="Column" style={{ width: PICKER_WIDTH_CSS }} ref={containerRef}>
|
||||
{lotusTerminal && (
|
||||
<div
|
||||
style={{
|
||||
@@ -178,7 +189,7 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
||||
<Grid
|
||||
key={searchKey}
|
||||
fetchGifs={fetchGifs}
|
||||
width={PICKER_WIDTH - 16}
|
||||
width={gridWidth}
|
||||
columns={2}
|
||||
gutter={4}
|
||||
onGifClick={handleClick}
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Menu, PopOut, toRem } from 'folds';
|
||||
import {
|
||||
Box,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Menu,
|
||||
Modal,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
PopOut,
|
||||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useCloseUserRoomProfile, useUserRoomProfileState } from '../state/hooks/userRoomProfile';
|
||||
import { UserRoomProfile } from './user-profile';
|
||||
@@ -8,6 +22,20 @@ import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom';
|
||||
import { stopPropagation } from '../utils/keyboard';
|
||||
import { SpaceProvider } from '../hooks/useSpace';
|
||||
import { RoomProvider } from '../hooks/useRoom';
|
||||
import { ScreenSize, useScreenSize } from '../hooks/useScreenSize';
|
||||
|
||||
// Matches useModalStyle's mobile branch: fill the phone screen with internal
|
||||
// scroll so tall profiles (moderation actions, device list, notes) are fully
|
||||
// reachable — the anchored 340px popout below can't scroll and clipped them.
|
||||
const MOBILE_FULLSCREEN = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
borderRadius: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
} as const;
|
||||
|
||||
function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) {
|
||||
const { roomId, spaceId, userId, cords, position } = state;
|
||||
@@ -15,32 +43,61 @@ function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState })
|
||||
const getRoom = useGetRoom(allJoinedRooms);
|
||||
const room = getRoom(roomId);
|
||||
const space = spaceId ? getRoom(spaceId) : undefined;
|
||||
const screenSize = useScreenSize();
|
||||
|
||||
const close = useCloseUserRoomProfile();
|
||||
|
||||
if (!room) return null;
|
||||
|
||||
const profile = (
|
||||
<SpaceProvider value={space ?? null}>
|
||||
<RoomProvider value={room}>
|
||||
<UserRoomProfile userId={userId} />
|
||||
</RoomProvider>
|
||||
</SpaceProvider>
|
||||
);
|
||||
|
||||
const focusTrapOptions = {
|
||||
initialFocus: false,
|
||||
onDeactivate: close,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
};
|
||||
|
||||
// On phones, render as a full-screen scrollable modal instead of an anchored,
|
||||
// fixed-width, unscrollable popout.
|
||||
if (screenSize === ScreenSize.Mobile) {
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap focusTrapOptions={focusTrapOptions}>
|
||||
<Modal size="500" style={MOBILE_FULLSCREEN}>
|
||||
{/* Full-screen covers the backdrop (no tap-to-dismiss) and the
|
||||
profile has no self-close, so provide an explicit close. */}
|
||||
<Header size="600" style={{ flexShrink: 0, paddingRight: config.space.S200 }}>
|
||||
<Box grow="Yes" />
|
||||
<IconButton size="300" radii="300" onClick={close} aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
<Box grow="Yes" style={{ overflow: 'hidden auto' }}>
|
||||
{profile}
|
||||
</Box>
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PopOut
|
||||
anchor={cords}
|
||||
position={position ?? 'Top'}
|
||||
align="Start"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: close,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu style={{ width: toRem(340) }}>
|
||||
<SpaceProvider value={space ?? null}>
|
||||
<RoomProvider value={room}>
|
||||
<UserRoomProfile userId={userId} />
|
||||
</RoomProvider>
|
||||
</SpaceProvider>
|
||||
</Menu>
|
||||
<FocusTrap focusTrapOptions={focusTrapOptions}>
|
||||
<Menu style={{ width: toRem(340) }}>{profile}</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -16,9 +16,23 @@ export const EditorOptions = style([
|
||||
DefaultReset,
|
||||
{
|
||||
padding: config.space.S200,
|
||||
'@media': {
|
||||
// On phones the toolbar can hold many 44px buttons; let them wrap to a
|
||||
// second line instead of overflowing horizontally.
|
||||
'(max-width: 750px)': { flexWrap: 'wrap' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// The composer's before | editable | after row. On phones, allow the toolbar
|
||||
// (`after`) to wrap below the input instead of squeezing the editable to zero
|
||||
// and pushing the Send button off-screen.
|
||||
export const EditorInputRow = style({
|
||||
'@media': {
|
||||
'(max-width: 750px)': { flexWrap: 'wrap' },
|
||||
},
|
||||
});
|
||||
|
||||
export const EditorTextareaScroll = style({});
|
||||
|
||||
export const EditorTextarea = style([
|
||||
|
||||
@@ -124,7 +124,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||
<div className={css.Editor} ref={ref}>
|
||||
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
|
||||
{top}
|
||||
<Box alignItems="Start">
|
||||
<Box className={css.EditorInputRow} alignItems="Start">
|
||||
{before && (
|
||||
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
|
||||
{before}
|
||||
|
||||
@@ -19,7 +19,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
||||
const { t } = useTranslation();
|
||||
const saveFile = useSaveFile();
|
||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
||||
const { pan, cursor, onMouseDown, onTouchStart } = usePan(zoom !== 1);
|
||||
|
||||
const handleDownload = async () => {
|
||||
const fileContent = await downloadMedia(src);
|
||||
@@ -100,6 +100,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
||||
src={src}
|
||||
alt={alt}
|
||||
onMouseDown={onMouseDown}
|
||||
onTouchStart={onTouchStart}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -11,7 +11,7 @@ export const MediaControl = as<'div', MediaControlProps>(
|
||||
({ before, after, leftControl, rightControl, children, ...props }, ref) => (
|
||||
<Box grow="Yes" direction="Column" gap="300" {...props} ref={ref}>
|
||||
{before && <Box direction="Column">{before}</Box>}
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Box alignItems="Center" gap="200" wrap="Wrap">
|
||||
<Box alignItems="Center" grow="Yes" gap="Inherit">
|
||||
{leftControl}
|
||||
</Box>
|
||||
|
||||
@@ -417,6 +417,22 @@ type RenderImageContentProps = {
|
||||
markedAsSpoiler?: boolean;
|
||||
spoilerReason?: string;
|
||||
};
|
||||
// Media frame sizing. When intrinsic width/height are known, drive the box by
|
||||
// aspect-ratio so its height tracks the responsive (maxWidth:100%) width — a
|
||||
// fixed pixel height computed for a 400px-wide layout otherwise crops (images,
|
||||
// object-fit:cover) or letterboxes (videos, object-fit:contain) on phones where
|
||||
// the box narrows below 400px. On desktop the box stays 400px wide, so the
|
||||
// aspect-ratio yields the identical height. Falls back to the fixed height when
|
||||
// dimensions are unknown.
|
||||
const attachmentMediaStyle = (
|
||||
w: number | undefined,
|
||||
h: number | undefined,
|
||||
fallbackHeight: number,
|
||||
): CSSProperties =>
|
||||
w && h
|
||||
? { aspectRatio: `${w} / ${h}`, minHeight: toRem(48) }
|
||||
: { height: toRem(fallbackHeight < 48 ? 48 : fallbackHeight) };
|
||||
|
||||
type MImageProps = {
|
||||
content: IImageContent;
|
||||
renderImageContent: (props: RenderImageContentProps) => ReactNode;
|
||||
@@ -432,11 +448,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
||||
|
||||
return (
|
||||
<Attachment outlined={outlined}>
|
||||
<AttachmentBox
|
||||
style={{
|
||||
height: toRem(height < 48 ? 48 : height),
|
||||
}}
|
||||
>
|
||||
<AttachmentBox style={attachmentMediaStyle(imgInfo?.w, imgInfo?.h, height)}>
|
||||
{renderImageContent({
|
||||
body: content.body || 'Image',
|
||||
info: imgInfo,
|
||||
@@ -498,11 +510,7 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
|
||||
}
|
||||
/>
|
||||
</AttachmentHeader>
|
||||
<AttachmentBox
|
||||
style={{
|
||||
height: toRem(height < 48 ? 48 : height),
|
||||
}}
|
||||
>
|
||||
<AttachmentBox style={attachmentMediaStyle(videoInfo.w, videoInfo.h, height)}>
|
||||
{renderVideoContent({
|
||||
body: content.body || 'Video',
|
||||
info: videoInfo,
|
||||
|
||||
@@ -53,6 +53,11 @@ const NavItemBase = style({
|
||||
color: OnContainer,
|
||||
outline: 'none',
|
||||
minHeight: toRem(36),
|
||||
'@media': {
|
||||
// The room/nav row is the app's primary tap target; give it a 44px touch
|
||||
// area on phones (desktop stays the denser 36px).
|
||||
'(max-width: 750px)': { minHeight: toRem(44) },
|
||||
},
|
||||
|
||||
selectors: {
|
||||
'&:hover, &:focus-visible': {
|
||||
|
||||
@@ -6,6 +6,11 @@ export const CardGrid = style({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: config.space.S400,
|
||||
'@media': {
|
||||
// Cards squish/overflow below ~360px each; drop to a single column on phones
|
||||
// (mirrors the 750px breakpoint the nav uses).
|
||||
'(max-width: 750px)': { gridTemplateColumns: '1fr' },
|
||||
},
|
||||
});
|
||||
|
||||
export const RoomCardBase = style([
|
||||
|
||||
@@ -81,6 +81,10 @@ export const SidebarItem = recipe({
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
|
||||
'@media': {
|
||||
// Space-rail buttons to a 44px touch target on phones (2px larger).
|
||||
'(max-width: 750px)': { minWidth: toRem(44), minHeight: toRem(44) },
|
||||
},
|
||||
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
|
||||
@@ -275,6 +275,7 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
||||
key={key}
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
wrap="Wrap"
|
||||
style={{
|
||||
padding: config.space.S200,
|
||||
borderRadius: config.radii.R400,
|
||||
|
||||
@@ -4,7 +4,9 @@ import { DefaultReset, color, config, toRem } from 'folds';
|
||||
export const UrlPreview = style([
|
||||
DefaultReset,
|
||||
{
|
||||
width: toRem(400),
|
||||
// 25rem (=400px) on desktop, but shrink to fit narrow phones so a single
|
||||
// card doesn't exceed the viewport (mirrors UrlPreviewWide's min()).
|
||||
width: 'min(25rem, 92vw)',
|
||||
minHeight: toRem(102),
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { UserAvatar } from '../user-avatar';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { BreakWord, LineClamp2, LineClamp3 } from '../../styles/Text.css';
|
||||
import { ModalMobileFull } from '../../styles/Modal.css';
|
||||
import { UserPresence } from '../../hooks/useUserPresence';
|
||||
import { AvatarPresence, PresenceBadge } from '../presence';
|
||||
import { AvatarDecoration } from '../avatar-decoration/AvatarDecoration';
|
||||
@@ -83,7 +84,11 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal size="500" onContextMenu={(evt: any) => evt.stopPropagation()}>
|
||||
<Modal
|
||||
size="500"
|
||||
className={ModalMobileFull}
|
||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||
>
|
||||
<ImageViewer
|
||||
src={viewAvatar}
|
||||
alt={userId}
|
||||
|
||||
@@ -35,6 +35,7 @@ import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { callEmbedAtom } from '../../state/callEmbed';
|
||||
import { useResizeObserver } from '../../hooks/useResizeObserver';
|
||||
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
||||
@@ -51,18 +52,25 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
const controlRef = useRef<HTMLDivElement>(null);
|
||||
const callEmbedRef = useCallEmbedRef();
|
||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||
const [compact, setCompact] = useState(document.body.clientWidth < 500);
|
||||
const screenSize = useScreenSize();
|
||||
const [narrowContainer, setNarrowContainer] = useState(document.body.clientWidth < 500);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useResizeObserver(
|
||||
useCallback(() => {
|
||||
const element = controlRef.current;
|
||||
if (!element) return;
|
||||
setCompact(element.clientWidth < 500);
|
||||
setNarrowContainer(element.clientWidth < 500);
|
||||
}, []),
|
||||
useCallback(() => controlRef.current, []),
|
||||
);
|
||||
|
||||
// Collapse to the stacked/compact layout whenever the bar's own container is
|
||||
// narrow (a small desktop call window) OR the viewport is a phone. The old
|
||||
// element-only `< 500` check left the ~11-control row overflowing off-screen
|
||||
// in the 500–750px band (landscape phones / small tablets).
|
||||
const compact = narrowContainer || screenSize === ScreenSize.Mobile;
|
||||
|
||||
useEffect(() => {
|
||||
const onFullscreenChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange);
|
||||
@@ -330,6 +338,9 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
padding: '1rem 1.25rem',
|
||||
zIndex: 100,
|
||||
minWidth: '260px',
|
||||
// Don't run past the screen edges on a narrow phone (centered via
|
||||
// translateX(-50%)); clamp to the viewport minus a small margin.
|
||||
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.35)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -376,6 +387,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
radii="500"
|
||||
alignItems="Center"
|
||||
justifyContent="SpaceBetween"
|
||||
wrap="Wrap"
|
||||
>
|
||||
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||
|
||||
@@ -135,7 +135,12 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu style={{ maxWidth: manage ? toRem(420) : toRem(340), maxHeight: '70vh' }}>
|
||||
<Menu
|
||||
style={{
|
||||
maxWidth: `min(${manage ? toRem(420) : toRem(340)}, calc(100vw - 2 * ${config.space.S400}))`,
|
||||
maxHeight: '70vh',
|
||||
}}
|
||||
>
|
||||
<Box direction="Column" style={{ maxHeight: '70vh' }}>
|
||||
<Box
|
||||
shrink="No"
|
||||
|
||||
@@ -119,7 +119,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
|
||||
return (
|
||||
<Box onSubmit={handleSubmit} as="form" direction="Column" gap="400">
|
||||
<Box direction="Column" gap="300">
|
||||
<Box gap="200">
|
||||
<Box gap="200" wrap="Wrap">
|
||||
<Box shrink="No" direction="Column" gap="100">
|
||||
<Text size="L400">Color</Text>
|
||||
<Box gap="200">
|
||||
|
||||
@@ -307,7 +307,7 @@ export function PolicyListViewer({ requestClose }: PolicyListViewerProps) {
|
||||
gap="300"
|
||||
>
|
||||
{/* Tabs */}
|
||||
<Box gap="200">
|
||||
<Box gap="200" wrap="Wrap">
|
||||
<TabButton
|
||||
label="Users"
|
||||
count={userEntries.length}
|
||||
|
||||
@@ -190,6 +190,7 @@ function LightboxMedia({
|
||||
pan,
|
||||
cursor,
|
||||
onMouseDown,
|
||||
onTouchStart,
|
||||
onImageDoubleClick,
|
||||
}: {
|
||||
item: LightboxItem;
|
||||
@@ -198,6 +199,7 @@ function LightboxMedia({
|
||||
pan: Pan;
|
||||
cursor: string;
|
||||
onMouseDown: React.MouseEventHandler<HTMLElement>;
|
||||
onTouchStart: React.TouchEventHandler<HTMLElement>;
|
||||
onImageDoubleClick: () => void;
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
@@ -253,6 +255,7 @@ function LightboxMedia({
|
||||
alt={item.body}
|
||||
draggable={false}
|
||||
onMouseDown={onMouseDown}
|
||||
onTouchStart={onTouchStart}
|
||||
onDoubleClick={onImageDoubleClick}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
@@ -297,7 +300,7 @@ function Lightbox({
|
||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||
// Pan is only active for a zoomed-in image; usePan resets its offset when this
|
||||
// flips false (i.e. back to 1x, on navigation, or on a video).
|
||||
const { pan, cursor, onMouseDown } = usePan(isImage && zoom !== 1);
|
||||
const { pan, cursor, onMouseDown, onTouchStart } = usePan(isImage && zoom !== 1);
|
||||
const toggleZoom = useCallback(() => setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]);
|
||||
|
||||
// Reset zoom when navigating to another item (and thus pan, via usePan).
|
||||
@@ -512,6 +515,7 @@ function Lightbox({
|
||||
pan={pan}
|
||||
cursor={cursor}
|
||||
onMouseDown={onMouseDown}
|
||||
onTouchStart={onTouchStart}
|
||||
onImageDoubleClick={toggleZoom}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomLatestRenderedEvent } from '../../hooks/useRoomLatestRenderedEvent';
|
||||
import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
|
||||
import { EventReaders } from '../../components/event-readers';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
|
||||
export function RoomViewFollowingPlaceholder() {
|
||||
@@ -34,6 +35,7 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
|
||||
({ className, room, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const modalStyle = useModalStyle(360);
|
||||
const latestEvent = useRoomLatestRenderedEvent(room);
|
||||
const latestEventReaders = useRoomEventReaders(room, latestEvent?.getId());
|
||||
const names = latestEventReaders
|
||||
@@ -55,7 +57,7 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal variant="Surface" size="300">
|
||||
<Modal variant="Surface" size="300" style={modalStyle}>
|
||||
<EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
|
||||
@@ -65,6 +65,7 @@ import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
||||
import { msgTranslationActiveAtomFamily } from '../../../state/translation';
|
||||
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useModalStyle } from '../../../hooks/useModalStyle';
|
||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||
import * as css from './styles.css';
|
||||
import { MsgAppearClass, SendingSpinClass } from '../../../styles/Animations.css';
|
||||
@@ -260,6 +261,7 @@ export const MessageReadReceiptItem = as<
|
||||
}
|
||||
>(({ room, eventId, onClose, ...props }, ref) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const modalStyle = useModalStyle(360);
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
@@ -278,7 +280,7 @@ export const MessageReadReceiptItem = as<
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal variant="Surface" size="300">
|
||||
<Modal variant="Surface" size="300" style={modalStyle}>
|
||||
<EventReaders room={room} eventId={eventId} requestClose={handleClose} />
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
@@ -674,6 +676,7 @@ export const MessageReportItem = as<
|
||||
>(({ room, mEvent, onClose, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const modalStyle = useModalStyle(480);
|
||||
const [reportState, reportMessage] = useAsyncCallback(
|
||||
useCallback(
|
||||
(eventId: string, score: number, reason: string) =>
|
||||
@@ -715,7 +718,7 @@ export const MessageReportItem = as<
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Dialog variant="Surface">
|
||||
<Dialog variant="Surface" style={modalStyle}>
|
||||
<Header
|
||||
style={{
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||
|
||||
@@ -1898,27 +1898,27 @@ function Calls() {
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Ringtone Volume"
|
||||
description="Volume of the incoming call ringtone."
|
||||
after={
|
||||
<Box direction="Row" alignItems="Center" gap="200" style={{ minWidth: '160px' }}>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="5"
|
||||
value={ringtoneVolume}
|
||||
onChange={(e) => setRingtoneVolume(parseInt(e.target.value, 10))}
|
||||
aria-label="Ringtone volume"
|
||||
style={{ flex: 1, accentColor: color.Primary.Main }}
|
||||
/>
|
||||
<Text size="T200" style={{ minWidth: '32px', textAlign: 'right' }}>
|
||||
{ringtoneVolume}%
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<SettingTile title="Ringtone Volume" description="Volume of the incoming call ringtone." />
|
||||
<Box
|
||||
direction="Row"
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="5"
|
||||
value={ringtoneVolume}
|
||||
onChange={(e) => setRingtoneVolume(parseInt(e.target.value, 10))}
|
||||
aria-label="Ringtone volume"
|
||||
style={{ flex: 1, accentColor: color.Primary.Main }}
|
||||
/>
|
||||
<Text size="T200" style={{ minWidth: '32px', textAlign: 'right' }}>
|
||||
{ringtoneVolume}%
|
||||
</Text>
|
||||
</Box>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
@@ -1990,26 +1990,28 @@ function Calls() {
|
||||
}
|
||||
/>
|
||||
{soundboardEnabled && (
|
||||
<SettingTile
|
||||
title="Soundboard Volume"
|
||||
after={
|
||||
<Box alignItems="Center" gap="200" style={{ minWidth: toRem(180) }}>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={soundboardVolume}
|
||||
onChange={(e) => setSoundboardVolume(parseInt(e.target.value, 10))}
|
||||
style={{ flexGrow: 1 }}
|
||||
aria-label="Soundboard volume"
|
||||
/>
|
||||
<Text size="T200" style={{ minWidth: toRem(36), textAlign: 'right' }}>
|
||||
{soundboardVolume}%
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<SettingTile title="Soundboard Volume" />
|
||||
<Box
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={soundboardVolume}
|
||||
onChange={(e) => setSoundboardVolume(parseInt(e.target.value, 10))}
|
||||
style={{ flexGrow: 1 }}
|
||||
aria-label="Soundboard volume"
|
||||
/>
|
||||
<Text size="T200" style={{ minWidth: toRem(36), textAlign: 'right' }}>
|
||||
{soundboardVolume}%
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
@@ -2454,19 +2456,13 @@ function Messages() {
|
||||
: 'On-device translation isn’t available in this browser. Use a Chromium desktop browser (Chrome/Edge 138+) or the Lotus desktop app.'
|
||||
}
|
||||
after={
|
||||
<select
|
||||
aria-label="Translate messages into"
|
||||
disabled={!translationSupported}
|
||||
<SettingsSelect
|
||||
value={selectedTargetLang}
|
||||
onChange={(e) => setTranslateTargetLang(e.target.value)}
|
||||
style={pickerInputStyle(color, config)}
|
||||
>
|
||||
{TRANSLATE_TARGET_LANGUAGES.map((l) => (
|
||||
<option key={l.code} value={l.code}>
|
||||
{l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(v) => setTranslateTargetLang(v)}
|
||||
disabled={!translationSupported}
|
||||
aria-label="Translate messages into"
|
||||
options={TRANSLATE_TARGET_LANGUAGES.map((l) => ({ value: l.code, label: l.name }))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{translationSupported && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useRef, CSSProperties } from 'react';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { color, config, Icon, IconButton, Icons } from 'folds';
|
||||
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
|
||||
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
@@ -37,6 +38,7 @@ function ToastCard({ toast }: ToastCardProps) {
|
||||
// folds tokens so toasts render correctly on stock Cinny themes (the --lt-*
|
||||
// vars only exist while Terminal mode is active).
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
const isMobile = useScreenSize() === ScreenSize.Mobile;
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -80,8 +82,11 @@ function ToastCard({ toast }: ToastCardProps) {
|
||||
}`,
|
||||
borderRadius: config.radii.R400,
|
||||
padding: `${config.space.S300} ${config.space.S400}`,
|
||||
minWidth: '280px',
|
||||
maxWidth: '340px',
|
||||
// Full-width on phones (the container spans the viewport there); a fixed
|
||||
// 280-340px card would otherwise overflow a narrow screen.
|
||||
minWidth: isMobile ? 0 : '280px',
|
||||
maxWidth: isMobile ? 'none' : '340px',
|
||||
width: isMobile ? '100%' : undefined,
|
||||
boxShadow: lotusTerminal
|
||||
? toast.sticky
|
||||
? 'var(--lt-box-glow-cyan)'
|
||||
@@ -216,13 +221,17 @@ export function LotusToastContainer() {
|
||||
}, []);
|
||||
|
||||
const toasts = useAtomValue(toastQueueAtom);
|
||||
const isMobile = useScreenSize() === ScreenSize.Mobile;
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
const containerStyle: CSSProperties = {
|
||||
position: 'fixed',
|
||||
bottom: '1.5rem',
|
||||
right: '1.5rem',
|
||||
// Span the width just inside the screen edges on a phone (so full-width
|
||||
// cards fit); float bottom-right on desktop.
|
||||
bottom: isMobile ? config.space.S200 : '1.5rem',
|
||||
right: isMobile ? config.space.S200 : '1.5rem',
|
||||
left: isMobile ? config.space.S200 : undefined,
|
||||
zIndex: zIndices.toast,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
||||
+46
-1
@@ -1,4 +1,4 @@
|
||||
import { MouseEventHandler, useEffect, useRef, useState } from 'react';
|
||||
import { MouseEventHandler, TouchEventHandler, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export type Pan = {
|
||||
translateX: number;
|
||||
@@ -21,6 +21,10 @@ export const usePan = (active: boolean) => {
|
||||
const attachedRef = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
|
||||
null,
|
||||
);
|
||||
const touchAttachedRef = useRef<{
|
||||
move: (e: TouchEvent) => void;
|
||||
end: (e: TouchEvent) => void;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCursor(active ? 'grab' : 'initial');
|
||||
@@ -53,6 +57,40 @@ export const usePan = (active: boolean) => {
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
// Touch equivalent so a zoomed image can be dragged on a phone. Single-finger
|
||||
// only (ignore multi-touch / pinch); touch events carry no movementX/Y, so we
|
||||
// derive the delta from the previous touch position.
|
||||
const handleTouchStart: TouchEventHandler<HTMLElement> = (evt) => {
|
||||
if (!active || evt.touches.length !== 1) return;
|
||||
setCursor('grabbing');
|
||||
let lastX = evt.touches[0].clientX;
|
||||
let lastY = evt.touches[0].clientY;
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
e.preventDefault();
|
||||
const t = e.touches[0];
|
||||
const dx = t.clientX - lastX;
|
||||
const dy = t.clientY - lastY;
|
||||
lastX = t.clientX;
|
||||
lastY = t.clientY;
|
||||
setPan((p) => ({ translateX: p.translateX + dx, translateY: p.translateY + dy }));
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
setCursor('grab');
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
document.removeEventListener('touchcancel', handleTouchEnd);
|
||||
touchAttachedRef.current = null;
|
||||
};
|
||||
|
||||
touchAttachedRef.current = { move: handleTouchMove, end: handleTouchEnd };
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
document.addEventListener('touchcancel', handleTouchEnd);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) setPan(INITIAL_PAN);
|
||||
}, [active]);
|
||||
@@ -65,6 +103,12 @@ export const usePan = (active: boolean) => {
|
||||
document.removeEventListener('mouseup', attachedRef.current.up);
|
||||
attachedRef.current = null;
|
||||
}
|
||||
if (touchAttachedRef.current) {
|
||||
document.removeEventListener('touchmove', touchAttachedRef.current.move);
|
||||
document.removeEventListener('touchend', touchAttachedRef.current.end);
|
||||
document.removeEventListener('touchcancel', touchAttachedRef.current.end);
|
||||
touchAttachedRef.current = null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -73,5 +117,6 @@ export const usePan = (active: boolean) => {
|
||||
pan,
|
||||
cursor,
|
||||
onMouseDown: handleMouseDown,
|
||||
onTouchStart: handleTouchStart,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -455,6 +455,17 @@ export const getReactCustomHtmlParser = (
|
||||
return <CodeBlock opts={opts}>{children}</CodeBlock>;
|
||||
}
|
||||
|
||||
if (name === 'table') {
|
||||
// Sanitize allows tables, but a wide one would otherwise overflow the
|
||||
// message column and the page body on narrow screens. Wrap it in a
|
||||
// horizontally-scrollable container (same idea as CodeBlock's Scroll).
|
||||
return (
|
||||
<div style={{ overflowX: 'auto', maxWidth: '100%' }}>
|
||||
<table {...props}>{domToReact(children as unknown as DOMNode[], opts)}</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'blockquote') {
|
||||
return (
|
||||
<Text {...props} size="Inherit" as="blockquote" className={css.BlockQuote}>
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
const mobileFullscreen = {
|
||||
minWidth: '100vw',
|
||||
minHeight: '100vh',
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
borderRadius: 0,
|
||||
} as const;
|
||||
|
||||
export const ModalWide = style({
|
||||
minWidth: '85vw',
|
||||
minHeight: '90vh',
|
||||
'@media': {
|
||||
// Fill the phone screen instead of floating as an 85vw card with margins.
|
||||
'(max-width: 750px)': mobileFullscreen,
|
||||
},
|
||||
});
|
||||
|
||||
// Mobile-only full-screen: no desktop effect (keeps the modal's normal size),
|
||||
// but fills the viewport on phones. For dialogs that should stay a small card on
|
||||
// desktop but go edge-to-edge on mobile (e.g. the avatar viewer).
|
||||
export const ModalMobileFull = style({
|
||||
'@media': {
|
||||
'(max-width: 750px)': mobileFullscreen,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user