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: patch
---

Fix DM notifications, encrypted event notifications, and enable reaction notifications
18 changes: 16 additions & 2 deletions src/app/components/notification-banner/NotificationBanner.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,14 @@ const slideOut = keyframes({
},
});

// Floats at the top of the viewport, spanning full width on all platforms.
// 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.
export const BannerContainer = style({
position: 'fixed',
top: 0,
// Use env(safe-area-inset-top) to respect device-specific safe areas (notches, etc)
// This also helps position correctly on iOS when the keyboard is open
top: 'env(safe-area-inset-top, 0)',
left: 0,
right: 0,
zIndex: 9999,
Expand All @@ -36,6 +40,16 @@ export const BannerContainer = style({
padding: config.space.S400,
pointerEvents: 'none',
alignItems: 'stretch',

// On iOS, when keyboard opens, ensure banner stays visible at top of visual viewport
'@supports': {
'(-webkit-touch-callout: none)': {
// iOS-specific: Position relative to the visible viewport when keyboard is open
position: 'fixed',
// Support both old and new safe area syntax
top: 'max(env(safe-area-inset-top, 0px), constant(safe-area-inset-top, 0px))',
},
},
});

export const Banner = style({
Expand Down
56 changes: 53 additions & 3 deletions src/app/components/notification-banner/NotificationBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { useAtom } from 'jotai';
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { Box, Icon, IconButton, Icons, Text } from 'folds';
import { createLogger } from '$utils/debug';
import { inAppBannerAtom, InAppBannerNotification } from '$state/sessions';
import * as css from './NotificationBanner.css';

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

// Renders body text capped at a max height with a gradient fade when it overflows.
Expand Down Expand Up @@ -173,29 +175,77 @@ export function NotificationBanner() {
// We store an array locally so multiple rapid notifications stack briefly.
const [banner, setBanner] = useAtom(inAppBannerAtom);
const [queue, setQueue] = useState<InAppBannerNotification[]>([]);
const containerRef = useRef<HTMLDivElement>(null);

log.log('[Banner] Component render, queue length:', queue.length, 'banner:', banner);

// Adjust banner position for iOS keyboard
useEffect(() => {
// Only apply on iOS/browsers that support visualViewport
if (!('visualViewport' in window)) return undefined;

const updatePosition = () => {
const container = containerRef.current;
if (!container) return;

const visualViewport = window.visualViewport!;
// Calculate how much of the screen is covered by the keyboard
// When keyboard opens, visualViewport.height shrinks
const keyboardHeight = window.innerHeight - visualViewport.height;

// Position the banner down by the keyboard height so it appears at the top of the visible area
// This puts it "halfway down the page" when keyboard covers half the screen
if (keyboardHeight > 0) {
container.style.top = `${keyboardHeight}px`;
} else {
// Reset to CSS default (env(safe-area-inset-top))
container.style.top = '';
}
};

const visualViewport = window.visualViewport!;
visualViewport.addEventListener('resize', updatePosition);
visualViewport.addEventListener('scroll', updatePosition);
updatePosition(); // Initial position

return () => {
visualViewport.removeEventListener('resize', updatePosition);
visualViewport.removeEventListener('scroll', updatePosition);
};
}, []);

// Push new notifications into the local queue.
useEffect(() => {
if (!banner) return;
log.log('[Banner] New banner from atom:', banner.id, banner.title);
setQueue((prev) => {
// De-duplicate by id
if (prev.some((n) => n.id === banner.id)) return prev;
if (prev.some((n) => n.id === banner.id)) {
log.log('[Banner] Duplicate banner, skipping:', banner.id);
return prev;
}
// Keep at most 3 visible at once — drop the oldest if over limit.
const next = [...prev, banner];
log.log('[Banner] Adding to queue, new length:', next.length);
return next.length > 3 ? next.slice(next.length - 3) : next;
});
// Clear the atom so the same notification doesn't re-enqueue on re-render.
setBanner(null);
}, [banner, setBanner]);

const handleDismiss = (id: string) => {
log.log('[Banner] Dismissing banner:', id);
setQueue((prev) => prev.filter((n) => n.id !== id));
};

if (queue.length === 0) return null;
if (queue.length === 0) {
log.log('[Banner] No banners in queue, returning null');
return null;
}

log.log('[Banner] Rendering', queue.length, 'banners');
return (
<div className={css.BannerContainer} aria-live="polite" aria-atomic="false">
<div ref={containerRef} className={css.BannerContainer} aria-live="polite" aria-atomic="false">
{queue.map((n) => (
<BannerItem key={n.id} notification={n} onDismiss={handleDismiss} />
))}
Expand Down
6 changes: 4 additions & 2 deletions src/app/features/settings/notifications/AllMessages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ export function AllMessagesNotifications() {
gap="400"
>
<SettingTile
title="1-to-1 Chats"
title="Direct Messages"
description="Includes 1-to-1, group DMs, and bridged conversations."
after={<AllMessagesModeSwitcher pushRules={pushRules} ruleId={RuleId.DM} oneToOne />}
/>
</SequenceCard>
Expand All @@ -119,7 +120,8 @@ export function AllMessagesNotifications() {
gap="400"
>
<SettingTile
title="1-to-1 Chats (Encrypted)"
title="Direct Messages (Encrypted)"
description="Includes 1-to-1, group DMs, and bridged conversations."
after={
<AllMessagesModeSwitcher
pushRules={pushRules}
Expand Down
Loading
Loading