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
32 changes: 32 additions & 0 deletions desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -325,3 +325,35 @@ test("readChannelSectionsStore: scoped key takes precedence over legacy key afte
const result = readChannelSectionsStore(pubkey, relay);
assert.deepEqual(result, newStore);
});

test("parseChannelSectionPayload: preserves icon field when present", () => {
const payload = {
version: 1,
sections: [{ id: "s1", name: "Work", icon: "🚀", order: 0 }],
assignments: { chan1: "s1" },
};
const result = parseChannelSectionPayload(payload);
assert.deepEqual(result, {
version: 1,
sections: [{ id: "s1", name: "Work", icon: "🚀", order: 0 }],
assignments: { chan1: "s1" },
});
});

test("parseChannelSectionPayload: omits icon field when empty or whitespace", () => {
const payload = {
version: 1,
sections: [
{ id: "s1", name: "A", icon: "", order: 0 },
{ id: "s2", name: "B", icon: " ", order: 1 },
{ id: "s3", name: "C", order: 2 },
],
assignments: {},
};
const result = parseChannelSectionPayload(payload);
assert.deepEqual(result?.sections, [
{ id: "s1", name: "A", order: 0 },
{ id: "s2", name: "B", order: 1 },
{ id: "s3", name: "C", order: 2 },
]);
});
32 changes: 24 additions & 8 deletions desktop/src/features/sidebar/lib/channelSectionsStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const STORAGE_KEY_PREFIX = "buzz-channel-sections.v1";
export type ChannelSection = {
id: string;
name: string;
icon?: string;
order: number;
};

