Skip to content
Open
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
Expand Up @@ -17,7 +17,7 @@ import { ToggleSidePanelButton } from "@/ui/messages/toggle-side-panel-button";
import { ProgramHelpLinks } from "@/ui/partners/program-help-links";
import { ProgramRewardsPanel } from "@/ui/partners/program-rewards-panel";
import { X } from "@/ui/shared/icons";
import { Button, Grid, useCopyToClipboard } from "@dub/ui";
import { Button, Grid, useCopyToClipboard, useMediaQuery } from "@dub/ui";
import {
Check,
ChevronLeft,
Expand All @@ -36,13 +36,21 @@ import {
} from "@dub/utils";
import { useAction } from "next-safe-action/hooks";
import Link from "next/link";
import { redirect, useParams } from "next/navigation";
import { useState } from "react";
import {
redirect,
useParams,
useRouter,
useSearchParams,
} from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { v4 as uuid } from "uuid";

export function PartnerMessagesProgramPageClient() {
const { programSlug } = useParams() as { programSlug: string };
const router = useRouter();
const searchParams = useSearchParams();
const { width } = useMediaQuery();

const { user } = useUser();
const { partner } = usePartnerProfile();
Expand Down Expand Up @@ -86,17 +94,35 @@ export function PartnerMessagesProgramPageClient() {
},
});

const program = programMessages?.[0]?.program;
const messages = programMessages?.[0]?.messages;
const { setCurrentPanel, targetThreadId } = useMessagesContext();
const isThreadTransitioning =
targetThreadId !== null && targetThreadId !== programSlug;

const activeThread =
!isThreadTransitioning && programMessages?.[0]?.program.slug === programSlug
? programMessages[0]
: undefined;
const program = activeThread?.program;
const messages = activeThread?.messages;

const { executeAsync: sendMessage } = useAction(messageProgramAction, {
onError({ error }) {
toast.error(parseActionError(error, "Failed to send message"));
},
});

const { setCurrentPanel } = useMessagesContext();
const [isRightPanelOpen, setIsRightPanelOpen] = useState(true);
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
const shouldAutoFocusComposer = searchParams.get("new") === "1";

useEffect(() => {
if (typeof width !== "number") return;
setIsRightPanelOpen(width >= 1082);
}, [programSlug, width]);

useEffect(() => {
if (!shouldAutoFocusComposer) return;
router.replace(`/messages/${programSlug}`);
}, [programSlug, router, shouldAutoFocusComposer]);

