Skip to content
Draft
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
13 changes: 13 additions & 0 deletions apps/web/src/features/auth/CalendarPermissionPrompt.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useCalendarUiFlag } from '@app/features/calendar/use-calendar-ui-flag';
import { useKeyedPersistentToasts } from '@core/component/Toast/useKeyedPersistentToasts';
import { useAddInboxFlow } from '@core/email-link';
import { isMobile } from '@core/mobile/isMobile';
import { useEmailLinksQuery } from '@queries/email/link';

/**
Expand All @@ -12,6 +13,10 @@ import { useEmailLinksQuery } from '@queries/email/link';
*
* Inboxes that also need a full reconnect are skipped: the reconnect prompt
* covers them, and reconnecting records the calendar grant anyway.
*
* Closing the prompt sticks across reloads. Nothing is broken while calendar
* is off, so re-asking every load is just nagging — Settings › Email keeps a
* per-inbox "Enable calendar" button for whenever the user wants it.
*/
export function CalendarPermissionPrompt() {
const calendarUiEnabled = useCalendarUiFlag();
Expand All @@ -26,6 +31,14 @@ export function CalendarPermissionPrompt() {
)
: [],
key: (link) => link.id,
persistKey: 'macro:calendar-prompt:dismissed',
// Until the flag resolves and the links land, the empty list above means
// "don't know yet", not "no inbox needs this" — stored dismissals must
// survive that window.
itemsLoaded: () => calendarUiEnabled() && linksQuery.isSuccess,
// A phone has room for one of these above the dock; extra inboxes wait
// their turn rather than burying the screen.
maxVisible: () => (isMobile() ? 1 : Number.POSITIVE_INFINITY),
toast: (link, dismiss) => ({
title: 'Enable calendar',
content(): string {
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/features/auth/GmailReauthenticationPrompt.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useKeyedPersistentToasts } from '@core/component/Toast/useKeyedPersistentToasts';
import { useAddInboxFlow } from '@core/email-link';
import { isMobile } from '@core/mobile/isMobile';
import {
useEmailLinksQuery,
useInboxHealthProbeQuery,
Expand All @@ -24,6 +25,8 @@ export function GmailReauthenticationPrompt() {
items: () =>
(linksQuery.data?.links ?? []).filter((link) => link.needs_reauth),
key: (link) => link.id,
// One at a time on a phone; the other dead inboxes queue behind it.
maxVisible: () => (isMobile() ? 1 : Number.POSITIVE_INFINITY),
toast: (link, dismiss) => ({
title: 'Reconnect Gmail',
content(): string {
Expand Down
136 changes: 91 additions & 45 deletions apps/web/src/lib/core/component/Toast/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,29 @@ function dismissActiveToast(region: string): boolean {
return true;
}

/**
* Hand the region's single visible slot to a new toast, and report whether it
* displaced one (so the newcomer can skip its entrance animation).
*
* Persistent toasts opt out of the slot entirely: they are prompts the user is
* expected to answer, so a passing "Copied" must not tear one down, and a
* prompt appearing must not swallow a result the user is still reading. They
* stack in the region instead, and leave only when dismissed.
*/
function replaceActiveToast(region: string, persistent?: boolean): boolean {
if (persistent) return false;
return dismissActiveToast(region);
}

function trackActiveToast(
region: string,
toastId: number,
persistent?: boolean
): void {
if (persistent) return;
setActiveToastId(region, toastId);
}

function clearTrackedToast(region: string, toastId: number): void {
if (getActiveToastId(region) === toastId) {
setActiveToastId(region, undefined);
Expand Down Expand Up @@ -251,8 +274,8 @@ function ActionButtons(props: { actions: ToastAction[]; mobile?: boolean }) {
<Button
size={props.mobile ? 'sm' : 'md'}
onClick={action.onClick}
variant={props.mobile ? 'ghost' : 'base'}
class={cn('px-2 py-1', props.mobile && 'text-panel text-xs')}
variant="base"
class="px-2 py-1"
depth={3}
>
<Show when={action.icon}>
Expand Down Expand Up @@ -289,7 +312,7 @@ function ToastBodyWrapper(props: {
}
>
<Layer depth={3}>
<div class="island relative w-[90vw] p-2 rounded-xl">
<div class="island relative w-full p-2 rounded-xl">
{props.children}
</div>
</Layer>
Expand Down Expand Up @@ -411,46 +434,69 @@ function ToastContent(props: {

{/* ── Custom layout ── */}
<Match when={props.custom}>
{(customConfig) => (
<>
<div class="flex items-center gap-2 justify-between">
<Show when={customConfig().icon && !props.mobile}>
{(_) => {
const icon = customConfig().icon!;
return (
<div class="size-5 flex shrink-0 justify-center items-center rounded-full p-0.75">
<Dynamic component={icon} />
</div>
);
}}
</Show>
<Toast.Title
class={cn(
'font-semibold grow shrink truncate text-left',
props.mobile ? 'text-xs' : 'text-ink'
)}
>
{customConfig().title}
</Toast.Title>
<Show when={customConfig().actions?.length}>
<ActionButtons
actions={customConfig().actions!}
mobile={props.mobile}
/>
{(customConfig) => {
// A persistent prompt on mobile can't borrow the transient
// one-line treatment: with the body and close button stripped it
// reduces to a bare title that never goes away. Give it the full
// card — description, close button, and its actions on their own
// row so the tap targets aren't fighting a truncated title.
const stacked = () => Boolean(props.mobile && props.persistent);
const showContent = () =>
Boolean(customConfig().content) && (!props.mobile || stacked());
return (
<>
<div class="flex items-center gap-2 justify-between">
<Show when={customConfig().icon && !props.mobile}>
{(_) => {
const icon = customConfig().icon!;
return (
<div class="size-5 flex shrink-0 justify-center items-center rounded-full p-0.75">
<Dynamic component={icon} />
</div>
);
}}
</Show>
<Toast.Title
class={cn(
'font-semibold grow shrink truncate text-left',
props.mobile ? 'text-xs' : 'text-ink',
stacked() && 'text-sm'
)}
>
{customConfig().title}
</Toast.Title>
<Show when={customConfig().actions?.length && !stacked()}>
<ActionButtons
actions={customConfig().actions!}
mobile={props.mobile}
/>
</Show>
<Show when={!props.mobile || props.persistent}>
<Toast.CloseButton>
<Button variant="ghost" size="icon-sm">
<XIcon />
</Button>
</Toast.CloseButton>
</Show>
</div>
<Show when={showContent()}>
<div
class={cn(
'my-2',
props.mobile ? 'text-xs text-ink-muted' : 'ml-7'
)}
>
{customConfig().content?.()}
</div>
</Show>
<Show when={!props.mobile}>
<Toast.CloseButton>
<Button variant="ghost" size="icon-sm">
<XIcon />
</Button>
</Toast.CloseButton>
<Show when={stacked() && customConfig().actions?.length}>
<div class="flex justify-end gap-2">
<ActionButtons actions={customConfig().actions!} mobile />
</div>
</Show>
</div>
<Show when={customConfig().content && !props.mobile}>
<div class="my-2 ml-7">{customConfig().content?.()}</div>
</Show>
</>
)}
</>
);
}}
</Match>

{/* ── Standard layout ── */}
Expand Down Expand Up @@ -667,7 +713,7 @@ function embed(
const useMobile = isMobile();
const region =
options?.region ?? (useMobile ? 'mobile-toast-region' : 'toast-region');
const skipOpenAnimation = dismissActiveToast(region);
const skipOpenAnimation = replaceActiveToast(region, options?.persistent);
const toastId = toaster.show(
(props) => (
<ToastContent
Expand All @@ -682,7 +728,7 @@ function embed(
),
{ region }
);
setActiveToastId(region, toastId);
trackActiveToast(region, toastId, options?.persistent);
return toastId;
}

Expand All @@ -705,7 +751,7 @@ function custom(
const useMobile = isMobile();
const region =
options?.region ?? (useMobile ? 'mobile-toast-region' : 'toast-region');
const skipOpenAnimation = dismissActiveToast(region);
const skipOpenAnimation = replaceActiveToast(region, options?.persistent);
const toastId = toaster.show(
(props) => (
<ToastContent
Expand All @@ -723,7 +769,7 @@ function custom(
),
{ region }
);
setActiveToastId(region, toastId);
trackActiveToast(region, toastId, options?.persistent);
return toastId;
}

Expand Down
9 changes: 5 additions & 4 deletions apps/web/src/lib/core/component/Toast/ToastRegion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ export function ToastRegion() {
</div>

{/*
Mobile-only region: centered above the mobile dock. Only one toast is
ever visible — Toast.tsx dismisses the previous mobile toast as soon
as a new one is shown, so no stacking is needed here.
Mobile-only region: centered above the mobile dock. At most one
transient toast is visible — Toast.tsx dismisses the previous one as
soon as a new one is shown. Persistent prompts opt out of that slot and
stack above it until answered, so this list needs real spacing.
*/}
<div
class="fixed left-1/2 -translate-x-1/2 w-full max-w-[420px] px-(--mobile-chrome-gutter) pointer-events-none z-toast-region"
Expand All @@ -33,7 +34,7 @@ export function ToastRegion() {
duration={Infinity}
pauseOnInteraction={false}
>
<Toast.List class="flex flex-col" />
<Toast.List class="flex flex-col gap-2" />
</Toast.Region>
</div>
</Portal>
Expand Down
Loading
Loading