feat(desktop): add drag-to-reorder for community rail (#2549)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-07-23 13:28:53 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent d0ab3fdb05
commit 1e68c6c050
6 changed files with 493 additions and 38 deletions
+1 -1
View File
@@ -71,7 +71,7 @@ export default defineConfig({
"**/home-collapsed-top-chrome.spec.ts",
"**/top-chrome-zoom-clearance.spec.ts",
"**/thread-unread.spec.ts",
"**/workspace-rail.spec.ts",
"**/community-rail.spec.ts",
"**/boot-splash.spec.ts",
"**/thread-reply-anchor-roleplay.spec.ts",
"**/threadpane-ultrawide.spec.ts",
+1
View File
@@ -714,6 +714,7 @@ export function AppShell() {
}
onAddCommunity={addCommunityDialog.openDialog}
onRemoveCommunity={communitiesHook.removeCommunity}
onReorderCommunities={communitiesHook.reorderCommunities}
onSwitchCommunity={handleSwitchCommunity}
onUpdateCommunity={communitiesHook.updateCommunity}
communities={communitiesHook.communities}
@@ -0,0 +1,110 @@
/**
* Unit tests for applyCommunitiesOrder — the pure permutation helper that
* drives community-rail drag-to-reorder.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { applyCommunitiesOrder } from "./useCommunities.tsx";
const A = {
id: "ws-a",
name: "Alpha",
relayUrl: "wss://a.example.com",
addedAt: "2024-01-01",
};
const B = {
id: "ws-b",
name: "Bravo",
relayUrl: "wss://b.example.com",
addedAt: "2024-01-02",
};
const C = {
id: "ws-c",
name: "Charlie",
relayUrl: "wss://c.example.com",
addedAt: "2024-01-03",
};
describe("applyCommunitiesOrder", () => {
it("reorders communities to match orderedIds", () => {
const result = applyCommunitiesOrder([A, B, C], ["ws-c", "ws-a", "ws-b"]);
assert.deepEqual(
result.map((c) => c.id),
["ws-c", "ws-a", "ws-b"],
);
});
it("returns same order when orderedIds matches current order", () => {
const result = applyCommunitiesOrder([A, B, C], ["ws-a", "ws-b", "ws-c"]);
assert.deepEqual(
result.map((c) => c.id),
["ws-a", "ws-b", "ws-c"],
);
});
it("appends communities not mentioned in orderedIds at the end in original relative order", () => {
// C was added after drag — not in orderedIds — should tail-append
const result = applyCommunitiesOrder([A, B, C], ["ws-b", "ws-a"]);
assert.deepEqual(
result.map((c) => c.id),
["ws-b", "ws-a", "ws-c"],
);
});
it("handles orderedIds that contain stale IDs not present in communities", () => {
// "ws-gone" is a stale id — silent skip, no crash
const result = applyCommunitiesOrder([A, B], ["ws-b", "ws-gone", "ws-a"]);
assert.deepEqual(
result.map((c) => c.id),
["ws-b", "ws-a"],
);
});
it("returns the full list when orderedIds is empty — original order preserved", () => {
const result = applyCommunitiesOrder([A, B, C], []);
assert.deepEqual(
result.map((c) => c.id),
["ws-a", "ws-b", "ws-c"],
);
});
it("handles a single-element list (no-op reorder)", () => {
const result = applyCommunitiesOrder([A], ["ws-a"]);
assert.deepEqual(
result.map((c) => c.id),
["ws-a"],
);
});
it("handles an empty communities list", () => {
const result = applyCommunitiesOrder([], ["ws-a", "ws-b"]);
assert.deepEqual(result, []);
});
it("does not mutate the original array", () => {
const original = [A, B, C];
applyCommunitiesOrder(original, ["ws-c", "ws-a", "ws-b"]);
assert.deepEqual(
original.map((c) => c.id),
["ws-a", "ws-b", "ws-c"],
);
});
it("preserves object identity of each community (no clone)", () => {
const result = applyCommunitiesOrder([A, B, C], ["ws-c", "ws-b", "ws-a"]);
assert.equal(result[0], C);
assert.equal(result[1], B);
assert.equal(result[2], A);
});
it("handles duplicate IDs in orderedIds — first occurrence wins", () => {
// Defensive: dnd-kit should never produce duplicates, but guard anyway.
const result = applyCommunitiesOrder([A, B, C], ["ws-b", "ws-b", "ws-a"]);
// ws-b appears once, ws-a once, ws-c appended
assert.deepEqual(
result.map((c) => c.id),
["ws-b", "ws-a", "ws-c"],
);
});
});
@@ -72,6 +72,42 @@ export function resolveCommunityUpdateResult(
return { kind: "updated", requiresReinit: backendFieldsChanged };
}
/**
* Permute `communities` so that its order matches `orderedIds`.
*
* - Communities whose id appears in `orderedIds` are placed first, in the
* order given by `orderedIds`.
* - Communities not mentioned in `orderedIds` (e.g. added after the drag
* completed) are appended at the end in their original relative order.
*
* Pure and side-effect-free — extracted so it can be unit-tested without
* a DOM or React.
*/
export function applyCommunitiesOrder(
communities: Community[],
orderedIds: string[],
): Community[] {
const byId = new Map(communities.map((c) => [c.id, c]));
const seen = new Set<string>();
const reordered: Community[] = [];
for (const id of orderedIds) {
const c = byId.get(id);
if (c && !seen.has(id)) {
reordered.push(c);
seen.add(id);
}
}
for (const c of communities) {
if (!seen.has(c.id)) {
reordered.push(c);
}
}
return reordered;
}
export type UseCommunitiesReturn = {
communities: Community[];
activeCommunity: Community | null;
@@ -90,6 +126,8 @@ export type UseCommunitiesReturn = {
Pick<Community, "name" | "relayUrl" | "token" | "pubkey" | "reposDir">
>,
) => UpdateCommunityResult;
/** Persist a new display order for the rail. IDs not in orderedIds keep their relative position at the end. */
reorderCommunities: (orderedIds: string[]) => void;
};
const CommunitiesContext = createContext<UseCommunitiesReturn | null>(null);
@@ -242,6 +280,14 @@ function useCommunitiesInternal(): UseCommunitiesReturn {
[activeId],
);
const reorderCommunities = useCallback((orderedIds: string[]) => {
setCommunitiesState((prev) => {
const next = applyCommunitiesOrder(prev, orderedIds);
saveCommunities(next);
return next;
});
}, []);
return {
communities,
activeCommunity,
@@ -252,5 +298,6 @@ function useCommunitiesInternal(): UseCommunitiesReturn {
switchCommunity,
reconnectCommunity,
updateCommunity,
reorderCommunities,
};
}
+190 -37
View File
@@ -1,3 +1,20 @@
import {
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { CheckCheck, Link2, Plus, Settings2 } from "lucide-react";
import * as React from "react";
@@ -33,6 +50,7 @@ type CommunityRailProps = {
updates: Partial<Pick<Community, "name" | "relayUrl" | "token">>,
) => void;
onRemoveCommunity: (id: string) => void;
onReorderCommunities: (orderedIds: string[]) => void;
};
const MAX_BADGE = 99;
@@ -76,6 +94,9 @@ function CommunityButton({
iconUrl,
onSwitch,
menu,
dragListeners,
dragAttributes,
isDragging,
}: {
community: Community;
isActive: boolean;
@@ -83,6 +104,9 @@ function CommunityButton({
iconUrl: string | null;
onSwitch: () => void;
menu: React.ReactNode;
dragListeners?: React.HTMLAttributes<HTMLElement>;
dragAttributes?: React.HTMLAttributes<HTMLElement>;
isDragging?: boolean;
}) {
const { mentionCount, showBadge, showDot, pending, badgeLabel } =
communityRailIndicators(unread);
@@ -101,10 +125,15 @@ function CommunityButton({
<button
aria-current={isActive ? "true" : undefined}
aria-label={tooltipLabel}
className="relative flex h-9 w-9 items-center justify-center outline-hidden focus:outline-none focus-visible:outline-none"
className={cn(
"relative flex h-9 w-9 items-center justify-center touch-none outline-hidden focus:outline-none focus-visible:outline-none",
isDragging && "opacity-30",
)}
data-testid={`community-rail-button-${community.id}`}
onClick={onSwitch}
type="button"
{...dragAttributes}
{...dragListeners}
>
<span
className={cn(
@@ -154,6 +183,105 @@ function CommunityButton({
);
}
function CommunityDragOverlay({
community,
iconUrl,
}: {
community: Community;
iconUrl: string | null;
}) {
return (
<div
className="flex h-9 w-9 cursor-grabbing items-center justify-center overflow-hidden rounded-xl bg-primary text-xs font-semibold text-primary-foreground opacity-90 shadow-lg ring-1 ring-sidebar-border"
data-buzz-flat
>
{iconUrl ? (
<img
alt=""
className="h-full w-full object-cover"
draggable={false}
src={iconUrl}
/>
) : (
getInitials(community.name) || "🐝"
)}
</div>
);
}
function SortableCommunityButton({
community,
activeCommunityId,
iconsByCommunity,
unreadByCommunity,
onSwitchCommunity,
onMarkAllRead,
onSetEditingCommunity,
}: {
community: Community;
activeCommunityId: string | null;
iconsByCommunity: Record<string, string | null | undefined>;
unreadByCommunity: Record<string, CommunityUnreadState>;
onSwitchCommunity: (id: string) => void;
onMarkAllRead: (community: Community) => void;
onSetEditingCommunity: (community: Community) => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: community.id });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div ref={setNodeRef} style={style}>
<CommunityButton
community={community}
dragAttributes={attributes}
dragListeners={listeners}
iconUrl={iconsByCommunity[community.id] ?? null}
isActive={community.id === activeCommunityId}
isDragging={isDragging}
menu={
<>
<ContextMenuItem onClick={() => onMarkAllRead(community)}>
<CheckCheck className="h-4 w-4" />
Mark all as read
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
void writeTextToClipboard(community.relayUrl);
}}
>
<Link2 className="h-4 w-4" />
Copy relay URL
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onSetEditingCommunity(community)}>
<Settings2 className="h-4 w-4" />
Community settings
</ContextMenuItem>
</>
}
onSwitch={() => onSwitchCommunity(community.id)}
unread={
unreadByCommunity[community.id] ?? {
hasUnread: false,
state: "unknown",
}
}
/>
</div>
);
}
/**
* Discord/Slack-style vertical rail of communities on the far left of the app.
* Shows a mention-count badge for inactive communities (observed via
@@ -169,6 +297,7 @@ export function CommunityRail({
onAddCommunity,
onUpdateCommunity,
onRemoveCommunity,
onReorderCommunities,
}: CommunityRailProps) {
const { unreadByCommunity, markCommunityRead } = useCommunityUnread(
communities,
@@ -179,10 +308,39 @@ export function CommunityRail({
const { markAllChannelsRead } = useAppShell();
const [editingCommunity, setEditingCommunity] =
React.useState<Community | null>(null);
const [draggingId, setDraggingId] = React.useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
if (communities.length <= 1) {
return null;
}
const communityIds = communities.map((c) => c.id);
const draggingCommunity = draggingId
? (communities.find((c) => c.id === draggingId) ?? null)
: null;
const handleDragStart = (event: DragStartEvent) => {
setDraggingId(event.active.id as string);
};
const handleDragEnd = (event: DragEndEvent) => {
setDraggingId(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIdx = communityIds.indexOf(active.id as string);
const newIdx = communityIds.indexOf(over.id as string);
if (oldIdx !== -1 && newIdx !== -1) {
onReorderCommunities(arrayMove(communityIds, oldIdx, newIdx));
}
};
const handleMarkAllRead = (community: Community) => {
if (community.id === activeCommunityId) {
markAllChannelsRead();
@@ -211,42 +369,37 @@ export function CommunityRail({
)}
data-testid="community-rail"
>
{communities.map((community) => (
<CommunityButton
key={community.id}
iconUrl={iconsByCommunity[community.id] ?? null}
isActive={community.id === activeCommunityId}
menu={
<>
<ContextMenuItem onClick={() => handleMarkAllRead(community)}>
<CheckCheck className="h-4 w-4" />
Mark all as read
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
void writeTextToClipboard(community.relayUrl);
}}
>
<Link2 className="h-4 w-4" />
Copy relay URL
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => setEditingCommunity(community)}>
<Settings2 className="h-4 w-4" />
Community settings
</ContextMenuItem>
</>
}
onSwitch={() => onSwitchCommunity(community.id)}
unread={
unreadByCommunity[community.id] ?? {
hasUnread: false,
state: "unknown",
}
}
community={community}
/>
))}
<DndContext
onDragEnd={handleDragEnd}
onDragStart={handleDragStart}
sensors={sensors}
>
<SortableContext
items={communityIds}
strategy={verticalListSortingStrategy}
>
{communities.map((community) => (
<SortableCommunityButton
key={community.id}
activeCommunityId={activeCommunityId}
community={community}
iconsByCommunity={iconsByCommunity}
unreadByCommunity={unreadByCommunity}
onMarkAllRead={handleMarkAllRead}
onSetEditingCommunity={setEditingCommunity}
onSwitchCommunity={onSwitchCommunity}
/>
))}
</SortableContext>
<DragOverlay>
{draggingCommunity ? (
<CommunityDragOverlay
community={draggingCommunity}
iconUrl={iconsByCommunity[draggingCommunity.id] ?? null}
/>
) : null}
</DragOverlay>
</DndContext>
<Tooltip>
<TooltipTrigger asChild>
<button
+144
View File
@@ -216,4 +216,148 @@ test.describe("community rail", () => {
expect(toggleBox).not.toBeNull();
expect(toggleBox?.x ?? 0).toBeLessThan(120);
});
test("drag-to-reorder updates the stored community order and survives reload", async ({
page,
}) => {
await installMockBridge(page, undefined, { skipCommunitySeed: true });
// Seed only if not already set so the persisted order survives page.reload().
await page.addInitScript(
({ list, active }) => {
if (!window.localStorage.getItem("buzz-communities")) {
window.localStorage.setItem("buzz-communities", JSON.stringify(list));
}
if (!window.localStorage.getItem("buzz-active-community-id")) {
window.localStorage.setItem("buzz-active-community-id", active);
}
},
{ list: [COMMUNITY_A, COMMUNITY_B], active: COMMUNITY_A.id },
);
await page.goto("/");
const buttonA = page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`);
const buttonB = page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`);
await expect(buttonA).toBeVisible();
await expect(buttonB).toBeVisible();
// Drag B (lower) up over A (higher) so the order becomes [B, A].
const boxA = await buttonA.boundingBox();
const boxB = await buttonB.boundingBox();
if (!boxA || !boxB) throw new Error("community buttons not laid out");
const startX = boxB.x + boxB.width / 2;
const startY = boxB.y + boxB.height / 2;
const targetY = boxA.y + boxA.height / 2;
// dnd-kit PointerSensor requires a 6px activation distance before it picks
// up the drag. Move in small steps so pointermove events fire on every pixel.
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX, startY - 3, { steps: 3 });
await page.mouse.move(startX, targetY, { steps: 20 });
await page.mouse.up();
// The community list in localStorage must now be [B, A].
await expect
.poll(() =>
page.evaluate(() => {
const raw = window.localStorage.getItem("buzz-communities");
if (!raw) return null;
const list = JSON.parse(raw) as Array<{ id: string }>;
return list.map((c) => c.id);
}),
)
.toEqual([COMMUNITY_B.id, COMMUNITY_A.id]);
// Verify the new order is also reflected in the rendered DOM — B button
// must appear above A button.
const newBoxA = await buttonA.boundingBox();
const newBoxB = await buttonB.boundingBox();
if (!newBoxA || !newBoxB)
throw new Error("community buttons not laid out after drag");
expect(newBoxB.y).toBeLessThan(newBoxA.y);
// Reload and confirm the order survives restart: addInitScript is
// conditional (no-op when data already exists), so the dragged [B, A]
// order is what React reads on boot.
await page.reload();
await expect(page.getByTestId("community-rail")).toBeVisible();
// Storage must still be [B, A] after reload.
const storedOrder = await page.evaluate(() => {
const raw = window.localStorage.getItem("buzz-communities");
if (!raw) return null;
const list = JSON.parse(raw) as Array<{ id: string }>;
return list.map((c) => c.id);
});
expect(storedOrder).toEqual([COMMUNITY_B.id, COMMUNITY_A.id]);
// DOM order must also be [B, A] after reload.
const reloadBoxA = await buttonA.boundingBox();
const reloadBoxB = await buttonB.boundingBox();
if (!reloadBoxA || !reloadBoxB)
throw new Error("community buttons not laid out after reload");
expect(reloadBoxB.y).toBeLessThan(reloadBoxA.y);
});
test("keyboard reorder: Space to pick up, ArrowUp to move, Space to drop updates stored order", async ({
page,
}) => {
await installMockBridge(page, undefined, { skipCommunitySeed: true });
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
await page.goto("/");
const buttonA = page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`);
const buttonB = page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`);
await expect(buttonA).toBeVisible();
await expect(buttonB).toBeVisible();
// Focus B (the second/lower item) and use keyboard to move it above A.
// Note: page.keyboard.press("Space") fires the button's native click on this
// Chromium build even when React's onKeyDown calls preventDefault — a CDP
// input-injection quirk. The synthetic dispatch below goes directly through
// React's event system where preventDefault correctly suppresses the click,
// while still exercising the real KeyboardSensor path (Thufir verified the
// test fails when KeyboardSensor is removed).
await buttonB.focus();
await page.evaluate((testId) => {
const el = document.querySelector(`[data-testid="${testId}"]`);
if (!el) throw new Error(`button not found: ${testId}`);
el.dispatchEvent(
new KeyboardEvent("keydown", {
key: " ",
code: "Space",
bubbles: true,
cancelable: true,
}),
);
}, `community-rail-button-${COMMUNITY_B.id}`);
// ArrowUp moves the active item one slot up.
await page.keyboard.press("ArrowUp");
// Space drops the item — same synthetic dispatch for consistency.
await page.evaluate((testId) => {
const el = document.querySelector(`[data-testid="${testId}"]`);
if (!el) throw new Error(`button not found: ${testId}`);
el.dispatchEvent(
new KeyboardEvent("keydown", {
key: " ",
code: "Space",
bubbles: true,
cancelable: true,
}),
);
}, `community-rail-button-${COMMUNITY_B.id}`);
// The community list in localStorage must now be [B, A].
await expect
.poll(() =>
page.evaluate(() => {
const raw = window.localStorage.getItem("buzz-communities");
if (!raw) return null;
const list = JSON.parse(raw) as Array<{ id: string }>;
return list.map((c) => c.id);
}),
)
.toEqual([COMMUNITY_B.id, COMMUNITY_A.id]);
});
});