feat(desktop): restore section icon/emoji picker (#1516)

Signed-off-by: klopez4212 <klopez4212@gmail.com>
This commit is contained in:
klopez4212
2026-07-06 15:33:11 +01:00
committed by GitHub
parent dc383c1993
commit 232b6066c0
7 changed files with 193 additions and 40 deletions
@@ -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 },
]);
});
@@ -5,6 +5,7 @@ const STORAGE_KEY_PREFIX = "buzz-channel-sections.v1";
export type ChannelSection = {
id: string;
name: string;
icon?: string;
order: number;
};
@@ -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" &&
@@ -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;
@@ -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;
@@ -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 =
@@ -179,6 +179,7 @@ export function useChannelSections(
const section: ChannelSection = {
id: crypto.randomUUID(),
name,
...(icon ? { icon } : {}),
order: maxOrder + 1,
};
setStore((current) => {
@@ -196,7 +197,7 @@ export function useChannelSections(
);
const renameSection = React.useCallback(
(sectionId: string, newName: string) => {
(sectionId: string, newName: string, icon?: string) => {
if (!pubkey) {
return;
}
@@ -204,7 +205,14 @@ export function useChannelSections(
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 } : {}),
order: s.order,
}
: s,
),
};
if (!writeChannelSectionsStore(pubkey, next, relayUrl)) {
@@ -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";
@@ -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;
}
@@ -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);
}}
@@ -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,
@@ -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({
@@ -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 (
@@ -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>
@@ -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({
@@ -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 (
@@ -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}
/>
@@ -53,6 +53,7 @@ import type { ChannelSection } from "@/features/sidebar/lib/useChannelSections";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { HashSearch } from "@/shared/ui/icons";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
const SECTION_LABEL_BUTTON_CLASS =
"group/section-label flex w-fit max-w-[calc(100%-3rem)] cursor-pointer appearance-none items-center gap-1 text-left transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground";
@@ -89,6 +90,8 @@ function MoveToSectionSubmenu({
>
{currentSectionId === section.id ? (
<Check className="h-4 w-4" />
) : section.icon ? (
<StatusEmoji className="h-4 w-4" value={section.icon} />
) : (
<span className="h-4 w-4" />
)}
@@ -597,7 +600,24 @@ export function CustomChannelSection({
)}
aria-hidden="true"
/>
<span>{section.name}</span>
{section.icon ? (
<span
aria-hidden="true"
className="flex h-4 w-4 shrink-0 items-center justify-center"
data-testid={`section-icon-${section.id}`}
>
<StatusEmoji
className="h-4 w-4"
value={section.icon}
/>
</span>
) : null}
<span
className="truncate"
data-testid={`section-title-${section.id}`}
>
{section.name}
</span>
<ChevronDown
aria-hidden="true"
className={cn(