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
10 changes: 5 additions & 5 deletions src/components/CommandPaletteDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,12 +156,12 @@ export function CommandPaletteDialog({ onClose }: CommandPaletteDialogProps) {
className="relative w-full max-w-full overflow-hidden border border-slate-800 bg-white dark:bg-slate-900 shadow-2xl animate-in fade-in zoom-in-95 duration-200 motion-reduce:animate-none motion-reduce:duration-0 motion-reduce:transform-none sm:max-w-[640px] sm:rounded-xl"
>
<div className="flex min-h-[56px] items-center border-b border-slate-800 px-4">
<Search className="mr-3 h-5 w-5 shrink-0 text-slate-400" />
<Search className="mr-3 h-5 w-5 shrink-0 text-slate-500 dark:text-slate-400" />
<input
ref={inputRef}
type="text"
placeholder="Search routes and actions... (Cmd+K)"
className="flex-1 border-none bg-transparent pt-[1px] text-slate-200 outline-none placeholder:text-slate-500"
className="flex-1 border-none bg-transparent pt-[1px] text-slate-900 dark:text-slate-200 outline-none placeholder:text-slate-400 dark:placeholder:text-slate-500"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
Expand All @@ -182,7 +182,7 @@ export function CommandPaletteDialog({ onClose }: CommandPaletteDialogProps) {
className="max-h-[300px] overflow-y-auto p-2 sm:max-h-[400px]"
>
{filteredCommands.length === 0 && (
<li className="p-4 text-center text-sm text-slate-500">
<li className="p-4 text-center text-sm text-slate-600 dark:text-slate-500">
No results found for &quot;{query}&quot;
</li>
)}
Expand All @@ -198,12 +198,12 @@ export function CommandPaletteDialog({ onClose }: CommandPaletteDialogProps) {
"flex min-h-[44px] cursor-pointer items-center rounded-md px-4 py-2 text-sm transition-colors motion-reduce:transition-none",
isSelected
? "bg-slate-800 text-sky-400"
: "text-slate-400 hover:bg-slate-800/50 hover:text-slate-200",
: "text-slate-600 dark:text-slate-400 hover:bg-slate-800/50 hover:text-slate-900 dark:hover:text-slate-200",
)}
onClick={() => command.action()}
onMouseEnter={() => setSelectedIndex(index)}
>
<command.icon className="mr-3 h-4 w-4 shrink-0" />
<command.icon className="mr-3 h-4 w-4 shrink-0 text-slate-500 dark:text-slate-400" />
<span>{command.name}</span>
</li>
);
Expand Down
64 changes: 64 additions & 0 deletions src/components/WalletConnectButton.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import test from "node:test";

/**
* Tests for WalletConnectButton modal detection logic.
* The component uses MutationObserver to detect wallet-kit modals and inject
* custom messaging. The fix ensures that modal detection works when the
* matching class is on a descendant element, not just the directly-added node.
*/

test("WalletConnectButton — modal detection finds descendant with swk class", () => {
// Simulate the logic: check if node or any descendant matches
const mockNode = {
querySelector: (selector: string) => {
if (selector === '[class*="swk"]') {
return { classList: { contains: () => true } } as any;
}
return null;
},
} as any;

const modalElement = mockNode.querySelector('[class*="swk"]');
assert.ok(modalElement, "Should find descendant with swk class");
});

test("WalletConnectButton — modal detection finds descendant with modal class", () => {
const mockNode = {
querySelector: (selector: string) => {
if (selector === '[class*="modal"]') {
return { classList: { contains: () => true } } as any;
}
return null;
},
} as any;

const modalElement = mockNode.querySelector('[class*="modal"]');
assert.ok(modalElement, "Should find descendant with modal class");
});

test("WalletConnectButton — modal detection returns null when no match", () => {
const mockNode = {
querySelector: () => null,
} as any;

const modalElement = mockNode.querySelector('[class*="swk"]') || mockNode.querySelector('[class*="modal"]');
assert.equal(modalElement, null, "Should return null when no matching descendant");
});

