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,7 @@ class MainActivity : TauriActivity() {

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

// 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 +179,26 @@ class MainActivity : TauriActivity() {
}
}

@JvmStatic
fun setImmersiveModeNative(enabled: Boolean) {
val activity = instance ?: return
activity.runOnUiThread {
val controller = WindowCompat.getInsetsController(activity.window, activity.window.decorView)
if (enabled) {
if (immersiveSystemBarsBehavior == null) {
immersiveSystemBarsBehavior = controller.systemBarsBehavior
}
controller.systemBarsBehavior =
androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
controller.hide(WindowInsetsCompat.Type.systemBars())
} else {
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) => void;
};

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) : undefined}
autoPlay={mediaAutoLoad}
renderImage={(p) => {
if (isGif && !autoplayGifs && p.src) {
Expand Down
45 changes: 43 additions & 2 deletions src/app/components/ResponsiveMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,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 @@ -75,6 +77,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 +100,44 @@ export function ResponsiveMenu({
};

if (isMobile) {
if (mobile === 'inline-dialog') {
return (
<>
{children}
{anchor && (
<Box
style={{
position: 'fixed',
inset: 0,
zIndex: mobileZIndex ?? 2_147_483_647,
background: 'transparent',
}}
onClick={requestClose}
>
<Box
direction="Column"
role="dialog"
aria-modal="true"
style={{
width: 'fit-content',
maxWidth: 'calc(100vw - 2rem)',
maxHeight: '75vh',
overflow: 'auto',
borderRadius: '20px',
position: 'absolute',
top: Math.max(8, anchor.y + anchor.height + 8),
right: Math.max(8, window.innerWidth - anchor.x - anchor.width),
}}
onClick={(event) => event.stopPropagation()}
>
{menu}
</Box>
</Box>
)}
</>
);
}

const sheetStyle: CSSProperties | undefined = surfaceColor
? { backgroundColor: surfaceColor }
: undefined;
Expand All @@ -110,7 +151,7 @@ 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,
});
26 changes: 23 additions & 3 deletions 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,26 @@ 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('shows an error toast when downloading media fails', async () => {
const error = new Error('network unavailable');
downloadMedia.mockRejectedValue(error);
Expand All @@ -124,14 +144,14 @@ describe('ImageViewer', () => {
});
});

it('shows the Android gallery action for trusted image media', () => {
it('does not duplicate the Android download action in the overflow menu', () => {
mockPlatform('android');

renderViewer({ info: { mimetype: 'image/png' } });

fireEvent.contextMenu(screen.getByAltText('kitten.png'));

expect(screen.getByText('Save to Gallery')).toBeInTheDocument();
expect(screen.queryByText('Save to Gallery')).not.toBeInTheDocument();
});

it('labels the primary action Save to Photos on iOS without duplicating it in the overflow menu', () => {
Expand Down
Loading
Loading