feat(web): add favorites for sticker and GIF pickers
All checks were successful
CI / test (push) Successful in 21s

This commit is contained in:
2026-03-08 13:38:55 +03:00
parent c6e8b779b0
commit 88ff11c130
2 changed files with 117 additions and 9 deletions

View File

@@ -25,8 +25,8 @@ Legend:
16. Media & Attachments - `DONE` (upload/preview/download/gallery)
17. Voice Messages - `PARTIAL` (record/send/play/seek/speed; UX still being polished)
18. Circle Video Messages - `PARTIAL` (send/play present, recording UX basic)
19. Stickers - `PARTIAL` (web sticker picker with preset pack)
20. GIF - `PARTIAL` (web GIF picker with preset catalog + local search)
19. Stickers - `PARTIAL` (web sticker picker with preset pack + favorites)
20. GIF - `PARTIAL` (web GIF picker with preset catalog + local search + favorites)
21. Message History/Search - `DONE` (history/pagination/chat+global search)
22. Text Formatting - `PARTIAL` (bold/italic/underline/spoiler/mono/links supported; toolbar still evolving)
23. Groups - `PARTIAL` (create/add/remove/invite link; advanced moderation partial)

View File

@@ -29,6 +29,29 @@ const GIF_PRESETS: Array<{ name: string; url: string }> = [
{ name: "Success", url: "https://media.giphy.com/media/4T7e4DmcrP9du/giphy.gif" },
];
const STICKER_FAVORITES_KEY = "bm_sticker_favorites_v1";
const GIF_FAVORITES_KEY = "bm_gif_favorites_v1";
function loadFavorites(key: string): Set<string> {
try {
const raw = window.localStorage.getItem(key);
if (!raw) return new Set();
const parsed = JSON.parse(raw) as string[];
if (!Array.isArray(parsed)) return new Set();
return new Set(parsed.filter((item) => typeof item === "string"));
} catch {
return new Set();
}
}
function saveFavorites(key: string, values: Set<string>): void {
try {
window.localStorage.setItem(key, JSON.stringify([...values]));
} catch {
return;
}
}
export function MessageComposer() {
const activeChatId = useChatStore((s) => s.activeChatId);
const chats = useChatStore((s) => s.chats);
@@ -72,7 +95,11 @@ export function MessageComposer() {
const [showFormatMenu, setShowFormatMenu] = useState(false);
const [showStickerMenu, setShowStickerMenu] = useState(false);
const [showGifMenu, setShowGifMenu] = useState(false);
const [stickerTab, setStickerTab] = useState<"all" | "favorites">("all");
const [gifTab, setGifTab] = useState<"all" | "favorites">("all");
const [gifQuery, setGifQuery] = useState("");
const [favoriteStickers, setFavoriteStickers] = useState<Set<string>>(() => loadFavorites(STICKER_FAVORITES_KEY));
const [favoriteGifs, setFavoriteGifs] = useState<Set<string>>(() => loadFavorites(GIF_FAVORITES_KEY));
const [captionDraft, setCaptionDraft] = useState("");
const mediaInputRef = useRef<HTMLInputElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
@@ -259,6 +286,32 @@ export function MessageComposer() {
}
}
function toggleStickerFavorite(url: string) {
setFavoriteStickers((prev) => {
const next = new Set(prev);
if (next.has(url)) {
next.delete(url);
} else {
next.add(url);
}
saveFavorites(STICKER_FAVORITES_KEY, next);
return next;
});
}
function toggleGifFavorite(url: string) {
setFavoriteGifs((prev) => {
const next = new Set(prev);
if (next.has(url)) {
next.delete(url);
} else {
next.add(url);
}
saveFavorites(GIF_FAVORITES_KEY, next);
return next;
});
}
function onComposerKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.key !== "Enter") {
return;
@@ -795,20 +848,46 @@ export function MessageComposer() {
<div className="mb-2 rounded-xl border border-slate-700/80 bg-slate-900/95 p-2">
<div className="mb-2 flex items-center justify-between">
<p className="text-xs font-semibold text-slate-200">Stickers</p>
<button className="rounded bg-slate-700 px-2 py-1 text-[11px]" onClick={() => setShowStickerMenu(false)} type="button">
Close
</button>
<div className="flex items-center gap-1">
<button
className={`rounded px-2 py-1 text-[11px] ${stickerTab === "all" ? "bg-sky-500 text-slate-950" : "bg-slate-700"}`}
onClick={() => setStickerTab("all")}
type="button"
>
All
</button>
<button
className={`rounded px-2 py-1 text-[11px] ${stickerTab === "favorites" ? "bg-sky-500 text-slate-950" : "bg-slate-700"}`}
onClick={() => setStickerTab("favorites")}
type="button"
>
Favorites
</button>
<button className="rounded bg-slate-700 px-2 py-1 text-[11px]" onClick={() => setShowStickerMenu(false)} type="button">
Close
</button>
</div>
</div>
<div className="grid grid-cols-4 gap-2 md:grid-cols-8">
{STICKER_PRESETS.map((sticker) => (
{STICKER_PRESETS.filter((item) => (stickerTab === "favorites" ? favoriteStickers.has(item.url) : true)).map((sticker) => (
<button
className="rounded-lg bg-slate-800/80 p-2 hover:bg-slate-700"
className="relative rounded-lg bg-slate-800/80 p-2 hover:bg-slate-700"
key={sticker.url}
onClick={() => void sendPresetMedia(sticker.url)}
title={sticker.name}
type="button"
>
<img alt={sticker.name} className="mx-auto h-9 w-9 object-contain" draggable={false} src={sticker.url} />
<span
className="absolute right-1 top-1 text-[10px]"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
toggleStickerFavorite(sticker.url);
}}
>
{favoriteStickers.has(sticker.url) ? "★" : "☆"}
</span>
</button>
))}
</div>
@@ -829,16 +908,45 @@ export function MessageComposer() {
Close
</button>
</div>
<div className="mb-2 flex items-center gap-1">
<button
className={`rounded px-2 py-1 text-[11px] ${gifTab === "all" ? "bg-sky-500 text-slate-950" : "bg-slate-700"}`}
onClick={() => setGifTab("all")}
type="button"
>
All
</button>
<button
className={`rounded px-2 py-1 text-[11px] ${gifTab === "favorites" ? "bg-sky-500 text-slate-950" : "bg-slate-700"}`}
onClick={() => setGifTab("favorites")}
type="button"
>
Favorites
</button>
</div>
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
{GIF_PRESETS.filter((item) => item.name.toLowerCase().includes(gifQuery.trim().toLowerCase())).map((gif) => (
{GIF_PRESETS
.filter((item) => item.name.toLowerCase().includes(gifQuery.trim().toLowerCase()))
.filter((item) => (gifTab === "favorites" ? favoriteGifs.has(item.url) : true))
.map((gif) => (
<button
className="overflow-hidden rounded-lg bg-slate-800/80 hover:bg-slate-700"
className="relative overflow-hidden rounded-lg bg-slate-800/80 hover:bg-slate-700"
key={gif.url}
onClick={() => void sendPresetMedia(gif.url)}
title={gif.name}
type="button"
>
<img alt={gif.name} className="h-20 w-full object-cover md:h-24" draggable={false} src={gif.url} />
<span
className="absolute right-1 top-1 rounded bg-black/45 px-1 text-[10px] text-white"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
toggleGifFavorite(gif.url);
}}
>
{favoriteGifs.has(gif.url) ? "★" : "☆"}
</span>
</button>
))}
</div>