test("WalletConnectButton — prioritizes swk class over modal class", () => {
const mockNode = {
querySelector: (selector: string) => {
if (selector === '[class*="swk"]') {
return { id: "swk-modal" } as any;
}
if (selector === '[class*="modal"]') {
return { id: "generic-modal" } as any;
}
return null;
},
} as any;

const modalElement = mockNode.querySelector('[class*="swk"]') || mockNode.querySelector('[class*="modal"]');
assert.equal((modalElement as any).id, "swk-modal", "Should prioritize swk class");
});
18 changes: 11 additions & 7 deletions src/components/WalletConnectButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,17 @@ export default function WalletConnectButton({
for (const mutation of mutationsList) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
for (const node of Array.from(mutation.addedNodes)) {
if (node instanceof HTMLElement && (node.matches('[class*="swk"]') || node.matches('[class*="modal"]'))) {
handleModalMutations(node);
const newModalObserver = new MutationObserver(() => handleModalMutations(node));
newModalObserver.observe(node, { childList: true, subtree: true });
modalObserverRef.current = newModalObserver;
observer.disconnect(); // Disconnect the root observer once the modal is found
return;
if (node instanceof HTMLElement) {
// Check if the node itself or any descendant matches the modal selector
const modalElement = node.querySelector('[class*="swk"]') || node.querySelector('[class*="modal"]');
if (modalElement) {
handleModalMutations(modalElement);
const newModalObserver = new MutationObserver(() => handleModalMutations(modalElement));
newModalObserver.observe(modalElement, { childList: true, subtree: true });
modalObserverRef.current = newModalObserver;
observer.disconnect(); // Disconnect the root observer once the modal is found
return;
}
}
}
}
Expand Down
21 changes: 14 additions & 7 deletions src/components/notifications/ToastProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
type ReactNode,
Expand Down Expand Up @@ -203,17 +205,17 @@ export function ToastProvider({
const [limit, setLimitState] = useState(defaultLimit);
const countRef = useRef(0);

const dismissToast = (id: string) => {
const dismissToast = useCallback((id: string) => {
setToasts((current) => current.filter((toast) => toast.id !== id));
};
}, []);

const setLimit = (nextLimit: number) => {
const setLimit = useCallback((nextLimit: number) => {
const safeLimit = Math.min(5, Math.max(1, nextLimit));
setLimitState(safeLimit);
setToasts((current) => current.slice(0, safeLimit));
};
}, []);

const pushToast = ({ title, description, variant = "info", duration }: ToastInput) => {
const pushToast = useCallback(({ title, description, variant = "info", duration }: ToastInput) => {
countRef.current += 1;
const id = `${baseId}-${countRef.current}`;
const toast: ToastRecord = {
Expand All @@ -226,10 +228,15 @@ export function ToastProvider({

setToasts((current) => [toast, ...current].slice(0, limit));
return id;
};
}, [baseId, limit]);

const contextValue = useMemo(
() => ({ toasts, limit, setLimit, pushToast, dismissToast }),
[toasts, limit, setLimit, pushToast, dismissToast],
);

return (
<ToastContext.Provider value={{ toasts, limit, setLimit, pushToast, dismissToast }}>
<ToastContext.Provider value={contextValue}>
{children}
<ToastViewport toasts={toasts} onDismiss={dismissToast} />
</ToastContext.Provider>
Expand Down
31 changes: 30 additions & 1 deletion src/hooks/useNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,43 @@ import { STORAGE_KEYS } from "@/lib/storage-keys";

const NOTIFICATION_STORAGE_KEY = STORAGE_KEYS.NOTIFICATIONS_LIST;

function isValidNotification(item: unknown): item is Notification {
if (typeof item !== "object" || item === null) return false;
const notification = item as Record<string, unknown>;
return (
typeof notification.id === "string" &&
typeof notification.title === "string" &&
typeof notification.message === "string" &&
typeof notification.timestamp === "string" &&
typeof notification.status === "string" &&
typeof notification.isRead === "boolean" &&
(notification.action === undefined ||
(typeof notification.action === "object" &&
notification.action !== null &&
typeof (notification.action as Record<string, unknown>).label === "string" &&
typeof (notification.action as Record<string, unknown>).href === "string"))
);
}

export function useNotifications() {
const [notifications, setNotifications] = useState<Notification[]>(() => {
if (typeof window === "undefined") return MOCK_NOTIFICATIONS;
const stored = localStorage.getItem(NOTIFICATION_STORAGE_KEY);
if (stored) {
try {
const parsed = JSON.parse(stored);
return Array.isArray(parsed) ? parsed : MOCK_NOTIFICATIONS;
if (!Array.isArray(parsed)) {
localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(MOCK_NOTIFICATIONS));
return MOCK_NOTIFICATIONS;
}
// Validate each item's shape
const validNotifications = parsed.filter(isValidNotification);
if (validNotifications.length !== parsed.length) {
// Some items were invalid - replace with validated set
localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(validNotifications));
return validNotifications.length > 0 ? validNotifications : MOCK_NOTIFICATIONS;
}
return validNotifications;
} catch {
// Malformed JSON - fallback to mock and clear corrupted storage
localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(MOCK_NOTIFICATIONS));
Expand Down
Loading