Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
7b2e847
only load room members when needed and timeline transition when no da…
7w1 May 10, 2026
e42f6a3
fix double sw registration
7w1 May 10, 2026
24cffc8
prevent accidental audio embed when video and image fail on a preview…
7w1 May 10, 2026
8dadeb3
lazy load various modals & dm list
7w1 May 10, 2026
7cdcd35
more lazy loading
7w1 May 10, 2026
ee11d92
spaces lazy loading
7w1 May 10, 2026
2da69fd
swipeable overlay lazy loading
7w1 May 10, 2026
e195385
minimize sentry load (maybe) when disabled
7w1 May 10, 2026
edfe23d
cache space things
7w1 May 10, 2026
51d3b2f
defer mono font loading
7w1 May 10, 2026
8ec447f
memoize room listings
7w1 May 10, 2026
33e5298
cache search room parents
7w1 May 10, 2026
060d38d
combine cache with other places
7w1 May 10, 2026
3a0df9a
random router memo and fallbacks
7w1 May 10, 2026
8550792
add prefetching on hover to some tabs
7w1 May 10, 2026
27efca1
more prefetching
7w1 May 10, 2026
60183a9
more lazy loads
7w1 May 10, 2026
bfabab6
defer some essential features?
7w1 May 10, 2026
7362327
defer sentry
7w1 May 10, 2026
858d276
fix & refactor: rip apart timeline and attempt to fix scroll behaviors
7w1 May 10, 2026
9932d10
fix: timeline not staying at bottom on room load
7w1 May 10, 2026
9d876af
lint
7w1 May 10, 2026
a6189ac
refactor again: timeline is somewhat working now
7w1 May 11, 2026
8e0ba22
fix: scrolling after jumping
7w1 May 11, 2026
2713074
fix: sort of finally working now
7w1 May 11, 2026
58d614f
refactor: try to offload more back to virtua
7w1 May 11, 2026
279d9bd
fix: readd the patch for room load bottom clamping
7w1 May 11, 2026
58bc414
cleanup: remove debug stuff and knip complaints
7w1 May 11, 2026
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: 3 additions & 2 deletions src/app/components/DefaultErrorPage.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/app/components/RoomNotificationSwitcher.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ describe('RoomNotificationModeSwitcher', () => {
RoomNotificationMode.SpecialMessages,
RoomNotificationMode.Unset
);
});
}, 15000);

it('disables interaction while the room mode is changing', () => {
modeStateStatus.current = 'loading';
Expand Down
106 changes: 25 additions & 81 deletions src/app/components/SwipeableChatWrapper.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -19,94 +22,35 @@ 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 (
<div
style={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
height: '100%',
width: '100%',
}}
>
{children}
</div>
);
}

return (
const plainWrapper = (
<div
{...bind()}
style={{
touchAction: 'pan-y',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
height: '100%',
width: '100%',
}}
>
<motion.div
style={{
x: springX,
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
height: '100%',
}}
{children}
</div>
);

if (!settings.mobileGestures || !mobileOrTablet()) {
return plainWrapper;
}

return (
<Suspense fallback={plainWrapper}>
<SwipeableChatWrapperActive
settings={settings}
onOpenSidebar={onOpenSidebar}
onOpenMembers={onOpenMembers}
onReply={onReply}
>
{children}
</motion.div>
</div>
</SwipeableChatWrapperActive>
</Suspense>
);
}
93 changes: 93 additions & 0 deletions src/app/components/SwipeableChatWrapperActive.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
{...bind()}
style={{
touchAction: 'pan-y',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
height: '100%',
width: '100%',
}}
>
<motion.div
style={{
x: springX,
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
height: '100%',
}}
>
{children}
</motion.div>
</div>
);
}
85 changes: 14 additions & 71 deletions src/app/components/SwipeableMessageWrapper.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div {...bind()} style={{ position: 'relative', touchAction: 'pan-y' }}>
<div
style={{
position: 'absolute',
top: 0,
bottom: 0,
right: 0,
paddingRight: config.space.S400,
display: 'flex',
alignItems: 'center',
zIndex: 0,
}}
>
<motion.div style={{ opacity: iconOpacity }}>
<Icon
src={Icons.ReplyArrow}
size="400"
style={{
color: isReady
? 'var(--sable-surface-on-container)'
: 'var(--sable-surface-container)',
transition: 'color 0.2s',
}}
/>
</motion.div>
</div>
<motion.div style={{ x: springX, position: 'relative', zIndex: 1 }}>{children}</motion.div>
</div>
);
}
const SwipeableMessageWrapperActive = lazy(async () => {
const mod = await import('./SwipeableMessageWrapperActive');
return { default: mod.SwipeableMessageWrapperActive };
});

export function SwipeableMessageWrapper({
children,
Expand All @@ -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 <ActiveSwipeWrapper onReply={onReply}>{children}</ActiveSwipeWrapper>;
return (
<Suspense fallback={children}>
<SwipeableMessageWrapperActive onReply={onReply}>{children}</SwipeableMessageWrapperActive>
</Suspense>
);
}
Loading
Loading