Skip to content
Closed
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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

68 changes: 66 additions & 2 deletions apps/web/src/components/app/app-sidebar/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import UsersThreeIcon from '@phosphor/users-three.svg';
import XIcon from '@phosphor/x.svg';
import { isRealNamePart, useOwnUserName } from '@queries/auth/user-name-self';
import { useEmailLinksQuery } from '@queries/email/link';
import { useEmailUnreadCounts } from '@queries/email/unread-counts';
import {
useJoinTeamMutation,
useRejectInvitationMutation,
Expand Down Expand Up @@ -1599,6 +1600,11 @@ interface SidebarLinkProps extends SidebarItem {
* Email link's expand chevron.
*/
trailingWhenActive?: JSX.Element;
/**
* Unread count shown as a badge at the row's right edge, or over the icon in
* slim mode. `0` (or omitted) renders nothing.
*/
unreadCount?: number;
/**
* Swaps the icon for an X while the row is hovered (expanded sidebar only —
* in slim mode the icon is the whole row, so the swap would hijack
Expand All @@ -1607,6 +1613,36 @@ interface SidebarLinkProps extends SidebarItem {
removeAction?: { tooltip: string; onRemove: () => void };
}

/**
* Unread-count badge for a sidebar row. Renders nothing at zero, so callers can
* pass a count unconditionally.
*
* `slim` is the compact variant that sits over a rail icon, where there is only
* room for a single digit before the count becomes an "attention" marker.
*/
const SidebarUnreadBadge = (props: { count: number; slim?: boolean }) => {
const label = () => {
if (props.slim) return props.count > 9 ? '9+' : `${props.count}`;
return props.count > 99 ? '99+' : `${props.count}`;
};

return (
<Show when={props.count > 0}>
<span
aria-label={`${props.count} unread`}
class={cn(
'rounded-full bg-accent/15 text-accent font-medium tabular-nums text-center',
props.slim
? 'absolute -top-1 -right-1 min-w-3.5 px-0.5 text-[8px] leading-3.5'
: 'min-w-4 px-1 text-xxs leading-4'
)}
>
{label()}
</span>
</Show>
);
};

/** Which action of {@link SidebarOpenInSplitMenu} placed the content. */
type SidebarOpenAction = 'current-split' | 'new-split' | 'fullscreen';

Expand Down Expand Up @@ -1780,6 +1816,12 @@ const SidebarLink = (props: SidebarLinkProps) => {
globalSplitManager()?.returnFocus();
}}
>
{/* Slim rail has no room for a trailing badge, so the count rides the
icon. Top-right keeps it clear of the bottom-right hotkey chip. */}
<Show when={props.sidebarState === 'slim'}>
<SidebarUnreadBadge count={props.unreadCount ?? 0} slim />
</Show>

<Show when={props.icon}>
<div class="size-5 shrink-0 flex items-center justify-center [&_svg]:size-3.5">
<Show
Expand Down Expand Up @@ -1836,14 +1878,22 @@ const SidebarLink = (props: SidebarLinkProps) => {
<span class="whitespace-nowrap">{props.label}</span>
</div>

{/* Sits before the chevron and hotkey hints so the count keeps its
place as those come and go with hover and active state. */}
<Show when={!props.hotkeyVisible}>
<div class="group-data-[slim=true]/sidebar:hidden ml-auto flex items-center">
<SidebarUnreadBadge count={props.unreadCount ?? 0} />
</div>
</Show>

<Show
when={
isActive() &&
props.trailingWhenActive !== undefined &&
!props.hotkeyVisible
}
>
<div class="group-data-[slim=true]/sidebar:hidden ml-auto flex items-center text-ink-muted">
<div class="group-data-[slim=true]/sidebar:hidden ml-1 flex items-center text-ink-muted">
{props.trailingWhenActive}
</div>
</Show>
Expand All @@ -1855,7 +1905,7 @@ const SidebarLink = (props: SidebarLinkProps) => {
!(isActive() && props.trailingWhenActive !== undefined)
}
>
<div class="group-data-[slim=true]/sidebar:hidden ml-auto">
<div class="group-data-[slim=true]/sidebar:hidden ml-1">
<div class="flex gap-1 items-center text-ink-extra-muted font-normal text-xxs">
<Show when={!props.standaloneHotkey}>
<div class="text-xxs text-ink-extra-muted rounded-sm ml-auto border border-ink/5 px-1.5 py-0.5 -my-1">
Expand Down Expand Up @@ -1909,6 +1959,11 @@ const SidebarLink = (props: SidebarLinkProps) => {
* Each row carries the same right-click menu as the parent link (open in the
* current split, a new split, or fullscreen), scoping whichever split it opens
* to that inbox.
*
* Unread Signal counts follow the same fan-out: while the rows are collapsed
* the Email link badges the total across every inbox, and once they expand each
* inbox badges its own. Noise is never counted, so the badge always matches the
* Signal tab a click lands on.
*/
const SidebarMailLink = (props: SidebarLinkProps) => {
const layout = useSplitLayout();
Expand All @@ -1923,6 +1978,9 @@ const SidebarMailLink = (props: SidebarLinkProps) => {
)
);

// Only worth asking once we know the user has an inbox at all.
const unread = useEmailUnreadCounts(() => links().length > 0);

Comment on lines +1981 to +1983

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -t f 'unread-counts.ts' apps/web/src/lib/queries/email
ast-grep outline apps/web/src/lib/queries/email/unread-counts.ts --items all
sed -n '1,220p' apps/web/src/lib/queries/email/unread-counts.ts

rg -n -C 5 \
  '<Suspense|SidebarMailLink|useEmailUnreadCounts' \
  apps/web/src -g '*.tsx'

Repository: macro-inc/macro

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "--- Sidebar imports and query call site ---"
sed -n '70,105p;1950,2010p' apps/web/src/components/app/app-sidebar/sidebar.tsx | cat -n

echo "--- App root and Layout Suspense nesting around authenticated sidebar area ---"
sed -n '690,720p' apps/web/src/routes/Root.tsx | cat -n
sed -n '415,445p' apps/web/src/components/app/Layout.tsx | cat -n
sed -n '530,545p;570,585p' apps/web/src/components/app/Layout.tsx | cat -n

echo "--- Sidebar render path outline ---"
ast-grep outline apps/web/src/components/app/app-sidebar/sidebar.tsx --match Sidebar --view expanded || true

echo "--- Other email query call sites ---"
rg -n 'useEmail(Links|UnreadCounts|.*Query)?|setSuspenseQuery|Suspense' apps/web/src/lib/queries/email apps/web/src/components/app/app-sidebar/sidebar.tsx apps/web/src/routes -g '*.tsx' -g '*.ts'

Repository: macro-inc/macro

Length of output: 13997


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "--- locate AppSidebar usages ---"
rg -n -C 8 '<AppSidebar|from ["'\''].*sidebar[["'\'']' apps/web/src -g '*.tsx' -g '*.ts'

echo "--- AppSidebar rendering section ---"
sed -n '1060,1135p;1460,1490p' apps/web/src/components/app/app-sidebar/sidebar.tsx | cat -n

Repository: macro-inc/macro

Length of output: 324


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "--- locate AppSidebar usages ---"
rg -n -C 8 '<AppSidebar' apps/web/src -g '*.tsx' -g '*.ts' || true

echo "--- locate sidebar file imports/usages ---"
rg -n 'app-sidebar/sidebar|from ["'\'']`@app-sidebar/sidebar`' apps/web/src -g '*.tsx' -g '*.ts' || true

echo "--- AppSidebar rendering section ---"
sed -n '1060,1135p;1460,1490p' apps/web/src/components/app/app-sidebar/sidebar.tsx | cat -n

Repository: macro-inc/macro

Length of output: 7061


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "--- Authenticated Layout Suspense / AppSidebar boundary ---"
sed -n '425,490p' apps/web/src/components/app/Layout.tsx | cat -n

echo "--- Sidebar middle render section around final Suspense blocks ---"
sed -n '1465,1515p' apps/web/src/components/app/app-sidebar/sidebar.tsx | cat -n

Repository: macro-inc/macro

Length of output: 5043


Wrap the sidebar unread-count query in its own Suspense boundary.

useEmailUnreadCounts() is a new query call site, and the parent Suspense only wraps the authenticated Layout content so it is not a deliberate sidebar-specific fallback. Wrap SidebarMailLink with a Suspense boundary that handles unread-count loading.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/app/app-sidebar/sidebar.tsx` around lines 1981 -
1983, Wrap SidebarMailLink, including its useEmailUnreadCounts call, in a
dedicated Suspense boundary with an appropriate sidebar-specific fallback. Keep
the existing links().length > 0 gating unchanged and ensure the parent layout
boundary is no longer relied on for unread-count loading.

Source: Path instructions

const isMailList = (content: SplitContent | undefined) =>
content?.type === 'component' && content.id === 'mail';

Expand Down Expand Up @@ -1987,6 +2045,9 @@ const SidebarMailLink = (props: SidebarLinkProps) => {
<>
<SidebarLink
{...props}
// Once the rows are out, each inbox badges itself — a total here on top
// of them would report the same mail twice.
unreadCount={showAccounts() ? 0 : unread.total()}
suppressActiveStyle={showAccounts() && onlySelectedId() !== undefined}
onActiveClick={() => {
if (!canShow()) return;
Expand Down Expand Up @@ -2064,6 +2125,9 @@ const SidebarMailLink = (props: SidebarLinkProps) => {
showTooltip={false}
/>
<span class="truncate">{link.email_address}</span>
<div class="ml-auto pl-1 flex items-center">
<SidebarUnreadBadge count={unread.forLink(link.id)} />
</div>
</NavRow>
</SidebarOpenInSplitMenu>
</li>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/lib/queries/email/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const emailKeys = createQueryKeys('email', {
labels: null,
links: null,
linksHealthProbe: null,
unreadCounts: null,
backfillJobs: null,
threads: null,
thread: (threadId: string) => ({
Expand Down
15 changes: 14 additions & 1 deletion apps/web/src/lib/queries/email/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { invalidateAllSoup } from '../soup/normalized-cache';
import { type UndoHandle, useUndoableMutation } from '../undo';
import { type MutationCallbacks, withCallbacks } from '../utils';
import { emailKeys } from './keys';
import { invalidateEmailUnreadCounts } from './unread-counts';

const THREAD_STALE_TIME = 5 * 60 * 1000;

Expand Down Expand Up @@ -216,6 +217,11 @@ export function useMarkThreadAsSeenMutation(
// Note: We intentionally don't invalidate thread messages in onSuccess.
// The optimistic update already sets isRead in soup, and invalidating
// thread messages triggers Suspense which resets scroll position.
//
// The sidebar badge counts unread signal threads, so reading one moves
// it. Refetched once the server has actually applied the read — doing
// it in onMutate would race the write and re-cache the old count.
onSettled: invalidateEmailUnreadCounts,
},
callbacks
),
Expand Down Expand Up @@ -285,7 +291,10 @@ export function useMarkThreadAsUnreadMutation(
);
},
...withCallbacks<void, Error, MarkThreadAsUnreadParams>(
{ onMutate: threadUnreadOnMutate },
{
onMutate: threadUnreadOnMutate,
onSettled: invalidateEmailUnreadCounts,
},
callbacks
),
}));
Expand Down Expand Up @@ -640,6 +649,9 @@ async function upsertSenderFilterWithToast(

const filterId = result.value.filter.id;
invalidateAllSoup();
// Reclassifying a sender moves their threads between Signal and Noise, so
// the Signal-only badge count changes with them.
invalidateEmailUnreadCounts();

toast.success(`Sender marked as ${label}`, {
subtext: `Messages from ${senderEmail} will appear in ${label}`,
Expand All @@ -655,6 +667,7 @@ async function upsertSenderFilterWithToast(
toast.failure('Failed to undo', { subtext: senderEmail });
} else {
invalidateAllSoup();
invalidateEmailUnreadCounts();
toast.success('Sender filter removed');
}
},
Expand Down
77 changes: 77 additions & 0 deletions apps/web/src/lib/queries/email/unread-counts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { throwOnErr } from '@core/util/result';
import { queryClient } from '@queries/client';
import { emailClient } from '@service-email/client';
import { useQuery } from '@tanstack/solid-query';
import { type Accessor, createMemo } from 'solid-js';
import { emailKeys } from './keys';

/**
* How long a fetched count stays fresh. Short, because the badge is a
* "there is something waiting" signal — a stale one is worse than a late one.
*/
const UNREAD_COUNTS_STALE_TIME = 30 * 1000;

/**
* Background refresh cadence. Mail arriving from the provider doesn't
* invalidate this query (the notification-driven soup refresh doesn't know
* about it), so a poll is what turns new mail into a badge while the tab
* stays open. Reads and unreads made in-app invalidate directly and don't
* wait for it.
*/
const UNREAD_COUNTS_REFETCH_INTERVAL = 60 * 1000;

const queryEnabled = () => true;

/**
* Unread Signal-view thread counts per connected inbox, as the sidebar's Email
* badge renders them. Counts are Signal only — Noise is deliberately excluded,
* so the number always matches the tab a click lands on.
*
* The server returns one entry per accessible inbox, including inboxes with
* nothing unread, so an inbox missing from the response means "not linked"
* rather than "caught up".
*/
export function useEmailUnreadCountsQuery(
enabled: Accessor<boolean> = queryEnabled
) {
return useQuery(() => ({
queryKey: emailKeys.unreadCounts.queryKey,
queryFn: async () =>
throwOnErr(async () => await emailClient.getUnreadCounts()),
enabled: enabled(),
staleTime: UNREAD_COUNTS_STALE_TIME,
refetchInterval: UNREAD_COUNTS_REFETCH_INTERVAL,
refetchOnWindowFocus: 'always' as const,
}));
}

/**
* Unread Signal counts keyed by email link id, plus the cross-inbox total the
* collapsed Email row shows. Both are `0`-safe before the query resolves, so
* the badge simply doesn't render rather than flashing a placeholder.
*/
export function useEmailUnreadCounts(enabled?: Accessor<boolean>) {
const query = useEmailUnreadCountsQuery(enabled);

const byLinkId = createMemo(() => {
const counts = new Map<string, number>();
for (const entry of query.data?.counts ?? []) {
counts.set(entry.link_id, entry.unread_count);
}
return counts;
});

return {
/** Unread Signal count for one inbox; `0` while the query is loading. */
forLink: (linkId: string) => byLinkId().get(linkId) ?? 0,
/** Unread Signal count summed across every connected inbox. */
total: () => query.data?.total ?? 0,
};
}

/** Refetch the unread counts — call after anything that reads or unreads mail. */
export function invalidateEmailUnreadCounts() {
queryClient.invalidateQueries({
queryKey: emailKeys.unreadCounts.queryKey,
});
}
9 changes: 9 additions & 0 deletions apps/web/src/lib/service-clients/service-email/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
ListEmailFiltersResponse,
ListLabelsResponse,
ListLinksResponse,
ListUnreadCountsResponse,
PatchSettingsRequest,
PatchSettingsResponse,
ResyncResponse,
Expand Down Expand Up @@ -298,6 +299,14 @@ export const emailClient = {
).map((result) => result);
},

async getUnreadCounts() {
return (
await emailFetch<ListUnreadCountsResponse>('/email/links/unread-counts', {
method: 'GET',
})
).map((result) => result);
},

async listBackfillJobs() {
return (
await emailFetch<ListBackfillJobsResponse>('/email/backfill/gmail', {
Expand Down
Loading
Loading