From 10b11b065fdb9f87391de7719ec3fdd10297a1d5 Mon Sep 17 00:00:00 2001 From: benya Date: Sun, 8 Mar 2026 13:32:26 +0300 Subject: [PATCH] feat(web): add built-in sticker and GIF picker --- docs/core-checklist-status.md | 4 +- web/src/components/MessageComposer.tsx | 125 +++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/docs/core-checklist-status.md b/docs/core-checklist-status.md index f167adc..088aabe 100644 --- a/docs/core-checklist-status.md +++ b/docs/core-checklist-status.md @@ -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 - `TODO` -20. GIF - `TODO` (native GIF search/favorites not implemented) +19. Stickers - `PARTIAL` (web sticker picker with preset pack) +20. GIF - `PARTIAL` (web GIF picker with preset catalog + local search) 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) diff --git a/web/src/components/MessageComposer.tsx b/web/src/components/MessageComposer.tsx index 89ce035..9bdfbac 100644 --- a/web/src/components/MessageComposer.tsx +++ b/web/src/components/MessageComposer.tsx @@ -7,6 +7,28 @@ import { getAppPreferences } from "../utils/preferences"; type RecordingState = "idle" | "recording" | "locked"; +const STICKER_PRESETS: Array<{ name: string; url: string }> = [ + { name: "Thumbs Up", url: "https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/72x72/1f44d.png" }, + { name: "Party", url: "https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/72x72/1f389.png" }, + { name: "Fire", url: "https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/72x72/1f525.png" }, + { name: "Heart", url: "https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/72x72/2764.png" }, + { name: "Cool", url: "https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/72x72/1f60e.png" }, + { name: "Rocket", url: "https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/72x72/1f680.png" }, + { name: "Clap", url: "https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/72x72/1f44f.png" }, + { name: "Star", url: "https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/72x72/2b50.png" }, +]; + +const GIF_PRESETS: Array<{ name: string; url: string }> = [ + { name: "Cat Typing", url: "https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.gif" }, + { name: "Thumbs Up", url: "https://media.giphy.com/media/111ebonMs90YLu/giphy.gif" }, + { name: "Excited", url: "https://media.giphy.com/media/5VKbvrjxpVJCM/giphy.gif" }, + { name: "Dance", url: "https://media.giphy.com/media/l0MYt5jPR6QX5pnqM/giphy.gif" }, + { name: "Nice", url: "https://media.giphy.com/media/3o7abKhOpu0NwenH3O/giphy.gif" }, + { name: "Wow", url: "https://media.giphy.com/media/3oEjI6SIIHBdRxXI40/giphy.gif" }, + { name: "Thanks", url: "https://media.giphy.com/media/26ufdipQqU2lhNA4g/giphy.gif" }, + { name: "Success", url: "https://media.giphy.com/media/4T7e4DmcrP9du/giphy.gif" }, +]; + export function MessageComposer() { const activeChatId = useChatStore((s) => s.activeChatId); const chats = useChatStore((s) => s.chats); @@ -48,6 +70,9 @@ export function MessageComposer() { const [previewUrl, setPreviewUrl] = useState(null); const [showAttachMenu, setShowAttachMenu] = useState(false); const [showFormatMenu, setShowFormatMenu] = useState(false); + const [showStickerMenu, setShowStickerMenu] = useState(false); + const [showGifMenu, setShowGifMenu] = useState(false); + const [gifQuery, setGifQuery] = useState(""); const [captionDraft, setCaptionDraft] = useState(""); const mediaInputRef = useRef(null); const fileInputRef = useRef(null); @@ -214,6 +239,26 @@ export function MessageComposer() { } } + async function sendPresetMedia(url: string) { + if (!activeChatId || !me || !canSendInActiveChat) { + return; + } + const clientMessageId = makeClientMessageId(); + const replyToMessageId = activeChatId ? (replyToByChat[activeChatId]?.id ?? undefined) : undefined; + addOptimisticMessage({ chatId: activeChatId, senderId: me.id, type: "image", text: url, clientMessageId }); + try { + const message = await sendMessageWithClientId(activeChatId, url, "image", clientMessageId, replyToMessageId); + confirmMessageByClientId(activeChatId, clientMessageId, message); + setReplyToMessage(activeChatId, null); + setShowStickerMenu(false); + setShowGifMenu(false); + setGifQuery(""); + } catch { + removeOptimisticMessage(activeChatId, clientMessageId); + setUploadError("Failed to send media"); + } + } + function onComposerKeyDown(event: KeyboardEvent) { if (event.key !== "Enter") { return; @@ -746,6 +791,60 @@ export function MessageComposer() { ) : null} + {showStickerMenu ? ( +
+
+

Stickers

+ +
+
+ {STICKER_PRESETS.map((sticker) => ( + + ))} +
+
+ ) : null} + + {showGifMenu ? ( +
+
+

GIF

+ setGifQuery(event.target.value)} + placeholder="Search GIF" + value={gifQuery} + /> + +
+
+ {GIF_PRESETS.filter((item) => item.name.toLowerCase().includes(gifQuery.trim().toLowerCase())).map((gif) => ( + + ))} +
+
+ ) : null} + {!canSendInActiveChat && activeChat?.type === "channel" ? (
Read-only channel: only owners and admins can post. @@ -811,6 +910,32 @@ export function MessageComposer() { />
+ + + +