feat(web): add favorites for sticker and GIF pickers
All checks were successful
CI / test (push) Successful in 21s
All checks were successful
CI / test (push) Successful in 21s
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user