diff --git a/src/app/components/DefaultErrorPage.tsx b/src/app/components/DefaultErrorPage.tsx
index 62042cef1..045b1ea50 100644
--- a/src/app/components/DefaultErrorPage.tsx
+++ b/src/app/components/DefaultErrorPage.tsx
@@ -1,7 +1,6 @@
import { Box, Button, Dialog, Icon, Icons, Text, color, config } from 'folds';
import * as Sentry from '@sentry/react';
import { SplashScreen } from '$components/splash-screen';
-import { buildGitHubUrl } from '$features/bug-report/BugReportModal';
type ErrorPageProps = {
error: Error;
@@ -25,7 +24,9 @@ ${error.message}
${stacktrace}
\`\`\``;
- return buildGitHubUrl('bug', `Error: ${error.message}`, { context: automatedBugReport });
+ const title = encodeURIComponent(`Error: ${error.message}`);
+ const body = encodeURIComponent(automatedBugReport);
+ return `https://github.com/SableClient/Sable/issues/new?template=bug_report.yml&title=${title}&description=${body}`;
}
// This component is used as the fallback for the ErrorBoundary in App.tsx, which means it will be rendered whenever an uncaught error is thrown in any of the child components and not handled locally.
diff --git a/src/app/components/RoomNotificationSwitcher.test.tsx b/src/app/components/RoomNotificationSwitcher.test.tsx
index bd0b7c04c..28bf44808 100644
--- a/src/app/components/RoomNotificationSwitcher.test.tsx
+++ b/src/app/components/RoomNotificationSwitcher.test.tsx
@@ -56,7 +56,7 @@ describe('RoomNotificationModeSwitcher', () => {
RoomNotificationMode.SpecialMessages,
RoomNotificationMode.Unset
);
- });
+ }, 15000);
it('disables interaction while the room mode is changing', () => {
modeStateStatus.current = 'loading';
diff --git a/src/app/components/SwipeableChatWrapper.tsx b/src/app/components/SwipeableChatWrapper.tsx
index d4a547298..4035d70b0 100644
--- a/src/app/components/SwipeableChatWrapper.tsx
+++ b/src/app/components/SwipeableChatWrapper.tsx
@@ -1,10 +1,13 @@
-import type { ReactNode } from 'react';
-import { motion, useMotionValue, useSpring } from 'framer-motion';
-import { useDrag } from '@use-gesture/react';
+import { lazy, Suspense, type ReactNode } from 'react';
import { useAtomValue } from 'jotai';
-import { settingsAtom, RightSwipeAction } from '$state/settings';
+import { settingsAtom } from '$state/settings';
import { mobileOrTablet } from '$utils/user-agent';
+const SwipeableChatWrapperActive = lazy(async () => {
+ const mod = await import('./SwipeableChatWrapperActive');
+ return { default: mod.SwipeableChatWrapperActive };
+});
+
interface SwipeableChatWrapperProps {
children: ReactNode;
onOpenSidebar?: () => void;
@@ -19,76 +22,10 @@ export function SwipeableChatWrapper({
onReply,
}: SwipeableChatWrapperProps) {
const settings = useAtomValue(settingsAtom);
- const x = useMotionValue(0);
- const springX = useSpring(x, { stiffness: 400, damping: 40 });
-
- const bind = useDrag(
- ({ active, movement: [mx], velocity: [vx], direction: [dx], event: e }) => {
- if (e && 'target' in e && e.target instanceof HTMLElement) {
- if (e.target.closest('[data-gestures="ignore"]')) {
- return;
- }
- }
-
- if (!settings.mobileGestures || !mobileOrTablet()) return;
-
- let val = mx;
-
- const canSwipeRight = !!onOpenSidebar;
- const canSwipeLeft =
- settings.rightSwipeAction === RightSwipeAction.Members ? !!onOpenMembers : !!onReply;
-
- if (!canSwipeRight && val > 0) val = 0;
- if (!canSwipeLeft && val < 0) val = 0;
- if (active) {
- x.set(val);
- } else {
- const swipeThreshold = 120;
- const velocityThreshold = 0.5;
-
- if (val > swipeThreshold || (vx > velocityThreshold && dx > 0 && val > 0)) {
- onOpenSidebar?.();
- } else if (val < -swipeThreshold || (vx > velocityThreshold && dx < 0 && val < 0)) {
- if (settings.rightSwipeAction === RightSwipeAction.Members) {
- onOpenMembers?.();
- } else {
- onReply?.();
- }
- }
- x.set(0);
- }
- },
- {
- axis: 'x',
- bounds: { left: -200, right: 200 },
- rubberband: true,
- filterTaps: true,
- }
- );
-
- if (!settings.mobileGestures || !mobileOrTablet()) {
- return (
-
- {children}
-
- );
- }
-
- return (
+ const plainWrapper = (
-
+ );
+
+ if (!settings.mobileGestures || !mobileOrTablet()) {
+ return plainWrapper;
+ }
+
+ return (
+
+
{children}
-
-
+
+
);
}
diff --git a/src/app/components/SwipeableChatWrapperActive.tsx b/src/app/components/SwipeableChatWrapperActive.tsx
new file mode 100644
index 000000000..dd7f9839b
--- /dev/null
+++ b/src/app/components/SwipeableChatWrapperActive.tsx
@@ -0,0 +1,93 @@
+import type { ReactNode } from 'react';
+import { motion, useMotionValue, useSpring } from 'framer-motion';
+import { useDrag } from '@use-gesture/react';
+import { RightSwipeAction, type Settings } from '$state/settings';
+
+interface SwipeableChatWrapperActiveProps {
+ children: ReactNode;
+ settings: Settings;
+ onOpenSidebar?: () => void;
+ onOpenMembers?: () => void;
+ onReply?: () => void;
+}
+
+export function SwipeableChatWrapperActive({
+ children,
+ settings,
+ onOpenSidebar,
+ onOpenMembers,
+ onReply,
+}: SwipeableChatWrapperActiveProps) {
+ const x = useMotionValue(0);
+ const springX = useSpring(x, { stiffness: 400, damping: 40 });
+
+ const bind = useDrag(
+ ({ active, movement: [mx], velocity: [vx], direction: [dx], event: e }) => {
+ if (e && 'target' in e && e.target instanceof HTMLElement) {
+ if (e.target.closest('[data-gestures="ignore"]')) {
+ return;
+ }
+ }
+
+ let val = mx;
+
+ const canSwipeRight = !!onOpenSidebar;
+ const canSwipeLeft =
+ settings.rightSwipeAction === RightSwipeAction.Members ? !!onOpenMembers : !!onReply;
+
+ if (!canSwipeRight && val > 0) val = 0;
+ if (!canSwipeLeft && val < 0) val = 0;
+
+ if (active) {
+ x.set(val);
+ } else {
+ const swipeThreshold = 120;
+ const velocityThreshold = 0.5;
+
+ if (val > swipeThreshold || (vx > velocityThreshold && dx > 0 && val > 0)) {
+ onOpenSidebar?.();
+ } else if (val < -swipeThreshold || (vx > velocityThreshold && dx < 0 && val < 0)) {
+ if (settings.rightSwipeAction === RightSwipeAction.Members) {
+ onOpenMembers?.();
+ } else {
+ onReply?.();
+ }
+ }
+ x.set(0);
+ }
+ },
+ {
+ axis: 'x',
+ bounds: { left: -200, right: 200 },
+ rubberband: true,
+ filterTaps: true,
+ }
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/app/components/SwipeableMessageWrapper.tsx b/src/app/components/SwipeableMessageWrapper.tsx
index 58fc9293e..0a7c75da4 100644
--- a/src/app/components/SwipeableMessageWrapper.tsx
+++ b/src/app/components/SwipeableMessageWrapper.tsx
@@ -1,70 +1,12 @@
-import { useMotionValue, useSpring, useTransform, motion } from 'framer-motion';
-import { useDrag } from '@use-gesture/react';
-import type { ReactNode } from 'react';
-import { useMemo, useState } from 'react';
+import { lazy, Suspense, type ReactNode } from 'react';
import { useAtomValue } from 'jotai';
-import { config, Icon, Icons } from 'folds';
import { mobileOrTablet } from '$utils/user-agent';
import { RightSwipeAction, settingsAtom } from '$state/settings';
-function ActiveSwipeWrapper({ children, onReply }: { children: ReactNode; onReply: () => void }) {
- const x = useMotionValue(0);
- const springX = useSpring(x, { stiffness: 300, damping: 35 });
- const [isReady, setIsReady] = useState(false);
- const iconOpacity = useTransform(x, [0, -8], [0, 1]);
-
- const bind = useDrag(
- ({ active, movement: [mx] }) => {
- if (active) {
- const val = mx < 0 ? mx : 0;
- x.set(Math.max(-80, val));
- if (mx < -50 !== isReady) setIsReady(mx < -50);
- } else {
- if (mx < -50) onReply();
- x.set(0);
- setIsReady(false);
- }
- },
- {
- axis: 'x',
- bounds: { right: 0 },
- rubberband: true,
- filterTaps: true,
- eventOptions: { passive: true },
- }
- );
-
- return (
-
- );
-}
+const SwipeableMessageWrapperActive = lazy(async () => {
+ const mod = await import('./SwipeableMessageWrapperActive');
+ return { default: mod.SwipeableMessageWrapperActive };
+});
export function SwipeableMessageWrapper({
children,
@@ -75,17 +17,18 @@ export function SwipeableMessageWrapper({
}) {
const settings = useAtomValue(settingsAtom);
- const isSwipeToReplyEnabled = useMemo(
- () =>
- settings.mobileGestures &&
- mobileOrTablet() &&
- settings.rightSwipeAction !== RightSwipeAction.Members,
- [settings.mobileGestures, settings.rightSwipeAction]
- );
+ const isSwipeToReplyEnabled =
+ settings.mobileGestures &&
+ mobileOrTablet() &&
+ settings.rightSwipeAction !== RightSwipeAction.Members;
if (!isSwipeToReplyEnabled) {
return children;
}
- return {children};
+ return (
+
+ {children}
+
+ );
}
diff --git a/src/app/components/SwipeableMessageWrapperActive.tsx b/src/app/components/SwipeableMessageWrapperActive.tsx
new file mode 100644
index 000000000..1f79458fd
--- /dev/null
+++ b/src/app/components/SwipeableMessageWrapperActive.tsx
@@ -0,0 +1,73 @@
+import type { ReactNode } from 'react';
+import { useMemo, useState } from 'react';
+import { useMotionValue, useSpring, useTransform, motion } from 'framer-motion';
+import { useDrag } from '@use-gesture/react';
+import { config, Icon, Icons } from 'folds';
+
+export function SwipeableMessageWrapperActive({
+ children,
+ onReply,
+}: {
+ children: ReactNode;
+ onReply: () => void;
+}) {
+ const x = useMotionValue(0);
+ const springX = useSpring(x, { stiffness: 300, damping: 35 });
+ const [isReady, setIsReady] = useState(false);
+ const iconOpacity = useTransform(x, [0, -8], [0, 1]);
+
+ const bind = useDrag(
+ ({ active, movement: [mx] }) => {
+ if (active) {
+ const val = mx < 0 ? mx : 0;
+ x.set(Math.max(-80, val));
+ if (mx < -50 !== isReady) setIsReady(mx < -50);
+ } else {
+ if (mx < -50) onReply();
+ x.set(0);
+ setIsReady(false);
+ }
+ },
+ {
+ axis: 'x',
+ bounds: { right: 0 },
+ rubberband: true,
+ filterTaps: true,
+ eventOptions: { passive: true },
+ }
+ );
+
+ const iconColor = useMemo(
+ () => (isReady ? 'var(--sable-surface-on-container)' : 'var(--sable-surface-container)'),
+ [isReady]
+ );
+
+ return (
+
+ );
+}
diff --git a/src/app/components/SwipeableOverlayWrapper.tsx b/src/app/components/SwipeableOverlayWrapper.tsx
index a77b802f5..daeadd5cf 100644
--- a/src/app/components/SwipeableOverlayWrapper.tsx
+++ b/src/app/components/SwipeableOverlayWrapper.tsx
@@ -1,10 +1,13 @@
-import type { ReactNode } from 'react';
-import { motion, useMotionValue, useSpring } from 'framer-motion';
-import { useDrag } from '@use-gesture/react';
+import { lazy, Suspense, type ReactNode } from 'react';
import { useAtomValue } from 'jotai';
import { settingsAtom } from '$state/settings';
import { mobileOrTablet } from '$utils/user-agent';
+const SwipeableOverlayWrapperActive = lazy(async () => {
+ const mod = await import('./SwipeableOverlayWrapperActive');
+ return { default: mod.SwipeableOverlayWrapperActive };
+});
+
interface SwipeableOverlayWrapperProps {
children: ReactNode;
onClose: () => void;
@@ -17,75 +20,10 @@ export function SwipeableOverlayWrapper({
direction,
}: SwipeableOverlayWrapperProps) {
const settings = useAtomValue(settingsAtom);
- const x = useMotionValue(0);
- const springX = useSpring(x, { stiffness: 400, damping: 40 });
-
- const bind = useDrag(
- ({ active, movement: [mx], velocity: [vx], direction: [dx], event, event: e }) => {
- if (e && 'target' in e && e.target instanceof HTMLElement) {
- if (e.target.closest('[data-gestures="ignore"]')) {
- return;
- }
- }
-
- if (!settings.mobileGestures || !mobileOrTablet()) return;
-
- event.stopPropagation();
-
- let val = mx;
-
- if (direction === 'left' && val > 0) val = 0;
- if (direction === 'right' && val < 0) val = 0;
-
- if (active) {
- x.set(val);
- } else {
- const swipeThreshold = 100;
- const velocityThreshold = 0.5;
-
- const swipedLeft =
- direction === 'left' && (val < -swipeThreshold || (vx > velocityThreshold && dx < 0));
- const swipedRight =
- direction === 'right' && (val > swipeThreshold || (vx > velocityThreshold && dx > 0));
-
- if (swipedLeft || swipedRight) {
- onClose();
- }
-
- x.set(0);
- }
- },
- {
- axis: 'x',
- bounds: direction === 'left' ? { left: -300, right: 0 } : { left: 0, right: 300 },
- rubberband: true,
- filterTaps: true,
- pointer: { capture: true },
- }
- );
-
- if (!settings.mobileGestures || !mobileOrTablet()) {
- return (
-
- {children}
-
- );
- }
- return (
+ const plainWrapper = (
-
- {children}
-
+ {children}
);
+
+ if (!settings.mobileGestures || !mobileOrTablet()) {
+ return plainWrapper;
+ }
+
+ return (
+
+
+ {children}
+
+
+ );
}
diff --git a/src/app/components/SwipeableOverlayWrapperActive.tsx b/src/app/components/SwipeableOverlayWrapperActive.tsx
new file mode 100644
index 000000000..5c117358b
--- /dev/null
+++ b/src/app/components/SwipeableOverlayWrapperActive.tsx
@@ -0,0 +1,87 @@
+import type { ReactNode } from 'react';
+import { motion, useMotionValue, useSpring } from 'framer-motion';
+import { useDrag } from '@use-gesture/react';
+
+interface SwipeableOverlayWrapperActiveProps {
+ children: ReactNode;
+ onClose: () => void;
+ direction: 'left' | 'right';
+}
+
+export function SwipeableOverlayWrapperActive({
+ children,
+ onClose,
+ direction,
+}: SwipeableOverlayWrapperActiveProps) {
+ const x = useMotionValue(0);
+ const springX = useSpring(x, { stiffness: 400, damping: 40 });
+
+ const bind = useDrag(
+ ({ active, movement: [mx], velocity: [vx], direction: [dx], event, event: e }) => {
+ if (e && 'target' in e && e.target instanceof HTMLElement) {
+ if (e.target.closest('[data-gestures="ignore"]')) {
+ return;
+ }
+ }
+
+ event.stopPropagation();
+
+ let val = mx;
+
+ if (direction === 'left' && val > 0) val = 0;
+ if (direction === 'right' && val < 0) val = 0;
+
+ if (active) {
+ x.set(val);
+ } else {
+ const swipeThreshold = 100;
+ const velocityThreshold = 0.5;
+
+ const swipedLeft =
+ direction === 'left' && (val < -swipeThreshold || (vx > velocityThreshold && dx < 0));
+ const swipedRight =
+ direction === 'right' && (val > swipeThreshold || (vx > velocityThreshold && dx > 0));
+
+ if (swipedLeft || swipedRight) {
+ onClose();
+ }
+
+ x.set(0);
+ }
+ },
+ {
+ axis: 'x',
+ bounds: direction === 'left' ? { left: -300, right: 0 } : { left: 0, right: 300 },
+ rubberband: true,
+ filterTaps: true,
+ pointer: { capture: true },
+ }
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/app/components/UserRoomProfileRenderer.test.tsx b/src/app/components/UserRoomProfileRenderer.test.tsx
new file mode 100644
index 000000000..6f2f3a1cb
--- /dev/null
+++ b/src/app/components/UserRoomProfileRenderer.test.tsx
@@ -0,0 +1,57 @@
+import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
+import { render } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { UserRoomProfileRenderer } from './UserRoomProfileRenderer';
+
+const mocks = vi.hoisted(() => ({
+ state: {
+ roomId: '!room:example.org',
+ userId: '@alice:example.org',
+ cords: new DOMRect(0, 0, 1, 1),
+ },
+ room: {
+ roomId: '!room:example.org',
+ },
+}));
+
+vi.mock('folds', () => ({
+ Menu: forwardRef>(
+ ({ children, ...props }, ref) => (
+
+ {children}
+
+ )
+ ),
+ PopOut: vi.fn<({ content }: { content: ReactNode }) => ReactNode>(({ content }) => (
+ {content}
+ )),
+ toRem: vi.fn<(value: number) => string>((value) => `${value / 16}rem`),
+}));
+
+vi.mock('$state/hooks/userRoomProfile', () => ({
+ useCloseUserRoomProfile: () => vi.fn<() => void>(),
+ useUserRoomProfileState: () => mocks.state,
+}));
+
+vi.mock('$hooks/useGetRoom', () => ({
+ useAllJoinedRoomsSet: () => new Set([mocks.room.roomId]),
+ useGetRoom: () => (roomId: string) => (roomId === mocks.room.roomId ? mocks.room : undefined),
+}));
+
+vi.mock('$hooks/useSpace', () => ({
+ SpaceProvider: ({ children }: { children: ReactNode }) => <>{children}>,
+}));
+
+vi.mock('$hooks/useRoom', () => ({
+ RoomProvider: ({ children }: { children: ReactNode }) => <>{children}>,
+}));
+
+vi.mock('./user-profile', () => ({
+ UserRoomProfile: () => ,
+}));
+
+describe('UserRoomProfileRenderer', () => {
+ it('does not throw while lazy profile content is loading inside the focus trap', () => {
+ expect(() => render()).not.toThrow();
+ });
+});
diff --git a/src/app/components/UserRoomProfileRenderer.tsx b/src/app/components/UserRoomProfileRenderer.tsx
index 866bb6b0f..4e07e1270 100644
--- a/src/app/components/UserRoomProfileRenderer.tsx
+++ b/src/app/components/UserRoomProfileRenderer.tsx
@@ -1,3 +1,4 @@
+import { lazy, Suspense, useRef } from 'react';
import { Menu, PopOut, toRem } from 'folds';
import FocusTrap from 'focus-trap-react';
import { useCloseUserRoomProfile, useUserRoomProfileState } from '$state/hooks/userRoomProfile';
@@ -6,9 +7,14 @@ import { useAllJoinedRoomsSet, useGetRoom } from '$hooks/useGetRoom';
import { stopPropagation } from '$utils/keyboard';
import { SpaceProvider } from '$hooks/useSpace';
import { RoomProvider } from '$hooks/useRoom';
-import { UserRoomProfile } from './user-profile';
+
+const UserRoomProfile = lazy(async () => {
+ const mod = await import('./user-profile');
+ return { default: mod.UserRoomProfile };
+});
function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) {
+ const menuRef = useRef(null);
const { roomId, spaceId, userId, cords, position, initialProfile } = state;
const allJoinedRooms = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allJoinedRooms);
@@ -28,15 +34,18 @@ function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState })
menuRef.current ?? document.body,
onDeactivate: close,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
-