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
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
2 changes: 1 addition & 1 deletion src/app/components/ResponsiveMenu.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { config, toRem } from 'folds';

export const DialogContent = style({
width: `min(90vw, ${toRem(400)})`,
maxHeight: '85vh',
maxHeight: '85dvh',
display: 'flex',
flexDirection: 'column',
});
Expand Down
97 changes: 92 additions & 5 deletions src/app/components/ResponsiveMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
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';
import { ScreenSize, useScreenSizeOptionally } from '$hooks/useScreenSize';
import { useCompactLayout } from '$hooks/useScreenSize';
import { stopPropagation } from '$utils/keyboard';
import { useDismissOnBack } from '$utils/androidBack';
import { MobileSheetFocusTrap, MobileSwipeDownModal } from './MobileSwipeDownModal';
Expand All @@ -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,9 +140,9 @@ 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;
const isMobile = useCompactLayout();

const isKeyForward = (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || (arrowNavigation === 'both' && evt.key === 'ArrowRight');
Expand All @@ -97,6 +162,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 +193,11 @@ export function ResponsiveMenu({
</MenuDialog>
)}
{anchor && mobile === 'sheet' && (
<MobileSwipeDownModal requestClose={requestClose} sheetStyle={sheetStyle}>
<MobileSwipeDownModal
requestClose={requestClose}
sheetStyle={sheetStyle}
zIndex={mobileZIndex}
>
{() => (
<MobileSheetFocusTrap
focusTrapOptions={{
Expand Down
110 changes: 110 additions & 0 deletions src/app/components/SwipeableMessageWrapper.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SwipeableMessageWrapper } from './SwipeableMessageWrapper';

vi.mock('$utils/platform', () => ({
isMobileOrTablet: () => true,
}));

vi.mock('$utils/haptics', () => ({
haptic: vi.fn<(kind?: 'light' | 'medium' | 'heavy' | 'selection') => void>(),
}));

const touchList = (target: HTMLElement, clientX: number, clientY: number) => {
const point = { identifier: 0, target, clientX, clientY, pageX: clientX, pageY: clientY };
return { touches: [point], targetTouches: [point], changedTouches: [point] };
};

// Rendered without MobileNavDrawerContext, which is the tablet and iPadOS-fullscreen
// case: no nav drawer coordinates the touch, so the message tracks it itself.
function renderWrapper(onReply: () => void) {
render(
<SwipeableMessageWrapper onReply={onReply}>
<div data-testid="content" />
</SwipeableMessageWrapper>
);
return screen.getByTestId('content').closest('[data-message-swipe]') as HTMLElement;
}

describe('SwipeableMessageWrapper without a nav drawer', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

it('replies after a leftward swipe past the threshold', () => {
const onReply = vi.fn<() => void>();
const container = renderWrapper(onReply);

fireEvent.touchStart(container, touchList(container, 200, 100));
fireEvent.touchMove(container, touchList(container, 100, 100));
fireEvent.touchEnd(container, {
...touchList(container, 100, 100),
touches: [],
targetTouches: [],
});

expect(onReply).toHaveBeenCalledOnce();
act(() => vi.advanceTimersByTime(220));
});

it('leaves a vertical scroll alone', () => {
const onReply = vi.fn<() => void>();
const container = renderWrapper(onReply);

fireEvent.touchStart(container, touchList(container, 200, 100));
fireEvent.touchMove(container, touchList(container, 205, 260));
fireEvent.touchEnd(container, {
...touchList(container, 205, 260),
touches: [],
targetTouches: [],
});

expect(onReply).not.toHaveBeenCalled();
});

it('does not reply on a rightward swipe', () => {
const onReply = vi.fn<() => void>();
const container = renderWrapper(onReply);

fireEvent.touchStart(container, touchList(container, 100, 100));
fireEvent.touchMove(container, touchList(container, 220, 100));
fireEvent.touchEnd(container, {
...touchList(container, 220, 100),
touches: [],
targetTouches: [],
});

expect(onReply).not.toHaveBeenCalled();
});

it('does not reply on a cancelled gesture', () => {
const onReply = vi.fn<() => void>();
const container = renderWrapper(onReply);

fireEvent.touchStart(container, touchList(container, 200, 100));
fireEvent.touchMove(container, touchList(container, 100, 100));
fireEvent.touchCancel(container, { touches: [], targetTouches: [] });

expect(onReply).not.toHaveBeenCalled();
});

it('does not reply when a second finger joins mid-gesture', () => {
const onReply = vi.fn<() => void>();
const container = renderWrapper(onReply);

fireEvent.touchStart(container, touchList(container, 200, 100));
fireEvent.touchMove(container, touchList(container, 100, 100));

const first = { identifier: 0, target: container, clientX: 100, clientY: 100 };
const second = { identifier: 1, target: container, clientX: 140, clientY: 140 };
fireEvent.touchStart(container, { touches: [first, second], targetTouches: [first, second] });
fireEvent.touchEnd(container, { touches: [], targetTouches: [] });

expect(onReply).not.toHaveBeenCalled();
});
});
Loading
Loading