mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): restore section icon/emoji picker (#1516)
Signed-off-by: klopez4212 <klopez4212@gmail.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user