Expand Down Expand Up @@ -54,14 +55,29 @@ export function parseChannelSectionPayload(
if (typeof json !== "object" || json === null) return null;
const obj = json as Record<string, unknown>;
const sections: ChannelSection[] = Array.isArray(obj.sections)
? obj.sections.filter(
(entry: unknown): entry is ChannelSection =>
typeof entry === "object" &&
entry !== null &&
typeof (entry as Record<string, unknown>).id === "string" &&
typeof (entry as Record<string, unknown>).name === "string" &&
typeof (entry as Record<string, unknown>).order === "number",
)
? obj.sections.flatMap((entry: unknown): ChannelSection[] => {
if (typeof entry !== "object" || entry === null) return [];
const section = entry as Record<string, unknown>;
if (
typeof section.id !== "string" ||
typeof section.name !== "string" ||
typeof section.order !== "number"
) {
return [];
}
const icon =
typeof section.icon === "string" && section.icon.trim().length > 0
? section.icon.trim()
: undefined;
return [
{
id: section.id,
name: section.name,
...(icon ? { icon } : {}),
order: section.order,
},
];
})
: [];
const assignments: Record<string, string> =
typeof obj.assignments === "object" &&
Expand Down
1 change: 1 addition & 0 deletions desktop/src/features/sidebar/lib/channelSectionsSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export class ChannelSectionSyncManager {
!last ||
last.id !== current.id ||
last.name !== current.name ||
last.icon !== current.icon ||
last.order !== current.order
)
return false;
Expand Down
18 changes: 13 additions & 5 deletions desktop/src/features/sidebar/lib/useChannelSections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ export function useChannelSections(
): {
sections: ChannelSection[];
assignments: Record<string, string>;
createSection: (name: string) => ChannelSection | null;
renameSection: (sectionId: string, newName: string) => void;
createSection: (name: string, icon?: string) => ChannelSection | null;
renameSection: (sectionId: string, newName: string, icon?: string) => void;
deleteSection: (sectionId: string) => void;
moveSectionUp: (sectionId: string) => void;
moveSectionDown: (sectionId: string) => void;
Expand Down Expand Up @@ -169,7 +169,7 @@ export function useChannelSections(
);

const createSection = React.useCallback(
(name: string): ChannelSection | null => {
(name: string, icon?: string): ChannelSection | null => {
if (!pubkey) return null;
const prev = readChannelSectionsStore(pubkey, relayUrl);
const maxOrder =
Expand All @@ -179,6 +179,7 @@ export function useChannelSections(
const section: ChannelSection = {
id: crypto.randomUUID(),
name,
...(icon ? { icon } : {}),
order: maxOrder + 1,
};
setStore((current) => {
Expand All @@ -196,15 +197,22 @@ export function useChannelSections(
);

const renameSection = React.useCallback(
(sectionId: string, newName: string) => {
(sectionId: string, newName: string, icon?: string) => {
if (!pubkey) {
return;
}
setStore((prev) => {
const next: ChannelSectionStore = {
...prev,
sections: prev.sections.map((s) =>
s.id === sectionId ? { ...s, name: newName } : s,
s.id === sectionId
? {
id: s.id,
name: newName,
...(icon ? { icon } : {}),
Comment thread
klopez4212 marked this conversation as resolved.
order: s.order,
}
: s,
),
};
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) {
Expand Down
10 changes: 6 additions & 4 deletions desktop/src/features/sidebar/ui/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
DeleteSectionAlertDialog,
RenameSectionDialog,
useLeaveChannelDialog,
type SectionDialogValue,
} from "@/features/sidebar/ui/ChannelSectionDialogs";
import { AppSidebarPinnedHeader } from "@/features/sidebar/ui/AppSidebarPinnedHeader";
import { MoreUnreadButton } from "@/features/sidebar/ui/MoreUnreadButton";
Expand Down Expand Up @@ -404,8 +405,8 @@ export function AppSidebar({
);

const handleCreateSectionConfirm = React.useCallback(
(name: string) => {
const section = createSection(name);
(value: SectionDialogValue) => {
const section = createSection(value.name, value.icon);
if (!section) {
return;
}
Expand Down Expand Up @@ -854,9 +855,10 @@ export function AppSidebar({
if (!open) setRenameSectionTarget(null);
}}
sectionName={renameSectionTarget?.name ?? ""}
onConfirm={(newName) => {
sectionIcon={renameSectionTarget?.icon}
onConfirm={(value) => {
if (renameSectionTarget) {
renameSection(renameSectionTarget.id, newName);
renameSection(renameSectionTarget.id, value.name, value.icon);
}
setRenameSectionTarget(null);
}}
Expand Down
118 changes: 96 additions & 22 deletions desktop/src/features/sidebar/ui/ChannelSectionDialogs.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import * as React from "react";

import { X } from "lucide-react";

import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
import {
AlertDialog,
AlertDialogAction,
Expand All @@ -19,19 +22,27 @@ import {
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
import { Input } from "@/shared/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import type { Channel } from "@/shared/api/types";
import { useLeaveChannelMutation } from "@/features/channels/hooks";

export type SectionDialogValue = {
name: string;
icon?: string;
};

type SectionNameDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description: string;
initialValue: string;
initialIcon?: string;
confirmLabel: string;
isConfirmDisabled: (trimmed: string) => boolean;
onConfirm: (name: string) => void;
isConfirmDisabled: (trimmed: string, icon: string) => boolean;
onConfirm: (value: SectionDialogValue) => void;
};

function SectionNameDialog({
Expand All @@ -40,28 +51,44 @@ function SectionNameDialog({
title,
description,
initialValue,
initialIcon = "",
confirmLabel,
isConfirmDisabled,
onConfirm,
}: SectionNameDialogProps) {
const [name, setName] = React.useState(initialValue);
const [icon, setIcon] = React.useState(initialIcon);
const [pickerOpen, setPickerOpen] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);

React.useEffect(() => {
if (!open) return;
if (!open) {
setPickerOpen(false);
return;
}
setName(initialValue);
setIcon(initialIcon);
// Small delay to let dialog animation start before focusing
const timerId = globalThis.setTimeout(() => {
inputRef.current?.focus();
}, 50);
return () => globalThis.clearTimeout(timerId);
}, [open, initialValue]);
}, [open, initialValue, initialIcon]);

function handleIconSelect(selectedIcon: string) {
setIcon(selectedIcon);
setPickerOpen(false);
}

function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmed = name.trim();
if (isConfirmDisabled(trimmed)) return;
onConfirm(trimmed);
const trimmedIcon = icon.trim();
if (isConfirmDisabled(trimmed, trimmedIcon)) return;
onConfirm({
name: trimmed,
...(trimmedIcon ? { icon: trimmedIcon } : {}),
});
}

return (
Expand All @@ -72,23 +99,66 @@ function SectionNameDialog({
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<Input
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
onChange={(event) => setName(event.target.value)}
placeholder="Section name"
ref={inputRef}
spellCheck={false}
value={name}
/>
<div className="flex items-center gap-2">
<Popover onOpenChange={setPickerOpen} open={pickerOpen}>
<div className="relative shrink-0">
<PopoverTrigger asChild>
<button
aria-label="Choose section icon"
className="flex h-9 w-9 items-center justify-center rounded-md border border-input text-lg transition-colors hover:bg-accent"
type="button"
>
{icon ? (
<StatusEmoji className="h-5 w-5" value={icon} />
) : (
<span className="text-sm font-medium">#</span>
)}
</button>
</PopoverTrigger>
{icon ? (
<button
aria-label="Clear section icon"
className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full border border-background bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
onClick={(event) => {
event.stopPropagation();
setIcon("");
}}
type="button"
>
<X className="h-3 w-3" />
</button>
) : null}
</div>
<PopoverContent
align="start"
className="w-auto overflow-hidden rounded-2xl p-0"
sideOffset={4}
>
<EmojiPicker onSelect={handleIconSelect} />
</PopoverContent>
</Popover>
<Input
autoCapitalize="none"
autoComplete="off"
autoCorrect="off"
className="flex-1"
onChange={(event) => setName(event.target.value)}
placeholder="Section name"
ref={inputRef}
spellCheck={false}
value={name}
/>
</div>
<div className="flex justify-end gap-2 mt-4">
<DialogClose asChild>
<Button variant="ghost" type="button">
Cancel
</Button>
</DialogClose>
<Button type="submit" disabled={isConfirmDisabled(name.trim())}>
<Button
type="submit"
disabled={isConfirmDisabled(name.trim(), icon.trim())}
>
{confirmLabel}
</Button>
</div>
Expand All @@ -101,7 +171,7 @@ function SectionNameDialog({
export type CreateSectionDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (name: string) => void;
onConfirm: (value: SectionDialogValue) => void;
};

export function CreateSectionDialog({
Expand All @@ -127,13 +197,15 @@ export type RenameSectionDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
sectionName: string;
onConfirm: (newName: string) => void;
sectionIcon?: string;
onConfirm: (value: SectionDialogValue) => void;
};

export function RenameSectionDialog({
open,
onOpenChange,
sectionName,
sectionIcon,
onConfirm,
}: RenameSectionDialogProps) {
return (
Expand All @@ -143,9 +215,11 @@ export function RenameSectionDialog({
title="Rename section"
description="Enter a new name for this section."
initialValue={sectionName}
confirmLabel="Rename"
isConfirmDisabled={(trimmed) =>
trimmed.length === 0 || trimmed === sectionName
initialIcon={sectionIcon}
confirmLabel="Save"
isConfirmDisabled={(trimmed, icon) =>
trimmed.length === 0 ||
(trimmed === sectionName && icon === (sectionIcon ?? ""))
}
onConfirm={onConfirm}
/>
Expand Down
Loading
Loading