Skip to content

Commit 84303e5

Browse files
authored
feat(mobile): allow swiping notifications away (#1863)
<!-- Please read https://github.com/SableClient/Sable/blob/dev/CONTRIBUTING.md before submitting your pull request --> ### Description Allow swiping notifications away to either side on mobile. [Screencast From 2026-08-18 22-52-06.webm](https://github.com/user-attachments/assets/fcc2db1d-d1da-4989-99aa-ecc1e07079f3) Closes #1842 #### Type of change - [ ] Bug fix (non-breaking change which fixes an issue) - [X] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] This change requires a documentation update ### Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings ### AI disclosure: - [ ] Partially AI assisted (clarify which code was AI assisted and briefly explain what it does). - [ ] Fully AI generated (explain what all the generated code does in moderate detail). <!-- Write any explanation required here, but do not generate the explanation using AI!! You must prove you understand what the code in this PR does. -->
2 parents 2a6c396 + bb7d9e2 commit 84303e5

3 files changed

Lines changed: 210 additions & 86 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
default: minor
3+
---
4+
5+
Allow swiping away in-app notifications on mobile

src/app/components/notification-banner/NotificationBanner.css.ts

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,42 @@ const slideIn = keyframes({
1313
},
1414
});
1515

16-
const slideOut = keyframes({
16+
const fadeOut = keyframes({
1717
from: {
1818
opacity: 1,
19-
transform: 'translateY(0)',
2019
},
2120
to: {
2221
opacity: 0,
22+
},
23+
});
24+
25+
const slideOut = keyframes({
26+
from: {
27+
transform: 'translateY(0)',
28+
},
29+
to: {
2330
transform: 'translateY(-100%)',
2431
},
2532
});
2633

34+
const swipeOutLeft = keyframes({
35+
from: {
36+
transform: 'translateX(0)',
37+
},
38+
to: {
39+
transform: 'translateX(-100%)',
40+
},
41+
});
42+
43+
const swipeOutRight = keyframes({
44+
from: {
45+
transform: 'translateX(0)',
46+
},
47+
to: {
48+
transform: 'translateX(100%)',
49+
},
50+
});
51+
2752
// Positions at the top of the viewport, spanning full width.
2853
// Uses fixed positioning with safe-area-inset to handle iOS keyboard correctly.
2954
// On iOS, the banner stays at the top of the visual viewport even when keyboard is open.
@@ -54,10 +79,44 @@ export const BannerContainer = style({
5479
},
5580
});
5681

82+
export const BannerWrapper = style({
83+
pointerEvents: 'all',
84+
cursor: 'pointer',
85+
width: '100%',
86+
maxWidth: toRem(420),
87+
animationName: slideIn,
88+
animationDuration: '260ms',
89+
animationTimingFunction: 'cubic-bezier(0.22, 0.8, 0.6, 1)',
90+
animationFillMode: 'backwards',
91+
transitionProperty: 'transform',
92+
transitionDuration: '200ms',
93+
transitionTimingFunction: 'ease-out',
94+
95+
selectors: {
96+
'&[data-dismissing=up], &[data-dismissing=left], &[data-dismissing=right]': {
97+
animationDuration: '200ms',
98+
animationTimingFunction: 'cubic-bezier(0.4, 0, 1, 1)',
99+
animationFillMode: 'forwards',
100+
animationComposition: 'accumulate, replace',
101+
},
102+
'&[data-dismissing=up]': {
103+
animationName: `${slideOut}, ${fadeOut}`,
104+
},
105+
'&[data-dismissing=left]': {
106+
animationName: `${swipeOutLeft}, ${fadeOut}`,
107+
},
108+
'&[data-dismissing=right]': {
109+
animationName: `${swipeOutRight}, ${fadeOut}`,
110+
},
111+
'&[data-swiping=true]': {
112+
transitionProperty: 'none',
113+
},
114+
},
115+
});
116+
57117
export const Banner = style({
58118
position: 'relative',
59119
overflow: 'hidden',
60-
pointerEvents: 'all',
61120
display: 'flex',
62121
alignItems: 'center',
63122
gap: config.space.S300,
@@ -67,24 +126,11 @@ export const Banner = style({
67126
borderRadius: toRem(16),
68127
padding: `${config.space.S300} ${config.space.S400}`,
69128
boxShadow: `0 ${toRem(8)} ${toRem(32)} rgba(0, 0, 0, 0.45), 0 ${toRem(2)} ${toRem(8)} rgba(0, 0, 0, 0.3)`,
70-
cursor: 'pointer',
71-
width: '100%',
72-
maxWidth: toRem(420),
73-
animationName: slideIn,
74-
animationDuration: '260ms',
75-
animationTimingFunction: 'cubic-bezier(0.22, 0.8, 0.6, 1)',
76-
animationFillMode: 'both',
77129

78130
selectors: {
79-
'&:hover': {
131+
':hover > &': {
80132
backgroundColor: color.Surface.ContainerHover,
81133
},
82-
'&[data-dismissing=true]': {
83-
animationName: slideOut,
84-
animationDuration: '200ms',
85-
animationTimingFunction: 'cubic-bezier(0.4, 0, 1, 1)',
86-
animationFillMode: 'both',
87-
},
88134
},
89135
});
90136

src/app/components/notification-banner/NotificationBanner.tsx

Lines changed: 142 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useAtom } from 'jotai';
2+
import type { TouchEvent } from 'react';
23
import { useCallback, useEffect, useRef, useState } from 'react';
34
import { Box, IconButton, Text } from 'folds';
45
import { sizedIcon, X } from '$components/icons/phosphor';
@@ -12,6 +13,7 @@ import * as css from './NotificationBanner.css';
1213

1314
const log = createLogger('NotificationBanner');
1415
const BANNER_DURATION_MS = 5000;
16+
const DISMISS_SWIPE_DISTANCE = 150;
1517

1618
// Renders body text capped at a max height with a gradient fade when it overflows.
1719
function BodyText({ text, hovered }: { text: string; hovered: boolean }) {
@@ -63,31 +65,37 @@ function BannerMessage({ notification }: { notification: InAppBannerNotification
6365
);
6466
}
6567

68+
type DismissDirection = 'up' | 'left' | 'right';
69+
6670
function BannerItem({ notification, onDismiss }: BannerItemProps) {
67-
const [dismissing, setDismissing] = useState(false);
71+
const [dismissing, setDismissing] = useState<DismissDirection | undefined>();
6872
const [paused, setPaused] = useState(false);
69-
const dismissedRef = useRef(false);
7073
const dismissAnimTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
7174
const elapsedRef = useRef(0);
7275

76+
const [gesture, setGesture] = useState<{ startX: number; startY: number } | undefined>();
77+
const [swipeDistance, setSwipeDistance] = useState(0);
78+
7379
// Use a ref to guard against double-dismiss without creating a new callback identity.
74-
const dismiss = useCallback(() => {
75-
if (dismissedRef.current) return;
76-
dismissedRef.current = true;
77-
setDismissing(true);
78-
dismissAnimTimerRef.current = setTimeout(() => onDismiss(notification.id), 200);
79-
}, [notification.id, onDismiss]);
80+
const dismiss = useCallback(
81+
(direction: DismissDirection) => {
82+
if (dismissing) return;
83+
setDismissing(direction);
84+
dismissAnimTimerRef.current = setTimeout(() => onDismiss(notification.id), 200);
85+
},
86+
[notification.id, onDismiss, dismissing]
87+
);
8088

8189
// Auto-dismiss timer  Eonly runs when not paused.
8290
useEffect(() => {
8391
if (paused) return undefined;
8492
const remaining = BANNER_DURATION_MS - elapsedRef.current;
8593
if (remaining <= 0) {
86-
dismiss();
94+
dismiss('up');
8795
return undefined;
8896
}
8997
const startedAt = Date.now();
90-
const t = setTimeout(dismiss, remaining);
98+
const t = setTimeout(() => dismiss('up'), remaining);
9199
return () => {
92100
clearTimeout(t);
93101
// Accumulate time spent un-paused so we can resume from the right point.
@@ -104,77 +112,142 @@ function BannerItem({ notification, onDismiss }: BannerItemProps) {
104112

105113
const handleClick = () => {
106114
notification.onClick();
107-
dismiss();
115+
dismiss('up');
108116
};
109117

110118
// When hovering, pause the auto-dismiss timer.
111-
const handleMouseEnter = () => setPaused(true);
112-
const handleMouseLeave = () => setPaused(false);
119+
const handleMouseEnter = useCallback(() => setPaused(true), []);
120+
const handleMouseLeave = useCallback(() => setPaused(false), []);
121+
122+
const release = useCallback(
123+
(commit: boolean) => {
124+
setPaused(false);
125+
126+
if (commit && Math.abs(swipeDistance) > DISMISS_SWIPE_DISTANCE) {
127+
// Continue off the side of the screen
128+
dismiss(swipeDistance > 0 ? 'right' : 'left');
129+
} else {
130+
// Spring back to center
131+
setSwipeDistance(0);
132+
setGesture(undefined);
133+
}
134+
},
135+
[dismiss, swipeDistance]
136+
);
137+
138+
const handleTouchStart = useCallback(
139+
(event: TouchEvent) => {
140+
const touch = event.touches[0];
141+
if (!touch || event.touches.length !== 1) {
142+
release(false);
143+
return;
144+
}
145+
146+
setPaused(true);
147+
148+
setGesture({
149+
startX: touch.clientX,
150+
startY: touch.clientY,
151+
});
152+
},
153+
[release]
154+
);
155+
const handleTouchMove = useCallback(
156+
(event: TouchEvent) => {
157+
if (dismissing) return;
158+
const touch = event.touches[0];
159+
if (!gesture || !touch) return;
160+
161+
setSwipeDistance(touch.clientX - gesture.startX);
162+
},
163+
[dismissing, gesture]
164+
);
165+
const handleTouchEnd = useCallback(() => {
166+
if (dismissing) return;
167+
release(true);
168+
}, [dismissing, release]);
169+
const handleTouchCancel = useCallback(() => {
170+
if (dismissing) return;
171+
release(false);
172+
}, [dismissing, release]);
113173

114174
return (
115175
<div
116-
className={css.Banner}
176+
className={css.BannerWrapper}
117177
data-dismissing={dismissing}
118-
onClick={handleClick}
119-
role="button"
120-
tabIndex={0}
121-
onKeyDown={(e) => {
122-
if (e.key === 'Enter' || e.key === ' ') handleClick();
123-
if (e.key === 'Escape') dismiss();
124-
}}
178+
data-swiping={gesture !== undefined}
125179
onMouseEnter={handleMouseEnter}
126180
onMouseLeave={handleMouseLeave}
181+
onTouchStart={handleTouchStart}
182+
onTouchMove={handleTouchMove}
183+
onTouchCancel={handleTouchCancel}
184+
onTouchEnd={handleTouchEnd}
185+
style={{
186+
transform: `translateX(${swipeDistance}px)`,
187+
willChange: 'transform',
188+
}}
127189
>
128-
{!notification.event && notification.icon && (
129-
<img
130-
src={notification.icon}
131-
alt=""
132-
className={css.BannerIcon}
133-
onError={(e) => {
134-
(e.currentTarget as HTMLImageElement).style.display = 'none';
135-
}}
136-
/>
137-
)}
138-
<div className={css.BannerContent}>
139-
{notification.room && notification.event ? (
140-
<BannerMessage notification={notification} />
141-
) : (
142-
<>
143-
<Text size="T300" truncate className={css.BannerTitle}>
144-
{notification.senderName ?? notification.title}
145-
{(notification.roomName || notification.serverName) && (
146-
<span className={css.BannerSubtitle}>
147-
{' ('}
148-
{notification.roomName && `#${notification.roomName}`}
149-
{notification.roomName && notification.serverName && ', '}
150-
{notification.serverName})
151-
</span>
152-
)}
153-
</Text>
154-
{notification.body && <BodyText text={notification.body} hovered={paused} />}
155-
</>
190+
<div
191+
className={css.Banner}
192+
onClick={handleClick}
193+
role="button"
194+
tabIndex={0}
195+
onKeyDown={(e) => {
196+
if (e.key === 'Enter' || e.key === ' ') handleClick();
197+
if (e.key === 'Escape') dismiss('up');
198+
}}
199+
>
200+
{!notification.event && notification.icon && (
201+
<img
202+
src={notification.icon}
203+
alt=""
204+
className={css.BannerIcon}
205+
onError={(e) => {
206+
(e.currentTarget as HTMLImageElement).style.display = 'none';
207+
}}
208+
/>
156209
)}
210+
<div className={css.BannerContent}>
211+
{notification.room && notification.event ? (
212+
<BannerMessage notification={notification} />
213+
) : (
214+
<>
215+
<Text size="T300" truncate className={css.BannerTitle}>
216+
{notification.senderName ?? notification.title}
217+
{(notification.roomName || notification.serverName) && (
218+
<span className={css.BannerSubtitle}>
219+
{' ('}
220+
{notification.roomName && `#${notification.roomName}`}
221+
{notification.roomName && notification.serverName && ', '}
222+
{notification.serverName})
223+
</span>
224+
)}
225+
</Text>
226+
{notification.body && <BodyText text={notification.body} hovered={paused} />}
227+
</>
228+
)}
229+
</div>
230+
<Box shrink="No">
231+
<IconButton
232+
size="300"
233+
variant="Surface"
234+
fill="None"
235+
radii="300"
236+
onClick={(e) => {
237+
e.stopPropagation();
238+
dismiss('up');
239+
}}
240+
aria-label="Dismiss notification"
241+
>
242+
{sizedIcon(X, '100')}
243+
</IconButton>
244+
</Box>
245+
<div
246+
className={css.ProgressBar}
247+
data-paused={paused}
248+
style={{ animationDuration: `${BANNER_DURATION_MS}ms` }}
249+
/>
157250
</div>
158-
<Box shrink="No">
159-
<IconButton
160-
size="300"
161-
variant="Surface"
162-
fill="None"
163-
radii="300"
164-
onClick={(e) => {
165-
e.stopPropagation();
166-
dismiss();
167-
}}
168-
aria-label="Dismiss notification"
169-
>
170-
{sizedIcon(X, '100')}
171-
</IconButton>
172-
</Box>
173-
<div
174-
className={css.ProgressBar}
175-
data-paused={paused}
176-
style={{ animationDuration: `${BANNER_DURATION_MS - elapsedRef.current}ms` }}
177-
/>
178251
</div>
179252
);
180253
}

0 commit comments

Comments
 (0)