Skip to content
Merged
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
@@ -0,0 +1,5 @@
---
default: minor
---

Allow swiping away in-app notifications on mobile
80 changes: 63 additions & 17 deletions src/app/components/notification-banner/NotificationBanner.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,42 @@ const slideIn = keyframes({
},
});

const slideOut = keyframes({
const fadeOut = keyframes({
from: {
opacity: 1,
transform: 'translateY(0)',
},
to: {
opacity: 0,
},
});

const slideOut = keyframes({
from: {
transform: 'translateY(0)',
},
to: {
transform: 'translateY(-100%)',
},
});

const swipeOutLeft = keyframes({
from: {
transform: 'translateX(0)',
},
to: {
transform: 'translateX(-100%)',
},
});

const swipeOutRight = keyframes({
from: {
transform: 'translateX(0)',
},
to: {
transform: 'translateX(100%)',
},
});

// Positions at the top of the viewport, spanning full width.
// Uses fixed positioning with safe-area-inset to handle iOS keyboard correctly.
// On iOS, the banner stays at the top of the visual viewport even when keyboard is open.
Expand Down Expand Up @@ -54,10 +79,44 @@ export const BannerContainer = style({
},
});

export const BannerWrapper = style({
pointerEvents: 'all',
cursor: 'pointer',
width: '100%',
maxWidth: toRem(420),
animationName: slideIn,
animationDuration: '260ms',
animationTimingFunction: 'cubic-bezier(0.22, 0.8, 0.6, 1)',
animationFillMode: 'backwards',
transitionProperty: 'transform',
transitionDuration: '200ms',
transitionTimingFunction: 'ease-out',

selectors: {
'&[data-dismissing=up], &[data-dismissing=left], &[data-dismissing=right]': {
animationDuration: '200ms',
animationTimingFunction: 'cubic-bezier(0.4, 0, 1, 1)',
animationFillMode: 'forwards',
animationComposition: 'accumulate, replace',
},
'&[data-dismissing=up]': {
animationName: `${slideOut}, ${fadeOut}`,
},
'&[data-dismissing=left]': {
animationName: `${swipeOutLeft}, ${fadeOut}`,
},
'&[data-dismissing=right]': {
animationName: `${swipeOutRight}, ${fadeOut}`,
},
'&[data-swiping=true]': {
transitionProperty: 'none',
},
},
});

export const Banner = style({
position: 'relative',
overflow: 'hidden',
pointerEvents: 'all',
display: 'flex',
alignItems: 'center',
gap: config.space.S300,
Expand All @@ -67,24 +126,11 @@ export const Banner = style({
borderRadius: toRem(16),
padding: `${config.space.S300} ${config.space.S400}`,
boxShadow: `0 ${toRem(8)} ${toRem(32)} rgba(0, 0, 0, 0.45), 0 ${toRem(2)} ${toRem(8)} rgba(0, 0, 0, 0.3)`,
cursor: 'pointer',
width: '100%',
maxWidth: toRem(420),
animationName: slideIn,
animationDuration: '260ms',
animationTimingFunction: 'cubic-bezier(0.22, 0.8, 0.6, 1)',
animationFillMode: 'both',

selectors: {
'&:hover': {
':hover > &': {
backgroundColor: color.Surface.ContainerHover,
},
'&[data-dismissing=true]': {
animationName: slideOut,
animationDuration: '200ms',
animationTimingFunction: 'cubic-bezier(0.4, 0, 1, 1)',
animationFillMode: 'both',
},
},
});

Expand Down
211 changes: 142 additions & 69 deletions src/app/components/notification-banner/NotificationBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useAtom } from 'jotai';
import type { TouchEvent } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Box, IconButton, Text } from 'folds';
import { sizedIcon, X } from '$components/icons/phosphor';
Expand All @@ -12,6 +13,7 @@ import * as css from './NotificationBanner.css';

const log = createLogger('NotificationBanner');
const BANNER_DURATION_MS = 5000;
const DISMISS_SWIPE_DISTANCE = 150;

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

type DismissDirection = 'up' | 'left' | 'right';

function BannerItem({ notification, onDismiss }: BannerItemProps) {
const [dismissing, setDismissing] = useState(false);
const [dismissing, setDismissing] = useState<DismissDirection | undefined>();
const [paused, setPaused] = useState(false);
const dismissedRef = useRef(false);
const dismissAnimTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const elapsedRef = useRef(0);

const [gesture, setGesture] = useState<{ startX: number; startY: number } | undefined>();
const [swipeDistance, setSwipeDistance] = useState(0);

// Use a ref to guard against double-dismiss without creating a new callback identity.
const dismiss = useCallback(() => {
if (dismissedRef.current) return;
dismissedRef.current = true;
setDismissing(true);
dismissAnimTimerRef.current = setTimeout(() => onDismiss(notification.id), 200);
}, [notification.id, onDismiss]);
const dismiss = useCallback(
(direction: DismissDirection) => {
if (dismissing) return;
setDismissing(direction);
dismissAnimTimerRef.current = setTimeout(() => onDismiss(notification.id), 200);
},
[notification.id, onDismiss, dismissing]
);

// Auto-dismiss timer  Eonly runs when not paused.
useEffect(() => {
if (paused) return undefined;
const remaining = BANNER_DURATION_MS - elapsedRef.current;
if (remaining <= 0) {
dismiss();
dismiss('up');
return undefined;
}
const startedAt = Date.now();
const t = setTimeout(dismiss, remaining);
const t = setTimeout(() => dismiss('up'), remaining);
return () => {
clearTimeout(t);
// Accumulate time spent un-paused so we can resume from the right point.
Expand All @@ -104,77 +112,142 @@ function BannerItem({ notification, onDismiss }: BannerItemProps) {

const handleClick = () => {
notification.onClick();
dismiss();
dismiss('up');
};

// When hovering, pause the auto-dismiss timer.
const handleMouseEnter = () => setPaused(true);
const handleMouseLeave = () => setPaused(false);
const handleMouseEnter = useCallback(() => setPaused(true), []);
const handleMouseLeave = useCallback(() => setPaused(false), []);

const release = useCallback(
(commit: boolean) => {
setPaused(false);

if (commit && Math.abs(swipeDistance) > DISMISS_SWIPE_DISTANCE) {
// Continue off the side of the screen
dismiss(swipeDistance > 0 ? 'right' : 'left');
} else {
// Spring back to center
setSwipeDistance(0);
setGesture(undefined);
}
},
[dismiss, swipeDistance]
);

const handleTouchStart = useCallback(
(event: TouchEvent) => {
const touch = event.touches[0];
if (!touch || event.touches.length !== 1) {
release(false);
return;
}

setPaused(true);

setGesture({
startX: touch.clientX,
startY: touch.clientY,
});
},
[release]
);
const handleTouchMove = useCallback(
(event: TouchEvent) => {
if (dismissing) return;
const touch = event.touches[0];
if (!gesture || !touch) return;

setSwipeDistance(touch.clientX - gesture.startX);
},
[dismissing, gesture]
);
const handleTouchEnd = useCallback(() => {
if (dismissing) return;
release(true);
}, [dismissing, release]);
const handleTouchCancel = useCallback(() => {
if (dismissing) return;
release(false);
}, [dismissing, release]);

return (
<div
className={css.Banner}
className={css.BannerWrapper}
data-dismissing={dismissing}
onClick={handleClick}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') handleClick();
if (e.key === 'Escape') dismiss();
}}
data-swiping={gesture !== undefined}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchCancel={handleTouchCancel}
onTouchEnd={handleTouchEnd}
style={{
transform: `translateX(${swipeDistance}px)`,
willChange: 'transform',
}}
>
{!notification.event && notification.icon && (
<img
src={notification.icon}
alt=""
className={css.BannerIcon}
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
)}
<div className={css.BannerContent}>
{notification.room && notification.event ? (
<BannerMessage notification={notification} />
) : (
<>
<Text size="T300" truncate className={css.BannerTitle}>
{notification.senderName ?? notification.title}
{(notification.roomName || notification.serverName) && (
<span className={css.BannerSubtitle}>
{' ('}
{notification.roomName && `#${notification.roomName}`}
{notification.roomName && notification.serverName && ', '}
{notification.serverName})
</span>
)}
</Text>
{notification.body && <BodyText text={notification.body} hovered={paused} />}
</>
<div
className={css.Banner}
onClick={handleClick}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') handleClick();
if (e.key === 'Escape') dismiss('up');
}}
>
{!notification.event && notification.icon && (
<img
src={notification.icon}
alt=""
className={css.BannerIcon}
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
)}
<div className={css.BannerContent}>
{notification.room && notification.event ? (
<BannerMessage notification={notification} />
) : (
<>
<Text size="T300" truncate className={css.BannerTitle}>
{notification.senderName ?? notification.title}
{(notification.roomName || notification.serverName) && (
<span className={css.BannerSubtitle}>
{' ('}
{notification.roomName && `#${notification.roomName}`}
{notification.roomName && notification.serverName && ', '}
{notification.serverName})
</span>
)}
</Text>
{notification.body && <BodyText text={notification.body} hovered={paused} />}
</>
)}
</div>
<Box shrink="No">
<IconButton
size="300"
variant="Surface"
fill="None"
radii="300"
onClick={(e) => {
e.stopPropagation();
dismiss('up');
}}
aria-label="Dismiss notification"
>
{sizedIcon(X, '100')}
</IconButton>
</Box>
<div
className={css.ProgressBar}
data-paused={paused}
style={{ animationDuration: `${BANNER_DURATION_MS}ms` }}
/>
</div>
<Box shrink="No">
<IconButton
size="300"
variant="Surface"
fill="None"
radii="300"
onClick={(e) => {
e.stopPropagation();
dismiss();
}}
aria-label="Dismiss notification"
>
{sizedIcon(X, '100')}
</IconButton>
</Box>
<div
className={css.ProgressBar}
data-paused={paused}
style={{ animationDuration: `${BANNER_DURATION_MS - elapsedRef.current}ms` }}
/>
</div>
);
}
Expand Down
Loading