Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mobile-room-media-viewer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Add a fullscreen mobile room media viewer with gallery navigation and image gestures.
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import androidx.activity.enableEdgeToEdge
import androidx.core.content.ContextCompat
import androidx.core.content.IntentCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
Expand Down Expand Up @@ -153,6 +154,8 @@ class MainActivity : TauriActivity() {

companion object {
private var instance: MainActivity? = null
private var immersiveSystemBarsBehavior: Int? = null
private var immersiveDepth = 0

// Bars stay transparent (edge-to-edge plugin) so the webview strips supply the color
// on every version; these only adapt icon contrast. setStatusBarColor/setNavigationBarColor
Expand All @@ -177,6 +180,32 @@ class MainActivity : TauriActivity() {
}
}

@JvmStatic
fun setImmersiveModeNative(enabled: Boolean) {
val activity = instance ?: return
activity.runOnUiThread {
val controller = WindowCompat.getInsetsController(activity.window, activity.window.decorView)
// Overlapping viewers each request immersive mode; the bars only come back
// once the last one has released it.
if (enabled) {
immersiveDepth += 1
if (immersiveDepth > 1) return@runOnUiThread
if (immersiveSystemBarsBehavior == null) {
immersiveSystemBarsBehavior = controller.systemBarsBehavior
}
controller.systemBarsBehavior =
androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
controller.hide(WindowInsetsCompat.Type.systemBars())
} else {
immersiveDepth = maxOf(0, immersiveDepth - 1)
if (immersiveDepth > 0) return@runOnUiThread
controller.show(WindowInsetsCompat.Type.systemBars())
immersiveSystemBarsBehavior?.let { controller.systemBarsBehavior = it }
immersiveSystemBarsBehavior = null
}
}
}

@JvmStatic
fun startCallForegroundServiceNative() {
val activity = checkNotNull(instance) { "MainActivity is unavailable" }
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,8 @@ pub fn run() {
#[cfg(target_os = "android")]
mobile::set_navigation_bar_color,
#[cfg(target_os = "android")]
mobile::set_immersive_mode,
#[cfg(target_os = "android")]
mobile::start_call_foreground_service,
#[cfg(target_os = "android")]
mobile::stop_call_foreground_service,
Expand Down
17 changes: 17 additions & 0 deletions src-tauri/src/mobile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,23 @@ pub fn set_navigation_bar_color(color: u32) -> Result<(), String> {
call_bar_color("setNavigationBarColorNative", color)
}

#[tauri::command]
pub fn set_immersive_mode(enabled: bool) -> Result<(), String> {
let vm = JAVA_VM.get().ok_or("java vm not initialized")?;
let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?;
let result = env.call_static_method(
"moe/sable/client/MainActivity",
"setImmersiveModeNative",
"(Z)V",
&[JValue::Bool(enabled as u8)],
);
if result.is_err() {
let _ = env.exception_clear();
}
result.map_err(|e| e.to_string())?;
Ok(())
}

#[tauri::command]
pub fn start_call_foreground_service() -> Result<(), String> {
call_static_method("startCallForegroundServiceNative")
Expand Down
7 changes: 6 additions & 1 deletion src/app/components/MobileSwipeDownModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ interface MobileSwipeDownModalProps {
sheetClassName?: string;
sheetStyle?: CSSProperties;
keyboardAware?: boolean;
zIndex?: number;
}

type FocusTrapOptions = ComponentProps<typeof FocusTrap>['focusTrapOptions'];
Expand Down Expand Up @@ -102,6 +103,7 @@ export function MobileSwipeDownModal({
sheetClassName,
sheetStyle,
keyboardAware = false,
zIndex,
}: MobileSwipeDownModalProps) {
const sheetRef = useRef<HTMLDivElement>(null);
const backdropTouchRef = useRef(false);
Expand Down Expand Up @@ -359,7 +361,10 @@ export function MobileSwipeDownModal({
portalRef ? css.MessageMobileOptionsWrappedContained : ''
}`}
data-gestures="ignore"
style={closing ? { opacity: 0, transition: 'opacity 100ms ease-out' } : undefined}
style={{
...(closing ? { opacity: 0, transition: 'opacity 100ms ease-out' } : undefined),
zIndex,
}}
onClick={handleBackdropClick}
// Touch events deliberately propagate: the drag binds them on `document`, and
// stopping them here would starve it. `data-gestures="ignore"` is what keeps the
Expand Down
3 changes: 3 additions & 0 deletions src/app/components/RenderMessageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ type RenderMessageContentProps = {
mEvent?: MatrixEvent;
mx?: MatrixClient;
room?: Room;
onOpenMedia?: (mEvent: MatrixEvent) => boolean;
};

const getMediaType = (url: string) => {
Expand Down Expand Up @@ -112,6 +113,7 @@ function RenderMessageContentInternal({
mEvent,
mx,
room,
onOpenMedia,
}: RenderMessageContentProps) {
const content = useMemo(() => getContent() as Record<string, unknown>, [getContent]);

Expand Down Expand Up @@ -395,6 +397,7 @@ function RenderMessageContentInternal({
renderImageContent={(imageProps) => (
<ImageContent
{...imageProps}
onOpenViewer={mEvent ? () => onOpenMedia?.(mEvent) ?? false : undefined}
autoPlay={mediaAutoLoad}
renderImage={(p) => {
if (isGif && !autoplayGifs && p.src) {
Expand Down
92 changes: 90 additions & 2 deletions src/app/components/ResponsiveMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ComponentProps, CSSProperties, ReactNode } from 'react';
import { useEffect, useState } from 'react';
import type { RectCords } from 'folds';
import { Box, Overlay, OverlayBackdrop, OverlayCenter, PopOut } from 'folds';
import FocusTrap from 'focus-trap-react';
Expand Down Expand Up @@ -28,8 +29,10 @@ type ResponsiveMenuProps = {
arrowNavigation?: 'vertical' | 'both';
/** How the menu shows on mobile: a bottom sheet, or a centred dialog for
* option pickers, which a sheet makes look like an action menu. */
mobile?: 'sheet' | 'dialog';
mobile?: 'sheet' | 'dialog' | 'inline-dialog';
surfaceColor?: string;
/** Raises a mobile sheet above a parent fullscreen overlay. */
mobileZIndex?: number;
};

function MenuDialog({
Expand Down Expand Up @@ -57,6 +60,68 @@ function MenuDialog({
);
}

function InlineMenuDialog({
anchor,
requestClose,
focusTrapOptions,
zIndex,
children,
}: {
anchor: RectCords;
requestClose: () => void;
focusTrapOptions: FocusTrapOptions;
zIndex: number;
children: ReactNode;
}) {
// Android back closes the menu instead of navigating away.
useDismissOnBack(requestClose);

const [viewport, setViewport] = useState(() => ({
width: window.innerWidth,
height: window.innerHeight,
}));
useEffect(() => {
const handleResize = () =>
setViewport({ width: window.innerWidth, height: window.innerHeight });
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);

const maxHeight = Math.round(viewport.height * 0.75);

return (
<FocusTrap focusTrapOptions={focusTrapOptions}>
<Box
style={{ position: 'fixed', inset: 0, zIndex, background: 'transparent' }}
onClick={requestClose}
>
<Box
direction="Column"
role="dialog"
aria-modal="true"
style={{
width: 'fit-content',
maxWidth: `${viewport.width - 16}px`,
maxHeight: `${maxHeight}px`,
overflow: 'auto',
borderRadius: '20px',
position: 'absolute',
// Keep the menu on screen when the trigger sits low in the viewport.
top: Math.min(
Math.max(8, anchor.y + anchor.height + 8),
Math.max(8, viewport.height - maxHeight - 8)
),
right: Math.max(8, viewport.width - anchor.x - anchor.width),
}}
onClick={(event) => event.stopPropagation()}
>
{children}
</Box>
</Box>
</FocusTrap>
);
}

/**
* A menu that hangs off its trigger on desktop and rises as a bottom sheet on
* mobile, where a popout anchored to a tiny target is hard to hit and easy to
Expand All @@ -75,6 +140,7 @@ export function ResponsiveMenu({
arrowNavigation = 'vertical',
mobile = 'sheet',
surfaceColor,
mobileZIndex,
}: ResponsiveMenuProps) {
// Null outside a provider, where desktop is the safe assumption.
const isMobile = useScreenSizeOptionally() === ScreenSize.Mobile;
Expand All @@ -97,6 +163,24 @@ export function ResponsiveMenu({
};

if (isMobile) {
if (mobile === 'inline-dialog') {
return (
<>
{children}
{anchor && (
<InlineMenuDialog
anchor={anchor}
requestClose={requestClose}
focusTrapOptions={focusTrapOptions}
zIndex={mobileZIndex ?? 2_147_483_647}
>
{menu}
</InlineMenuDialog>
)}
</>
);
}

const sheetStyle: CSSProperties | undefined = surfaceColor
? { backgroundColor: surfaceColor }
: undefined;
Expand All @@ -110,7 +194,11 @@ export function ResponsiveMenu({
</MenuDialog>
)}
{anchor && mobile === 'sheet' && (
<MobileSwipeDownModal requestClose={requestClose} sheetStyle={sheetStyle}>
<MobileSwipeDownModal
requestClose={requestClose}
sheetStyle={sheetStyle}
zIndex={mobileZIndex}
>
{() => (
<MobileSheetFocusTrap
focusTrapOptions={{
Expand Down
37 changes: 37 additions & 0 deletions src/app/components/image-viewer/ImageViewer.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ export const ImageViewerHeader = style([
minHeight: config.space.S400,
paddingTop: config.space.S100,
paddingBottom: config.space.S100,
'@media': {
'(max-width: 600px)': {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 1,
borderBottomWidth: 0,
background: 'linear-gradient(#000a, transparent)',
color: '#fff',
},
},
},
]);

Expand All @@ -31,6 +43,12 @@ export const ImageViewerContent = style([
backgroundColor: color.Background.Container,
color: color.Background.OnContainer,
overflow: 'hidden',
'@media': {
'(max-width: 600px)': {
backgroundColor: '#000',
color: '#fff',
},
},
},
]);

Expand Down Expand Up @@ -66,3 +84,22 @@ export const ImageViewerImgPixelated = style({
imageRendering: 'pixelated',
willChange: 'auto',
});

const mobileGalleryControl = {
position: 'absolute' as const,
top: '50%',
zIndex: 1,
transform: 'translateY(-50%)',
backgroundColor: '#0009',
color: '#fff',
};

export const ImageViewerPrevious = style({
...mobileGalleryControl,
left: config.space.S100,
});

export const ImageViewerNext = style({
...mobileGalleryControl,
right: config.space.S100,
});
47 changes: 46 additions & 1 deletion src/app/components/image-viewer/ImageViewer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ describe('ImageViewer', () => {

renderViewer();

const download = screen.getByText('Download');
const download = screen.getByRole('button', { name: 'Download' });
fireEvent.pointerDown(download, { pointerId: 1, pointerType: 'touch' });
fireEvent.pointerUp(download, { pointerId: 1, pointerType: 'touch' });
fireEvent.click(download);
Expand All @@ -110,6 +110,51 @@ describe('ImageViewer', () => {
screenMocks.isMobile = false;
});

it('uses compact controls on mobile', () => {
screenMocks.isMobile = true;
try {
renderViewer();

expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Download' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Zoom In' })).not.toBeInTheDocument();

fireEvent.click(screen.getByRole('button', { name: 'More options' }));

expect(screen.getByText('Turn pixelation on')).toBeInTheDocument();
expect(screen.queryByText('Zoom out')).not.toBeInTheDocument();
expect(screen.queryByText('Zoom in')).not.toBeInTheDocument();
expect(screen.queryByText('Save image')).not.toBeInTheDocument();
} finally {
screenMocks.isMobile = false;
}
});

it('closes the mobile overflow menu once an item is picked', () => {
screenMocks.isMobile = true;
try {
renderViewer();

fireEvent.click(screen.getByRole('button', { name: 'More options' }));
fireEvent.click(screen.getByText('Turn pixelation on'));

expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
} finally {
screenMocks.isMobile = false;
}
});

it('hides the share control when the platform cannot share', () => {
screenMocks.isMobile = true;
try {
renderViewer();

expect(screen.queryByRole('button', { name: 'Share' })).not.toBeInTheDocument();
} finally {
screenMocks.isMobile = false;
}
});

it('shows an error toast when downloading media fails', async () => {
const error = new Error('network unavailable');
downloadMedia.mockRejectedValue(error);
Expand Down
Loading
Loading