// Redirect if no messages and not enrolled, or messages error
if (
Expand Down Expand Up @@ -194,6 +220,7 @@ export function PartnerMessagesProgramPageClient() {
currentUserType="partner"
currentUserId={partner?.id || ""}
program={program}
autoFocusComposer={shouldAutoFocusComposer}
onSendMessage={async (message) => {
const createdAt = new Date();

Expand Down
26 changes: 18 additions & 8 deletions apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { NavButton } from "@/ui/layout/page-content/nav-button";
import { MessagesContext, MessagesPanel } from "@/ui/messages/messages-context";
import { MessagesList } from "@/ui/messages/messages-list";
import { ProgramSelector } from "@/ui/partners/program-selector";
import { Button, InfoTooltip, useRouterStuff } from "@dub/ui";
import { Button, InfoTooltip } from "@dub/ui";
import { Msgs, Pen2 } from "@dub/ui/icons";
import { useParams, useRouter } from "next/navigation";
import { CSSProperties, ReactNode, useEffect, useState } from "react";
Expand All @@ -14,7 +14,6 @@ export default function MessagesLayout({ children }: { children: ReactNode }) {
const { programSlug } = useParams() as { programSlug?: string };

const router = useRouter();
const { searchParams } = useRouterStuff();

const { programMessages, isLoading, error } = useProgramMessages({
query: { messagesLimit: 1 },
Expand All @@ -23,13 +22,22 @@ export default function MessagesLayout({ children }: { children: ReactNode }) {
const [currentPanel, setCurrentPanel] = useState<MessagesPanel>(
programSlug ? "main" : "index",
);
const [targetThreadId, setTargetThreadId] = useState<string | null>(null);

useEffect(() => {
searchParams.get("new") && setCurrentPanel("main");
}, [searchParams.get("new")]);
setCurrentPanel(programSlug ? "main" : "index");
setTargetThreadId(null);
}, [programSlug]);

return (
<MessagesContext.Provider value={{ currentPanel, setCurrentPanel }}>
<MessagesContext.Provider
value={{
currentPanel,
setCurrentPanel,
targetThreadId,
setTargetThreadId,
}}
>
<div className="@container/page h-[calc(100dvh-var(--page-top-margin)-var(--page-bottom-margin)-1px)] w-full overflow-hidden rounded-t-[inherit] bg-white">
<div
className="@[800px]/page:grid-cols-[min-content_minmax(340px,1fr)] @[800px]/page:translate-x-0 grid h-full translate-x-[calc(var(--current-panel)*-100%)] grid-cols-[100%_100%]"
Expand Down Expand Up @@ -57,9 +65,11 @@ export default function MessagesLayout({ children }: { children: ReactNode }) {
</div>
<ProgramSelector
selectedProgramSlug={programSlug ?? null}
setSelectedProgramSlug={(slug) =>
router.push(`/messages/${slug}`)
}
setSelectedProgramSlug={(slug) => {
setTargetThreadId(slug);
setCurrentPanel("main");
router.push(`/messages/${slug}?new=1`);
}}
trigger={
<Button
type="button"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,28 @@ import { PartnerInfoGroup } from "@/ui/partners/partner-info-group";
import { PartnerInfoSection } from "@/ui/partners/partner-info-section";
import { PartnerInfoStats } from "@/ui/partners/partner-info-stats";
import { X } from "@/ui/shared/icons";
import { Button } from "@dub/ui";
import { Button, useMediaQuery } from "@dub/ui";
import { ChevronLeft } from "@dub/ui/icons";
import { OG_AVATAR_URL, cn } from "@dub/utils";
import { useAction } from "next-safe-action/hooks";
import Link from "next/link";
import { redirect, useParams } from "next/navigation";
import { useState } from "react";
import {
usePathname,
redirect,
useParams,
useRouter,
useSearchParams,
} from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { v4 as uuid } from "uuid";

export function ProgramMessagesPartnerPageClient() {
const { id: workspaceId, slug: workspaceSlug } = useWorkspace();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { width } = useMediaQuery();

const { partnerId } = useParams() as { partnerId: string };
const { user } = useUser();
Expand Down Expand Up @@ -71,17 +81,40 @@ export function ProgramMessagesPartnerPageClient() {
},
});

const partner = partnerMessages?.[0]?.partner;
const messages = partnerMessages?.[0]?.messages;
const { setCurrentPanel, targetThreadId } = useMessagesContext();
const isThreadTransitioning =
targetThreadId !== null && targetThreadId !== partnerId;

const activeThread =
!isThreadTransitioning && partnerMessages?.[0]?.partner.id === partnerId
? partnerMessages[0]
: undefined;
const partner = activeThread?.partner;
const messages = activeThread?.messages;

const { executeAsync: sendMessage } = useAction(messagePartnerAction, {
onError({ error }) {
toast.error(parseActionError(error, "Failed to send message"));
},
});

const { setCurrentPanel } = useMessagesContext();
const [isRightPanelOpen, setIsRightPanelOpen] = useState(true);
const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
const shouldAutoFocusComposer = searchParams.get("new") === "1";
Comment on lines +101 to +102
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Minor layout shift on desktop: isRightPanelOpen defaults to false.

On wide screens (≥960px), the right panel will be closed on the initial render, then opened by the effect on lines 104–107 after paint. This may cause a brief visible layout shift. Previously this defaulted to true.

Consider initializing based on width if available synchronously, e.g.:

- const [isRightPanelOpen, setIsRightPanelOpen] = useState(false);
+ const [isRightPanelOpen, setIsRightPanelOpen] = useState(
+   typeof width === "number" ? width >= 960 : false,
+ );

Though this depends on whether useMediaQuery provides a synchronous initial value or not.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/web/app/app.dub.co/`(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx
around lines 101 - 102, The right-panel opens after initial paint causing a
layout shift because isRightPanelOpen is initialized to false; change the
initialization so the state is computed synchronously from the current viewport
(e.g. use a media-query check in the useState initializer) instead of always
false. Update the isRightPanelOpen / setIsRightPanelOpen initialization to
derive its initial value from a synchronous media check (window.matchMedia or
your useMediaQuery sync value) with proper SSR guarding, leaving the existing
effect that updates it in place.


useEffect(() => {
if (typeof width !== "number") return;
setIsRightPanelOpen(width >= 960);
}, [partnerId, width]);
Comment on lines +104 to +107
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Right panel effect re-fires on every width change, potentially overriding manual user toggle.

If a user manually closes the right panel on a wide screen (≥960px), any subsequent resize event (even a 1px change) will reopen it because the effect unconditionally sets isRightPanelOpen based on width. Similarly, manually opening the panel on a narrow screen gets overridden on resize.

Consider tracking whether the user has manually toggled the panel and skipping the auto-set in that case, or only running this logic on partnerId change (not width).

♻️ Possible approach: only auto-set on partnerId change
  useEffect(() => {
    if (typeof width !== "number") return;
    setIsRightPanelOpen(width >= 960);
-  }, [partnerId, width]);
+  }, [partnerId]); // eslint-disable-line react-hooks/exhaustive-deps

This would set the initial panel state based on current viewport when switching partners, without overriding manual toggles during resizes.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
if (typeof width !== "number") return;
setIsRightPanelOpen(width >= 960);
}, [partnerId, width]);
useEffect(() => {
if (typeof width !== "number") return;
setIsRightPanelOpen(width >= 960);
}, [partnerId]); // eslint-disable-line react-hooks/exhaustive-deps
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/web/app/app.dub.co/`(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx
around lines 104 - 107, The effect that auto-sets panel open state should not
unconditionally run on every width change; update the logic in the useEffect
(the effect that currently reads width and calls setIsRightPanelOpen) to respect
a user toggle flag or to run only when partnerId changes: add a new state like
userHasToggled (initially false), set userHasToggled = true inside the manual
toggle handler that calls setIsRightPanelOpen, and change the effect to only
auto-set when userHasToggled is false (or change its dependency array to only
[partnerId] so it runs on partner change only); reference the existing
useEffect, setIsRightPanelOpen, isRightPanelOpen, partnerId and width when
making the changes.


useEffect(() => {
if (!shouldAutoFocusComposer) return;

const nextSearchParams = new URLSearchParams(searchParams.toString());
nextSearchParams.delete("new");
const nextSearch = nextSearchParams.toString();

router.replace(nextSearch ? `${pathname}?${nextSearch}` : pathname);
}, [pathname, router, searchParams, shouldAutoFocusComposer]);

if (errorMessages) redirect(`/${workspaceSlug}/program/messages`);

Expand Down Expand Up @@ -144,6 +177,7 @@ export function ProgramMessagesPartnerPageClient() {
currentUserId={user?.id || ""}
program={program}
partner={partner}
autoFocusComposer={shouldAutoFocusComposer}
onSendMessage={async (message) => {
const createdAt = new Date();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { PartnerSelector } from "@/ui/partners/partner-selector";
import { Button, InfoTooltip } from "@dub/ui";
import { Msgs, Pen2 } from "@dub/ui/icons";
import { useParams, useRouter } from "next/navigation";
import { CSSProperties, ReactNode, useState } from "react";
import { CSSProperties, ReactNode, useEffect, useState } from "react";
import { MessagesUpsell } from "./messages-upsell";

export default function MessagesLayout({ children }: { children: ReactNode }) {
Expand All @@ -30,7 +30,6 @@ export default function MessagesLayout({ children }: { children: ReactNode }) {
function CapableLayout({ children }: { children: ReactNode }) {
const { slug: workspaceSlug } = useWorkspace();
const { partnerId } = useParams() as { partnerId?: string };
const { program } = useProgram();

const router = useRouter();

Expand All @@ -41,9 +40,22 @@ function CapableLayout({ children }: { children: ReactNode }) {
const [currentPanel, setCurrentPanel] = useState<MessagesPanel>(
partnerId ? "main" : "index",
);
const [targetThreadId, setTargetThreadId] = useState<string | null>(null);

useEffect(() => {
setCurrentPanel(partnerId ? "main" : "index");
setTargetThreadId(null);
}, [partnerId]);

return (
<MessagesContext.Provider value={{ currentPanel, setCurrentPanel }}>
<MessagesContext.Provider
value={{
currentPanel,
setCurrentPanel,
targetThreadId,
setTargetThreadId,
}}
>
<div className="@container/page h-[calc(100dvh-var(--page-top-margin)-var(--page-bottom-margin)-1px)] w-full overflow-hidden rounded-t-[inherit] bg-white">
<div
className="@[800px]/page:grid-cols-[min-content_minmax(340px,1fr)] @[800px]/page:translate-x-0 grid h-full translate-x-[calc(var(--current-panel)*-100%)] grid-cols-[100%_100%]"
Expand Down Expand Up @@ -71,9 +83,11 @@ function CapableLayout({ children }: { children: ReactNode }) {
</div>
<PartnerSelector
selectedPartnerId={partnerId ?? null}
setSelectedPartnerId={(id) =>
router.push(`/${workspaceSlug}/program/messages/${id}`)
}
setSelectedPartnerId={(id) => {
setTargetThreadId(id);
setCurrentPanel("main");
router.push(`/${workspaceSlug}/program/messages/${id}?new=1`);
}}
trigger={
<Button
type="button"
Expand Down
20 changes: 14 additions & 6 deletions apps/web/ui/layout/sidebar/app-sidebar-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import useProgram from "@/lib/swr/use-program";
import { useProgramReferralsCount } from "@/lib/swr/use-program-referrals-count";
import useWorkspace from "@/lib/swr/use-workspace";
import useWorkspaces from "@/lib/swr/use-workspaces";
import { useRouterStuff } from "@dub/ui";
import { useMediaQuery, useRouterStuff } from "@dub/ui";
import {
Bell,
Brush,
Expand Down Expand Up @@ -498,6 +498,7 @@ export function AppSidebarNav({
}) {
const { slug: paramsSlug } = useParams() as { slug?: string };
const pathname = usePathname();
const { width, isMobile } = useMediaQuery();
const { getQueryString } = useRouterStuff();
const { data: session, status } = useSession();
const { plan, defaultProgramId } = useWorkspace();
Expand Down Expand Up @@ -547,18 +548,25 @@ export function AppSidebarNav({
session?.user?.["defaultWorkspace"];

const currentArea = useMemo(() => {
const focusedProgramPath =
pathname.includes("/program/campaigns/") ||
pathname.includes("/program/messages/") ||
pathname.endsWith("/program/payouts/success");
const isMobileDrawerViewport =
typeof width === "number" ? width < 768 : isMobile;
Comment on lines +555 to +556
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Find the useMediaQuery hook definition
rg -n "useMediaQuery" --type=ts --type=tsx -A 5 -B 2 | head -100

Repository: dubinc/dub

Length of output: 81


🏁 Script executed:

# Examine the specific file and context around lines 555-556
cat -n apps/web/ui/layout/sidebar/app-sidebar-nav.tsx | sed -n '545,570p'

Repository: dubinc/dub

Length of output: 1049


🏁 Script executed:

# Look for the currentArea variable and its dependencies
rg -n "currentArea" apps/web/ui/layout/sidebar/app-sidebar-nav.tsx -B 2 -A 2

Repository: dubinc/dub

Length of output: 1471


🏁 Script executed:

# Find useMediaQuery hook definition and where width/isMobile come from
rg -n "const.*useMediaQuery\|function useMediaQuery\|export.*useMediaQuery" --type=ts -A 10 | head -50

Repository: dubinc/dub

Length of output: 36


🏁 Script executed:

# Search in the same file to see where width and isMobile are declared
cat -n apps/web/ui/layout/sidebar/app-sidebar-nav.tsx | head -100

Repository: dubinc/dub

Length of output: 3737


🏁 Script executed:

# Look for imports and variable declarations in the file
cat -n apps/web/ui/layout/sidebar/app-sidebar-nav.tsx | grep -n "import\|width\|isMobile" | head -30

Repository: dubinc/dub

Length of output: 1929


🏁 Script executed:

# Find the useMediaQuery hook implementation in `@dub/ui`
find . -type f -name "*.ts" -o -name "*.tsx" | xargs grep -l "export.*useMediaQuery\|function useMediaQuery" | head -10

Repository: dubinc/dub

Length of output: 96


🏁 Script executed:

# Search more broadly for useMediaQuery definition
rg -n "useMediaQuery" --type=ts -A 20 | grep -A 20 "function\|export\|useState\|initialValue" | head -80

Repository: dubinc/dub

Length of output: 4959


🏁 Script executed:

# Examine the useMediaQuery hook implementation
cat -n ./packages/ui/src/hooks/use-media-query.ts

Repository: dubinc/dub

Length of output: 1738


Potential one-frame flash on mobile for focused program paths.

The useMediaQuery hook initializes with width = undefined and isMobile = false during SSR/hydration. On mobile clients visiting /program/messages/, /program/campaigns/, or /program/payouts/success, the first render evaluates isMobileDrawerViewport = false (since width is not yet a number), causing currentArea to be null. After the hook's useEffect runs and detects actual mobile dimensions, isMobileDrawerViewport flips to true and currentArea becomes "program", enabling data fetches and changing the sidebar layout. This causes a brief visual shift.

Consider initializing the guard optimistically (e.g., treat an unresolved width as mobile-like, or render a skeleton) if this flash is observable in practice.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/ui/layout/sidebar/app-sidebar-nav.tsx` around lines 555 - 556, The
first render treats unresolved width as desktop and causes a one-frame flash;
update the guard in app-sidebar-nav.tsx (the isMobileDrawerViewport computation
that uses width and isMobile from the useMediaQuery hook) to optimistically
assume mobile when width is undefined (or otherwise render a skeleton) so that
isMobileDrawerViewport becomes true on initial render, which prevents
currentArea from being null and avoids the layout/data-fetch flash for routes
like /program/messages, /program/campaigns, and /program/payouts/success.


return pathname.startsWith("/account/settings")
? "userSettings"
: pathname.startsWith(`/${slug}/settings`)
? "workspaceSettings"
: pathname.includes("/program/campaigns/") ||
pathname.includes("/program/messages/") ||
pathname.endsWith("/program/payouts/success")
? null
: focusedProgramPath
? isMobileDrawerViewport
? "program"
: null
: pathname.startsWith(`/${slug}/program`)
? "program"
: "default";
}, [slug, pathname]);
}, [isMobile, pathname, slug, width]);

const { program } = useProgram({
enabled: Boolean(currentArea === "program" && defaultProgramId),
Expand Down
4 changes: 4 additions & 0 deletions apps/web/ui/messages/messages-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ export type MessagesPanel = "index" | "main";
export const MessagesContext = createContext<{
currentPanel: MessagesPanel;
setCurrentPanel: Dispatch<SetStateAction<MessagesPanel>>;
targetThreadId: string | null;
setTargetThreadId: Dispatch<SetStateAction<string | null>>;
}>({
currentPanel: "index",
setCurrentPanel: () => {},
targetThreadId: null,
setTargetThreadId: () => {},
});

export function useMessagesContext() {
Expand Down
7 changes: 5 additions & 2 deletions apps/web/ui/messages/messages-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export function MessagesList({
| undefined;
activeId?: string;
}) {
const { setCurrentPanel } = useMessagesContext();
const { setCurrentPanel, setTargetThreadId } = useMessagesContext();

return (
<div className="flex w-full flex-col">
Expand All @@ -43,7 +43,10 @@ export function MessagesList({
<Link
key={group.id}
href={group.href}
onClick={() => setCurrentPanel("main")}
onClick={() => {
setTargetThreadId(group.id);
setCurrentPanel("main");
}}
className={cn(
"border-border-subtle flex w-full items-center gap-2.5 border-b bg-white px-6 py-4",
group.id === activeId ? "bg-bg-subtle" : "hover:bg-bg-muted",
Expand Down
4 changes: 3 additions & 1 deletion apps/web/ui/messages/messages-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export function MessagesPanel({
partner,
onSendMessage,
placeholder,
autoFocusComposer,
error,
}: {
messages?: (Message & { delivered?: boolean })[];
Expand All @@ -40,6 +41,7 @@ export function MessagesPanel({
partner?: Pick<PartnerProps, "name">;
onSendMessage: (message: string) => void;
placeholder?: string;
autoFocusComposer?: boolean;
error?: any;
}) {
const { isMobile } = useMediaQuery();
Expand Down Expand Up @@ -286,7 +288,7 @@ export function MessagesPanel({
<MessageInput
placeholder={personalizedPlaceholder}
onSendMessage={sendMessage}
autoFocus={!isMobile}
autoFocus={!isMobile || autoFocusComposer}
/>
</div>
</div>
Expand Down