Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
24 changes: 21 additions & 3 deletions apps/game-client/src/api/characters.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ export type MargonemCharacter = {
world?: string;
};

export const normalizeCharacterList = (
characters: unknown,
): MargonemCharacter[] => {
if (!Array.isArray(characters)) {
return [];
}

return characters.filter((character): character is MargonemCharacter => {
return (
typeof character === "object" &&
character !== null &&
typeof character.id === "number"
);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};

type FetchCharacterListOptions = {
accountId: number;
world: string | undefined;
Expand All @@ -39,9 +55,11 @@ export async function fetchCharacterList({
MargonemCharacter[]
> | null;

const cached = accountId ? (charlist?.[accountId] ?? null) : null;
const cached = accountId
? normalizeCharacterList(charlist?.[accountId] ?? null)
: [];

if (cached) {
if (cached.length > 0) {
return cached
.filter((character) => character.world === world)
.sort((a, b) => b.lvl - a.lvl);
Expand All @@ -66,7 +84,7 @@ export async function fetchCharacterList({
throw new Error("Empty character list received from API");
}

return response.data
return normalizeCharacterList(response.data)
.filter((character) => character.world === world)
.sort((a, b) => b.lvl - a.lvl);
}
12 changes: 9 additions & 3 deletions apps/game-client/src/components/delete-timer-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import {
import { ContextMenuItem } from "@/components/ui/context-menu";
import { fetchGuildPermissions } from "@/api";
import { useGuilds } from "@/hooks/api/use-guilds";
import {
getGuildPermissionsQueryKey,
normalizeGuildPermissions,
} from "@/hooks/api/use-guild-permissions";
import type { TimerWithTimeLeft } from "@/features/timers/utils/timers-utils";
import { cn } from "@/lib/utils";
import { Permission } from "@lootlog/types";
Expand Down Expand Up @@ -42,7 +46,7 @@ export const DeleteTimerPopover: FC<DeleteTimerPopoverProps> = ({

const permissionsQueries = useQueries({
queries: uniqueGuildIds.map((guildId) => ({
queryKey: ["guild-permissions", guildId],
queryKey: getGuildPermissionsQueryKey(guildId),
queryFn: () => fetchGuildPermissions(guildId),
staleTime: 5 * 60 * 1000,
})),
Expand All @@ -51,9 +55,11 @@ export const DeleteTimerPopover: FC<DeleteTimerPopoverProps> = ({
const guildsWithPermissions = useMemo(() => {
return uniqueGuildIds
.map((guildId, index) => {
const permissions = permissionsQueries[index]?.data;
const permissions = normalizeGuildPermissions(
permissionsQueries[index]?.data,
);
const canDelete = REQUIRED_DELETE_PERMISSIONS.some((perm) =>
permissions?.includes(perm),
permissions.includes(perm),
);
const entry = guildEntries.find((e) => e.guildId === guildId);
return {
Expand Down
98 changes: 98 additions & 0 deletions apps/game-client/src/components/guild-switcher.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Guild } from "@/api";
import { GuildSwitcher } from "./guild-switcher";

const mockUseGuilds = vi.fn();
const mockUseUserPreferences = vi.fn();

vi.mock("@/hooks/api/use-guilds", () => ({
useGuilds: () => mockUseGuilds(),
}));

vi.mock("@/hooks/api/use-user-preferences", () => ({
useUserPreferences: () => mockUseUserPreferences(),
}));

vi.mock("@/lib/game", () => ({
Game: {
hero: {
id: 123,
},
},
}));

const createGuild = (id: string, name: string): Guild => ({
id,
name,
icon: null,
});

describe("GuildSwitcher", () => {
beforeEach(() => {
vi.clearAllMocks();

mockUseGuilds.mockReturnValue({
data: [
createGuild("guild-1", "Alpha"),
createGuild("guild-2", "Beta"),
createGuild("guild-3", "Gamma"),
],
isFetched: true,
});
mockUseUserPreferences.mockReturnValue({
data: {
guildsOrder: [],
},
});
});

it("renders guilds in the user preferences order and appends missing guilds", () => {
mockUseUserPreferences.mockReturnValue({
data: {
guildsOrder: ["guild-2", "guild-1"],
},
});

render(<GuildSwitcher />);

expect(
screen.getAllByRole("button").map((button) => button.textContent),
).toEqual(["B", "A", "G"]);
});

it("falls back to the first ordered guild when the current selection is missing", async () => {
const handleChange = vi.fn();

mockUseUserPreferences.mockReturnValue({
data: {
guildsOrder: ["guild-2", "guild-1"],
},
});

render(<GuildSwitcher value="missing-guild" onChange={handleChange} />);

await waitFor(() => {
expect(handleChange).toHaveBeenCalledWith("guild-2");
});
});

it("scrolls horizontally when the user uses the mouse wheel", () => {
const { container } = render(<GuildSwitcher />);
const viewport = container.querySelector(
"[data-radix-scroll-area-viewport]",
);

expect(viewport).not.toBeNull();

Object.defineProperty(viewport, "scrollLeft", {
configurable: true,
value: 0,
writable: true,
});

fireEvent.wheel(viewport as HTMLElement, { deltaY: 48 });

expect((viewport as HTMLElement).scrollLeft).toBe(48);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
72 changes: 50 additions & 22 deletions apps/game-client/src/components/guild-switcher.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { cn } from "@/lib/utils";
import { useGuilds } from "@/hooks/api/use-guilds";
import { useUserPreferences } from "@/hooks/api/use-user-preferences";
import { useSettingsStore } from "@/store/settings.store";
import { TooltipProvider } from "@/components/ui/tooltip";
import { AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { type FC, useEffect, useRef } from "react";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import type { Viewport } from "@radix-ui/react-scroll-area";
import { Game } from "@/lib/game";
import { GuildButton } from "@/components/guild-button";
import type { Guild } from "@/api";

type GuildSwitcherProps = {
disabled?: boolean;
Expand All @@ -23,6 +24,27 @@ type GuildSwitcherProps = {
value?: string;
};

const orderGuilds = (guilds: Guild[] | undefined, guildsOrder?: string[]) => {
if (!guilds?.length) {
return [];
}

if (!guildsOrder?.length) {
return guilds;
}

const guildsById = new Map(guilds.map((guild) => [guild.id, guild] as const));
const orderedGuilds = guildsOrder
.map((guildId) => guildsById.get(guildId))
.filter((guild): guild is Guild => guild !== undefined);
const orderedGuildIds = new Set(orderedGuilds.map((guild) => guild.id));
const remainingGuilds = guilds.filter(
(guild) => !orderedGuildIds.has(guild.id),
);

return [...orderedGuilds, ...remainingGuilds];
};

export const GuildSwitcher: FC<GuildSwitcherProps> = ({
disabled = false,
allowAll = false,
Expand All @@ -36,31 +58,40 @@ export const GuildSwitcher: FC<GuildSwitcherProps> = ({
selectedValues,
value,
}) => {
const scrollContainerRef = useRef<React.ElementRef<typeof Viewport>>(null);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const characterId = String(Game.hero.id);
const { data: guilds, isFetched } = useGuilds();
const { data: userPreferences } = useUserPreferences();
const { setGuildId, guildIdByCharId } = useSettingsStore();
const guildsOrder = userPreferences?.guildsOrder;
const orderedGuilds = orderGuilds(guilds, guildsOrder);

const guildId = guildIdByCharId[characterId];

useEffect(() => {
if (!isFetched || !guilds || guilds.length === 0) return;
const orderedGuildsForSelection = orderGuilds(guilds, guildsOrder);

if (!isFetched || orderedGuildsForSelection.length === 0) return;
if (multiple) return;
const currentValue = value !== undefined ? value : guildId;
if (allowAll && currentValue === "all") return;
const exists = guilds.some((guild) => guild.id === currentValue);
const exists = orderedGuildsForSelection.some(
(guild) => guild.id === currentValue,
);
if (exists) return;
if (onChange) {
onChange(guilds[0].id);
onChange(orderedGuildsForSelection[0].id);
} else {
setGuildId(characterId, guilds[0].id);
setGuildId(characterId, orderedGuildsForSelection[0].id);
}
}, [
isFetched,
guilds,
guildsOrder,
guildId,
value,
allowAll,
multiple,
onChange,
characterId,
setGuildId,
Expand All @@ -86,23 +117,19 @@ export const GuildSwitcher: FC<GuildSwitcherProps> = ({
setGuildId(characterId, newGuildId);
};

useEffect(() => {
if (layout !== "scroll") return;
const handleWheel = (event: React.WheelEvent<HTMLDivElement>) => {
if (layout !== "scroll" || event.deltaY === 0) {
return;
}

const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) return;

const handleWheel = (e: WheelEvent) => {
e.preventDefault();
scrollContainer.scrollLeft += e.deltaY;
};

scrollContainer.addEventListener("wheel", handleWheel, { passive: false });
if (!scrollContainer) {
return;
}

return () => {
scrollContainer.removeEventListener("wheel", handleWheel);
};
}, []);
event.preventDefault();
scrollContainer.scrollLeft += event.deltaY;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

const content = (
<>
Expand All @@ -120,7 +147,7 @@ export const GuildSwitcher: FC<GuildSwitcherProps> = ({
</AvatarFallback>
</GuildButton>
)}
{guilds?.map((guild) => (
{orderedGuilds.map((guild) => (
<GuildButton
key={guild.id}
isSelected={
Expand Down Expand Up @@ -167,9 +194,10 @@ export const GuildSwitcher: FC<GuildSwitcherProps> = ({
<ScrollArea
className={cn("ll:w-full", className)}
ref={scrollContainerRef}
onWheel={handleWheel}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use a non-passive wheel listener for horizontal scroll

onWheel is now wired through React (<ScrollArea onWheel={handleWheel} />), but React 19 registers wheel handlers as passive, so event.preventDefault() in handleWheel is ignored. In scroll layout this means mouse-wheel input will also trigger native vertical page/container scrolling while you manually mutate scrollLeft, which regresses the previous behavior that used a native { passive: false } listener to prevent vertical scrolling during horizontal guild switching.

Useful? React with 👍 / 👎.

type="hover"
>
<div className="ll:flex ll:gap-1 ll:mt-1 ll:overflow-hidden">
<div className="ll:mt-1 ll:flex ll:w-max ll:min-w-full ll:gap-1">
{content}
</div>
<ScrollBar
Expand Down
10 changes: 7 additions & 3 deletions apps/game-client/src/features/timers/components/single-timer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ import { useTimerActions } from "../hooks/use-timer-actions";
import { useTimerDisplay } from "../hooks/use-timer-display";
import { TimerContextMenuContent } from "./timer-context-menu-content";
import { TimerTooltip } from "./timer-tooltip";
import { useGuildPermissions } from "@/hooks/api/use-guild-permissions";
import {
normalizeGuildPermissions,
useGuildPermissions,
} from "@/hooks/api/use-guild-permissions";
import { REQUIRED_DELETE_PERMISSIONS } from "../constants/required-delete-permissions";
import { Game } from "@/lib/game";
import { REQUIRED_RESET_PERMISSIONS } from "@/features/timers/constants/required-reset-permissions";
Expand Down Expand Up @@ -55,13 +58,14 @@ export const SingleTimer: FC<SingleTimerProps> = ({
const { data: guildPermissions } = useGuildPermissions({
guildId: timer.guildId,
});
const normalizedGuildPermissions = normalizeGuildPermissions(guildPermissions);

const canDelete = REQUIRED_DELETE_PERMISSIONS.some((perm) =>
guildPermissions?.includes(perm),
normalizedGuildPermissions.includes(perm),
);

const canReset = REQUIRED_RESET_PERMISSIONS.some((perm) =>
guildPermissions?.includes(perm),
normalizedGuildPermissions.includes(perm),
);

const {
Expand Down
Loading
Loading