diff --git a/e2e/ui.spec.js b/e2e/ui.spec.js index 227cd2c..bed74b0 100644 --- a/e2e/ui.spec.js +++ b/e2e/ui.spec.js @@ -652,6 +652,26 @@ test('centers the compose card in the bottom input layer', async ({ page }) => { expect(Math.abs(gaps.top - gaps.bottom)).toBeLessThanOrEqual(2); }); +test('keeps compose tool buttons tightly grouped', async ({ page }) => { + await openConversation(page, 'Sarah Chen'); + + const gaps = await page.evaluate(() => { + const rectFor = (selector) => document.querySelector(selector).getBoundingClientRect(); + const attach = rectFor('#attach-btn'); + const gif = rectFor('#compose-gif-btn'); + const emoji = rectFor('#compose-emoji-btn'); + const input = rectFor('#compose-input'); + return { + attachToGif: gif.left - attach.right, + gifToEmoji: emoji.left - gif.right, + emojiToInput: input.left - emoji.right, + }; + }); + expect(gaps.attachToGif).toBeLessThanOrEqual(5); + expect(gaps.gifToEmoji).toBeLessThanOrEqual(5); + expect(gaps.emojiToInput).toBeGreaterThanOrEqual(8); +}); + test('hydrates cached Google contact photos into avatars', async ({ page, request }) => { await request.post('/_e2e/avatar', { data: { @@ -783,6 +803,96 @@ test('sends a captioned Signal image as one message, not two', async ({ page }) } }); +test('sends a GIF through the compose picker', async ({ page }) => { + const caption = `GIF caption ${Date.now()}`; + let sendGIFPayload = null; + await page.route(/\/api\/gifs(?:\/trending)?\?/, async route => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + results: [{ + title: 'Wave', + preview_url: 'data:image/gif;base64,R0lGODlhAQABAAAAACwAAAAAAQABAAA=', + url: 'https://media.klipy.com/fake/wave.gif', + mime_type: 'image/gif', + width: 1, + height: 1, + }], + }), + }); + }); + await page.route('**/api/send-gif', async route => { + sendGIFPayload = JSON.parse(route.request().postData() || '{}'); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ status: 'SUCCESS', success: true }), + }); + }); + + await openConversation(page, 'Taylor Price'); + await expect(page.locator('#chat-header-source')).toContainText('Signal'); + await page.locator('#compose-input').fill(caption); + await page.locator('#compose-gif-btn').click(); + await expect(page.locator('#compose-gif-panel.show')).toBeVisible(); + await page.locator('#compose-gif-panel .gif-tile').first().click(); + + await expect.poll(() => sendGIFPayload).not.toBeNull(); + expect(sendGIFPayload.conversation_id).toContain('signal:'); + expect(sendGIFPayload.url).toBe('https://media.klipy.com/fake/wave.gif'); + expect(sendGIFPayload.caption).toBe(caption); +}); + +test('debounces GIF search while typing', async ({ page }) => { + const gifRequests = []; + await page.route(/\/api\/gifs(?:\/trending)?\?/, async route => { + gifRequests.push(new URL(route.request().url())); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + results: [{ + title: 'Wave', + preview_url: 'data:image/gif;base64,R0lGODlhAQABAAAAACwAAAAAAQABAAA=', + url: 'https://media.klipy.com/fake/wave.gif', + mime_type: 'image/gif', + width: 1, + height: 1, + }], + }), + }); + }); + + await openConversation(page, 'Taylor Price'); + await page.locator('#compose-gif-btn').click(); + await expect(page.locator('#compose-gif-panel .gif-tile')).toHaveCount(1); + + gifRequests.length = 0; + await page.locator('#compose-gif-search').pressSequentially('cats', { delay: 60 }); + await page.waitForTimeout(300); + expect(gifRequests).toHaveLength(0); + + await page.waitForTimeout(350); + expect(gifRequests).toHaveLength(1); + expect(gifRequests[0].pathname).toBe('/api/gifs'); + expect(gifRequests[0].searchParams.get('q')).toBe('cats'); +}); + +test('accepts dropped files in the compose box', async ({ page }) => { + await openConversation(page, 'Taylor Price'); + await page.locator('#compose-bar').evaluate((composeBar) => { + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(new File(['hello from drop'], 'dropped-note.txt', { type: 'text/plain' })); + const event = new Event('drop', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'dataTransfer', { value: dataTransfer }); + composeBar.dispatchEvent(event); + }); + + await expect(page.locator('#attach-preview')).toHaveClass(/active/); + await expect(page.locator('#attach-name')).toHaveText('dropped-note.txt'); +}); + test('keeps the active thread pinned to the bottom after sending', async ({ page }) => { const outbound = `Bottom send ${Date.now()}`; diff --git a/internal/web/api.go b/internal/web/api.go index cc43c24..35d52c2 100644 --- a/internal/web/api.go +++ b/internal/web/api.go @@ -434,6 +434,130 @@ func APIHandlerWithOptions(store *db.Store, cli *client.Client, logger zerolog.L if fetchLinkPreview == nil { fetchLinkPreview = NewLinkPreviewService(logger).Fetch } + sendMediaBytes := func(w http.ResponseWriter, convID string, data []byte, filename, mimeType, caption, replyToID string) { + if isSignalConversation(convID) { + msg, err := sendSignalMedia(convID, data, filename, mimeType, caption, replyToID) + switch { + case errors.Is(err, errSignalMediaUnavailable): + httpError(w, err.Error(), 501) + return + case errors.Is(err, errSignalLocalStore): + httpError(w, "message sent remotely but failed to update local store: "+err.Error(), 500) + return + case err != nil: + httpError(w, err.Error(), 502) + return + } + publishMessages(convID) + publishConversations() + writeJSON(w, map[string]any{ + "message_id": msg.MessageID, + "status": "SUCCESS", + "success": true, + }) + return + } + if isWhatsAppConversation(convID) { + msg, err := sendWhatsAppMedia(convID, data, filename, mimeType, caption, replyToID) + switch { + case errors.Is(err, errWhatsAppMediaUnavailable): + httpError(w, err.Error(), 501) + return + case errors.Is(err, errWhatsAppLocalStore): + httpError(w, "message sent remotely but failed to update local store: "+err.Error(), 500) + return + case err != nil: + httpError(w, err.Error(), 502) + return + } + publishMessages(convID) + publishConversations() + writeJSON(w, map[string]any{ + "message_id": msg.MessageID, + "status": "SUCCESS", + "success": true, + }) + return + } + cli := getClient() + if cli == nil { + httpError(w, app.ErrNotConnected, 503) + return + } + + media, err := cli.GM.UploadMedia(data, filename, mimeType) + if err != nil { + markGoogleAuthExpired(err) + httpError(w, googleAPIErrorMessage("upload media", err), 502) + return + } + + conv, err := cli.GM.GetConversation(convID) + if err != nil { + markGoogleAuthExpired(err) + httpError(w, googleAPIErrorMessage("get conversation", err), 502) + return + } + + myParticipantID, simPayload := app.ExtractSIMAndParticipant(conv) + payload := app.BuildSendMediaPayload(convID, media, myParticipantID, simPayload) + + logger.Info(). + Str("conv_id", convID). + Str("mime", mimeType). + Str("filename", filename). + Int("size", len(data)). + Msg("Sending media message") + + resp, err := cli.GM.SendMessage(payload) + if err != nil { + markGoogleAuthExpired(err) + httpError(w, googleAPIErrorMessage("send message", err), 502) + return + } + success := resp.GetStatus() == gmproto.SendMessageResponse_SUCCESS + if !success { + now := time.Now().UnixMilli() + _ = recordOutgoingMessage(&db.Message{ + MessageID: payload.TmpID, + ConversationID: convID, + Body: "", + IsFromMe: true, + TimestampMS: now, + Status: "OUTGOING_FAILED:" + resp.GetStatus().String(), + MediaID: media.MediaID, + MimeType: media.MimeType, + DecryptionKey: hex.EncodeToString(media.DecryptionKey), + }, "") + publishMessages(convID) + publishConversations() + recordGoogleSend(false) + httpError(w, googleSendRejectedMessage(resp.GetStatus().String()), 502) + return + } + recordGoogleSend(true) + now := time.Now().UnixMilli() + if err := recordOutgoingMessage(&db.Message{ + MessageID: payload.TmpID, + ConversationID: convID, + Body: "", + IsFromMe: true, + TimestampMS: now, + Status: "OUTGOING_SENDING", + MediaID: media.MediaID, + MimeType: media.MimeType, + DecryptionKey: hex.EncodeToString(media.DecryptionKey), + }, ""); err != nil { + httpError(w, "message sent remotely but failed to update local store: "+err.Error(), 500) + return + } + publishMessages(convID) + publishConversations() + writeJSON(w, map[string]any{ + "status": resp.GetStatus().String(), + "success": success, + }) + } mux.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) { if opts.Events == nil { @@ -1065,6 +1189,83 @@ func APIHandlerWithOptions(store *db.Store, cli *client.Client, logger zerolog.L }) }) + mux.HandleFunc("/api/gifs", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + httpError(w, "method not allowed", 405) + return + } + limit := queryIntClamped(r, "limit", defaultGIFSearchLimit, maxGIFSearchResults) + results, err := searchKlipyGIFs(r.Context(), r.URL.Query().Get("q"), limit) + if err != nil { + httpError(w, err.Error(), 502) + return + } + for i := range results { + results[i].PreviewURL = proxyGIFPreviewURL(results[i].PreviewURL) + } + writeJSON(w, map[string]any{"results": results}) + }) + + mux.HandleFunc("/api/gifs/trending", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + httpError(w, "method not allowed", 405) + return + } + limit := queryIntClamped(r, "limit", defaultGIFSearchLimit, maxGIFSearchResults) + results, err := searchKlipyGIFs(r.Context(), defaultGIFSearchQuery, limit) + if err != nil { + httpError(w, err.Error(), 502) + return + } + for i := range results { + results[i].PreviewURL = proxyGIFPreviewURL(results[i].PreviewURL) + } + writeJSON(w, map[string]any{"results": results}) + }) + + mux.HandleFunc("/api/gifs/preview", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + httpError(w, "method not allowed", 405) + return + } + data, _, mimeType, err := downloadGIFMedia(r.Context(), r.URL.Query().Get("url"), maxGIFPreviewBytes) + if err != nil { + httpError(w, err.Error(), 400) + return + } + w.Header().Set("Cache-Control", "private, max-age=3600") + w.Header().Set("Content-Type", mimeType) + _, _ = w.Write(data) + }) + + mux.HandleFunc("/api/send-gif", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + httpError(w, "method not allowed", 405) + return + } + var req struct { + ConversationID string `json:"conversation_id"` + URL string `json:"url"` + Caption string `json:"caption"` + ReplyToID string `json:"reply_to_id"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&req); err != nil { + httpError(w, "invalid JSON: "+err.Error(), 400) + return + } + convID := strings.TrimSpace(req.ConversationID) + if convID == "" { + httpError(w, "conversation_id is required", 400) + return + } + data, filename, mimeType, err := downloadGIFMedia(r.Context(), req.URL, maxGIFSendBytes) + if err != nil { + httpError(w, err.Error(), 400) + return + } + sendMediaBytes(w, convID, data, filename, mimeType, strings.TrimSpace(req.Caption), strings.TrimSpace(req.ReplyToID)) + }) + mux.HandleFunc("/api/send-media", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { httpError(w, "method not allowed", 405) @@ -1116,131 +1317,7 @@ func APIHandlerWithOptions(store *db.Store, cli *client.Client, logger zerolog.L if mime == "" { mime = "application/octet-stream" } - if isSignalConversation(convID) { - msg, err := sendSignalMedia(convID, data, header.Filename, mime, caption, replyToID) - switch { - case errors.Is(err, errSignalMediaUnavailable): - httpError(w, err.Error(), 501) - return - case errors.Is(err, errSignalLocalStore): - httpError(w, "message sent remotely but failed to update local store: "+err.Error(), 500) - return - case err != nil: - httpError(w, err.Error(), 502) - return - } - publishMessages(convID) - publishConversations() - writeJSON(w, map[string]any{ - "message_id": msg.MessageID, - "status": "SUCCESS", - "success": true, - }) - return - } - if isWhatsAppConversation(convID) { - msg, err := sendWhatsAppMedia(convID, data, header.Filename, mime, caption, replyToID) - switch { - case errors.Is(err, errWhatsAppMediaUnavailable): - httpError(w, err.Error(), 501) - return - case errors.Is(err, errWhatsAppLocalStore): - httpError(w, "message sent remotely but failed to update local store: "+err.Error(), 500) - return - case err != nil: - httpError(w, err.Error(), 502) - return - } - publishMessages(convID) - publishConversations() - writeJSON(w, map[string]any{ - "message_id": msg.MessageID, - "status": "SUCCESS", - "success": true, - }) - return - } - cli := getClient() - if cli == nil { - httpError(w, app.ErrNotConnected, 503) - return - } - - // Upload media via libgm - media, err := cli.GM.UploadMedia(data, header.Filename, mime) - if err != nil { - markGoogleAuthExpired(err) - httpError(w, googleAPIErrorMessage("upload media", err), 502) - return - } - - // Get SIM and participant info - conv, err := cli.GM.GetConversation(convID) - if err != nil { - markGoogleAuthExpired(err) - httpError(w, googleAPIErrorMessage("get conversation", err), 502) - return - } - - myParticipantID, simPayload := app.ExtractSIMAndParticipant(conv) - - payload := app.BuildSendMediaPayload(convID, media, myParticipantID, simPayload) - - logger.Info(). - Str("conv_id", convID). - Str("mime", mime). - Str("filename", header.Filename). - Int("size", len(data)). - Msg("Sending media message") - - resp, err := cli.GM.SendMessage(payload) - if err != nil { - markGoogleAuthExpired(err) - httpError(w, googleAPIErrorMessage("send message", err), 502) - return - } - success := resp.GetStatus() == gmproto.SendMessageResponse_SUCCESS - if !success { - now := time.Now().UnixMilli() - _ = recordOutgoingMessage(&db.Message{ - MessageID: payload.TmpID, - ConversationID: convID, - Body: "", - IsFromMe: true, - TimestampMS: now, - Status: "OUTGOING_FAILED:" + resp.GetStatus().String(), - MediaID: media.MediaID, - MimeType: media.MimeType, - DecryptionKey: hex.EncodeToString(media.DecryptionKey), - }, "") - publishMessages(convID) - publishConversations() - recordGoogleSend(false) - httpError(w, googleSendRejectedMessage(resp.GetStatus().String()), 502) - return - } - recordGoogleSend(true) - now := time.Now().UnixMilli() - if err := recordOutgoingMessage(&db.Message{ - MessageID: payload.TmpID, - ConversationID: convID, - Body: "", - IsFromMe: true, - TimestampMS: now, - Status: "OUTGOING_SENDING", - MediaID: media.MediaID, - MimeType: media.MimeType, - DecryptionKey: hex.EncodeToString(media.DecryptionKey), - }, ""); err != nil { - httpError(w, "message sent remotely but failed to update local store: "+err.Error(), 500) - return - } - publishMessages(convID) - publishConversations() - writeJSON(w, map[string]any{ - "status": resp.GetStatus().String(), - "success": success, - }) + sendMediaBytes(w, convID, data, header.Filename, mime, caption, replyToID) }) mux.HandleFunc("/api/media/", func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/web/gifs.go b/internal/web/gifs.go new file mode 100644 index 0000000..19d1b95 --- /dev/null +++ b/internal/web/gifs.go @@ -0,0 +1,288 @@ +package web + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "os" + "path" + "strings" + "time" +) + +const ( + defaultKlipyAPIKey = "AtywYPdjAHtT9HcbzaIi3JR3xxwGFwIiufVXOxjtP9ObHxTdY6uRXqxHcayVSsKm" + defaultGIFSearchQuery = "popular" + maxGIFSearchResults = 48 + defaultGIFSearchLimit = 24 + klipyPreferredSizeLimit = 3_000_000 + maxGIFPreviewBytes int64 = 8 << 20 + maxGIFSendBytes int64 = 24 << 20 +) + +var ( + klipySearchEndpoint = "https://api.klipy.com/v2/search" + gifHTTPClient = &http.Client{Timeout: 15 * time.Second} +) + +type gifSearchResult struct { + ID string `json:"id,omitempty"` + Title string `json:"title,omitempty"` + PreviewURL string `json:"preview_url"` + URL string `json:"url"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + MimeType string `json:"mime_type"` +} + +type klipySearchResponse struct { + Results []klipyResult `json:"results"` +} + +type klipyResult struct { + ID string `json:"id"` + Title string `json:"title"` + Content string `json:"content_description"` + MediaFormats map[string]klipyMediaFormat `json:"media_formats"` +} + +type klipyMediaFormat struct { + URL string `json:"url"` + Size int64 `json:"size"` + Dims []int `json:"dims"` +} + +func searchKlipyGIFs(ctx context.Context, query string, limit int) ([]gifSearchResult, error) { + query = strings.TrimSpace(query) + if query == "" { + query = defaultGIFSearchQuery + } + if limit <= 0 { + limit = defaultGIFSearchLimit + } + if limit > maxGIFSearchResults { + limit = maxGIFSearchResults + } + + endpoint, err := url.Parse(klipySearchEndpoint) + if err != nil { + return nil, fmt.Errorf("parse GIF endpoint: %w", err) + } + params := endpoint.Query() + params.Set("q", query) + params.Set("key", klipyAPIKey()) + endpoint.RawQuery = params.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return nil, err + } + resp, err := gifHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("search GIFs: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("search GIFs: provider returned %d", resp.StatusCode) + } + + var payload klipySearchResponse + if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&payload); err != nil { + return nil, fmt.Errorf("decode GIF search: %w", err) + } + + results := make([]gifSearchResult, 0, min(limit, len(payload.Results))) + for _, item := range payload.Results { + result, ok := parseKlipyGIFResult(item) + if !ok { + continue + } + results = append(results, result) + if len(results) >= limit { + break + } + } + return results, nil +} + +func parseKlipyGIFResult(item klipyResult) (gifSearchResult, bool) { + formats := item.MediaFormats + if len(formats) == 0 { + return gifSearchResult{}, false + } + + preview, ok := firstKlipyFormat(formats, "tinygif", "nanogif", "mediumgif", "gif", "webp") + if !ok || strings.TrimSpace(preview.URL) == "" { + return gifSearchResult{}, false + } + + full, ok := formats["gif"] + mimeType := "image/gif" + if !ok || strings.TrimSpace(full.URL) == "" { + full, ok = firstKlipyFormat(formats, "mediumgif", "webp", "tinygif", "nanogif") + if !ok || strings.TrimSpace(full.URL) == "" { + return gifSearchResult{}, false + } + if strings.Contains(strings.ToLower(full.URL), ".webp") { + mimeType = "image/webp" + } + } + + if medium, ok := formats["mediumgif"]; ok && strings.TrimSpace(medium.URL) != "" && full.Size > klipyPreferredSizeLimit { + full = medium + mimeType = "image/gif" + } + if webp, ok := formats["webp"]; ok && strings.TrimSpace(webp.URL) != "" && (full.Size == 0 || webp.Size == 0 || webp.Size < full.Size) { + full = webp + mimeType = "image/webp" + } + + width, height := klipyDimensions(full) + if width == 0 || height == 0 { + width, height = klipyDimensions(preview) + } + + title := strings.TrimSpace(item.Title) + if title == "" { + title = strings.TrimSpace(item.Content) + } + + return gifSearchResult{ + ID: strings.TrimSpace(item.ID), + Title: title, + PreviewURL: strings.TrimSpace(preview.URL), + URL: strings.TrimSpace(full.URL), + Width: width, + Height: height, + MimeType: mimeType, + }, true +} + +func firstKlipyFormat(formats map[string]klipyMediaFormat, names ...string) (klipyMediaFormat, bool) { + for _, name := range names { + format, ok := formats[name] + if ok && strings.TrimSpace(format.URL) != "" { + return format, true + } + } + return klipyMediaFormat{}, false +} + +func klipyDimensions(format klipyMediaFormat) (int, int) { + if len(format.Dims) < 2 || format.Dims[0] <= 0 || format.Dims[1] <= 0 { + return 0, 0 + } + return format.Dims[0], format.Dims[1] +} + +func klipyAPIKey() string { + if key := strings.TrimSpace(os.Getenv("KLIPY_API_KEY")); key != "" { + return key + } + return defaultKlipyAPIKey +} + +func proxyGIFPreviewURL(rawURL string) string { + return "/api/gifs/preview?url=" + url.QueryEscape(rawURL) +} + +func downloadGIFMedia(ctx context.Context, rawURL string, limit int64) ([]byte, string, string, error) { + parsed, err := validateKlipyMediaURL(rawURL) + if err != nil { + return nil, "", "", err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) + if err != nil { + return nil, "", "", err + } + resp, err := gifHTTPClient.Do(req) + if err != nil { + return nil, "", "", fmt.Errorf("download GIF: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, "", "", fmt.Errorf("download GIF: provider returned %d", resp.StatusCode) + } + + data, err := io.ReadAll(io.LimitReader(resp.Body, limit+1)) + if err != nil { + return nil, "", "", fmt.Errorf("read GIF: %w", err) + } + if int64(len(data)) > limit { + return nil, "", "", fmt.Errorf("GIF too large (limit %d MB)", limit>>20) + } + + contentType := strings.TrimSpace(resp.Header.Get("Content-Type")) + if idx := strings.Index(contentType, ";"); idx >= 0 { + contentType = strings.TrimSpace(contentType[:idx]) + } + if contentType == "" || contentType == "application/octet-stream" { + contentType = mime.TypeByExtension(path.Ext(parsed.Path)) + } + if contentType == "" { + contentType = "image/gif" + } + if !strings.HasPrefix(strings.ToLower(contentType), "image/") { + return nil, "", "", errors.New("GIF provider returned non-image content") + } + + filename := gifFilename(parsed, contentType) + return data, filename, contentType, nil +} + +func validateKlipyMediaURL(rawURL string) (*url.URL, error) { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return nil, fmt.Errorf("invalid GIF URL: %w", err) + } + if parsed.Scheme != "https" { + return nil, errors.New("GIF URL must use HTTPS") + } + host := strings.ToLower(parsed.Hostname()) + if host != "klipy.com" && !strings.HasSuffix(host, ".klipy.com") { + return nil, errors.New("GIF URL host is not allowed") + } + return parsed, nil +} + +func gifFilename(parsed *url.URL, mimeType string) string { + name := strings.TrimSpace(path.Base(parsed.Path)) + if name == "." || name == "/" || name == "" { + name = "openmessage-gif" + } + if ext := path.Ext(name); ext == "" { + switch strings.ToLower(mimeType) { + case "image/webp": + name += ".webp" + case "image/png": + name += ".png" + case "image/jpeg": + name += ".jpg" + default: + name += ".gif" + } + } + if decoded, err := url.PathUnescape(name); err == nil && strings.TrimSpace(decoded) != "" { + name = decoded + } + name = strings.Map(func(r rune) rune { + switch r { + case '/', '\\', 0: + return -1 + default: + return r + } + }, name) + if name == "" { + return "openmessage-gif.gif" + } + return name +} diff --git a/internal/web/gifs_test.go b/internal/web/gifs_test.go new file mode 100644 index 0000000..cffac1c --- /dev/null +++ b/internal/web/gifs_test.go @@ -0,0 +1,173 @@ +package web + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/maxghenis/openmessage/internal/db" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) +} + +func TestGIFSearchEndpointReturnsProxiedKlipyResults(t *testing.T) { + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("q"); got != "thumbs up" { + t.Fatalf("query = %q, want thumbs up", got) + } + if got := r.URL.Query().Get("key"); got == "" { + t.Fatal("provider request missing API key") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "results": [{ + "id": "gif-1", + "title": "Thumbs up", + "media_formats": { + "tinygif": {"url": "https://media.klipy.com/preview.gif", "size": 12000, "dims": [120, 90]}, + "gif": {"url": "https://media.klipy.com/full.gif", "size": 4000000, "dims": [640, 480]}, + "mediumgif": {"url": "https://media.klipy.com/medium.gif", "size": 2200000, "dims": [320, 240]}, + "webp": {"url": "https://media.klipy.com/full.webp", "size": 900000, "dims": [640, 480]} + } + }] + }`)) + })) + defer provider.Close() + + oldEndpoint := klipySearchEndpoint + oldClient := gifHTTPClient + klipySearchEndpoint = provider.URL + gifHTTPClient = provider.Client() + t.Cleanup(func() { + klipySearchEndpoint = oldEndpoint + gifHTTPClient = oldClient + }) + + ts := newTestServer(t) + resp, err := http.Get(ts.server.URL + "/api/gifs?q=thumbs%20up&limit=5") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 200: %s", resp.StatusCode, string(raw)) + } + + var payload struct { + Results []gifSearchResult `json:"results"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + t.Fatal(err) + } + if len(payload.Results) != 1 { + t.Fatalf("got %d results, want 1", len(payload.Results)) + } + result := payload.Results[0] + if result.URL != "https://media.klipy.com/full.webp" { + t.Fatalf("selected URL = %q, want smaller webp", result.URL) + } + if result.MimeType != "image/webp" { + t.Fatalf("mime = %q, want image/webp", result.MimeType) + } + if !strings.HasPrefix(result.PreviewURL, "/api/gifs/preview?url=") { + t.Fatalf("preview URL = %q, want local proxy path", result.PreviewURL) + } +} + +func TestDownloadGIFMediaRejectsNonKlipyURL(t *testing.T) { + if _, _, _, err := downloadGIFMedia(context.Background(), "https://example.com/not-allowed.gif", maxGIFSendBytes); err == nil { + t.Fatal("expected non-Klipy GIF URL to be rejected") + } +} + +func TestSendGIFUsesSignalMediaSender(t *testing.T) { + oldClient := gifHTTPClient + gifHTTPClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.String() != "https://media.klipy.com/fake/openmessage.gif" { + t.Fatalf("download URL = %s", r.URL.String()) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"image/gif"}}, + Body: io.NopCloser(strings.NewReader("GIF89a")), + Request: r, + }, nil + })} + t.Cleanup(func() { + gifHTTPClient = oldClient + }) + + var gotConversationID, gotFilename, gotMIME, gotCaption, gotReplyToID string + var gotData []byte + ts := newTestServerWithOptions(t, APIOptions{ + SendSignalMedia: func(conversationID string, data []byte, filename, mime, caption, replyToID string) (*db.Message, error) { + gotConversationID = conversationID + gotFilename = filename + gotMIME = mime + gotCaption = caption + gotReplyToID = replyToID + gotData = append([]byte(nil), data...) + return &db.Message{ + MessageID: "signal:local:gif-1", + ConversationID: conversationID, + Body: caption, + IsFromMe: true, + TimestampMS: 1234, + Status: "sent", + MediaID: "signallocal:gif", + MimeType: mime, + ReplyToID: replyToID, + SourcePlatform: "signal", + }, nil + }, + }) + if err := ts.store.UpsertConversation(&db.Conversation{ + ConversationID: "signal:+15551234567", + Name: "Taylor Price", + SourcePlatform: "signal", + }); err != nil { + t.Fatal(err) + } + + resp, err := http.Post(ts.server.URL+"/api/send-gif", "application/json", strings.NewReader(`{ + "conversation_id": "signal:+15551234567", + "url": "https://media.klipy.com/fake/openmessage.gif", + "caption": "gif caption", + "reply_to_id": "signal:reply-1" + }`)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + raw, _ := io.ReadAll(resp.Body) + t.Fatalf("status = %d, want 200: %s", resp.StatusCode, string(raw)) + } + if gotConversationID != "signal:+15551234567" { + t.Fatalf("conversation_id = %q", gotConversationID) + } + if gotFilename != "openmessage.gif" { + t.Fatalf("filename = %q, want openmessage.gif", gotFilename) + } + if gotMIME != "image/gif" { + t.Fatalf("mime = %q, want image/gif", gotMIME) + } + if gotCaption != "gif caption" { + t.Fatalf("caption = %q, want gif caption", gotCaption) + } + if gotReplyToID != "signal:reply-1" { + t.Fatalf("reply_to_id = %q, want signal:reply-1", gotReplyToID) + } + if string(gotData) != "GIF89a" { + t.Fatalf("data = %q, want GIF89a", string(gotData)) + } +} diff --git a/internal/web/static/index.html b/internal/web/static/index.html index fa925c8..78cf9e7 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -4262,6 +4262,7 @@ } .attach-btn, +.gif-compose-btn, .emoji-compose-btn, .send-btn { width: 46px; @@ -4271,6 +4272,7 @@ } .attach-btn:disabled, +.gif-compose-btn:disabled, .emoji-compose-btn:disabled, .send-btn:disabled { opacity: 0.45; @@ -4278,30 +4280,128 @@ } .attach-btn, +.gif-compose-btn, .emoji-compose-btn { background: var(--bg-surface); border: 1px solid var(--border); color: var(--text-secondary); } +.attach-btn + .gif-compose-btn, +.gif-compose-btn + .emoji-compose-btn { + margin-left: -6px; +} + .attach-btn:hover, +.gif-compose-btn:hover, .emoji-compose-btn:hover { background: var(--bg-hover); color: var(--text-primary); } +.gif-compose-btn { + font: 700 11px/1 var(--font-sans); + letter-spacing: 0.02em; +} + .emoji-compose-btn svg { width: 20px; height: 20px; } +.compose-bar.drag-over { + border-color: var(--border-accent); + background: var(--bg-hover); +} + .compose-emoji-panel { - left: 58px; + left: 114px; right: auto; bottom: calc(100% + 8px); z-index: 70; } +.gif-compose-panel { + position: absolute; + left: 58px; + right: auto; + bottom: calc(100% + 8px); + z-index: 72; + display: none; + width: min(560px, calc(100vw - 96px)); + max-height: min(430px, calc(100vh - 260px)); + overflow: hidden; + flex-direction: column; + border: 1px solid var(--border); + border-radius: 16px; + background: var(--bg-elevated); + box-shadow: var(--shadow-soft); +} + +.gif-compose-panel.show { + display: flex; +} + +.gif-search { + padding: 12px; + border-bottom: 1px solid var(--border); +} + +.gif-search input { + width: 100%; + height: 42px; + padding: 0 14px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--bg-surface); + color: var(--text-primary); + font: 500 14px/1 var(--font-sans); + outline: none; +} + +.gif-search input:focus { + border-color: var(--border-accent); +} + +.gif-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); + gap: 8px; + overflow-y: auto; + padding: 12px; +} + +.gif-tile { + min-width: 0; + aspect-ratio: 1.1; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--bg-surface); + cursor: pointer; + padding: 0; +} + +.gif-tile:hover, +.gif-tile:focus-visible { + border-color: var(--border-accent); + outline: none; +} + +.gif-tile img { + width: 100%; + height: 100%; + display: block; + object-fit: cover; +} + +.gif-status { + padding: 18px 12px; + color: var(--text-muted); + font-size: 13px; + text-align: center; +} + .send-btn { background: var(--accent); border: 1px solid var(--accent); @@ -4333,11 +4433,13 @@ @media (max-width: 520px) { .thread-search-results, - .compose-emoji-panel { + .compose-emoji-panel, + .gif-compose-panel { width: min(320px, calc(100vw - 32px)); } - .compose-emoji-panel { + .compose-emoji-panel, + .gif-compose-panel { left: 0; right: auto; } @@ -4883,10 +4985,11 @@

No conversations yet

@@ -5032,6 +5136,8 @@

No conversations yet

const $messagesArea = document.getElementById('messages-area'); const $composeBar = document.getElementById('compose-bar'); const $composeInput = document.getElementById('compose-input'); + const $composeGifBtn = document.getElementById('compose-gif-btn'); + const $composeGifPanel = document.getElementById('compose-gif-panel'); const $composeEmojiBtn = document.getElementById('compose-emoji-btn'); const $composeEmojiPanel = document.getElementById('compose-emoji-panel'); const $sendBtn = document.getElementById('send-btn'); @@ -6818,8 +6924,10 @@

No conversations yet

if (!activeConversation) { $composeInput.disabled = false; if ($composeEmojiBtn) $composeEmojiBtn.disabled = false; + if ($composeGifBtn) $composeGifBtn.disabled = false; $attachBtn.disabled = false; $attachBtn.title = 'Attach media'; + if ($composeGifBtn) $composeGifBtn.title = 'Search GIFs'; return; } @@ -6827,12 +6935,20 @@

No conversations yet

const canSendMedia = conversationSupportsMediaOutbound(activeConversation); $composeInput.disabled = !canSend; if ($composeEmojiBtn) $composeEmojiBtn.disabled = !canSend; + if ($composeGifBtn) $composeGifBtn.disabled = !canSendMedia; $attachBtn.disabled = !canSendMedia; $attachBtn.title = canSendMedia ? 'Attach media' : (sourcePlatformOf(activeConversation) === 'whatsapp' ? 'Reconnect WhatsApp to send attachments on this route' : 'Attachments unavailable on this route'); + if ($composeGifBtn) { + $composeGifBtn.title = canSendMedia + ? 'Search GIFs' + : (sourcePlatformOf(activeConversation) === 'whatsapp' + ? 'Reconnect WhatsApp to send GIFs on this route' + : 'GIFs unavailable on this route'); + } $composeInput.placeholder = canSend ? 'Write a message...' : routeUnavailablePlaceholder(activeConversation); if (!canSendMedia && pendingFile) { clearAttachment(); @@ -6843,6 +6959,9 @@

No conversations yet

if (!canSend) { closeComposeEmojiPanel(); } + if (!canSendMedia) { + closeComposeGifPanel(); + } autoResize(); } @@ -9167,13 +9286,14 @@

No conversations yet

if ($composeEmojiBtn) $composeEmojiBtn.setAttribute('aria-expanded', 'false'); } - function toggleComposeEmojiPanel() { - if (!$composeEmojiPanel || !$composeEmojiBtn) return; - buildComposeEmojiPanel(); - const willOpen = !$composeEmojiPanel.classList.contains('show'); - document.querySelectorAll('.emoji-picker.show').forEach(p => p.classList.remove('show')); - document.querySelectorAll('.emoji-full-panel.show').forEach(p => { - if (p !== $composeEmojiPanel) p.classList.remove('show'); + function toggleComposeEmojiPanel() { + if (!$composeEmojiPanel || !$composeEmojiBtn) return; + buildComposeEmojiPanel(); + const willOpen = !$composeEmojiPanel.classList.contains('show'); + closeComposeGifPanel(); + document.querySelectorAll('.emoji-picker.show').forEach(p => p.classList.remove('show')); + document.querySelectorAll('.emoji-full-panel.show').forEach(p => { + if (p !== $composeEmojiPanel) p.classList.remove('show'); }); $composeEmojiPanel.classList.toggle('show', willOpen); $composeEmojiBtn.setAttribute('aria-expanded', willOpen ? 'true' : 'false'); @@ -9200,14 +9320,219 @@

No conversations yet

$composeInput.focus(); } - if ($composeEmojiBtn) { - $composeEmojiBtn.addEventListener('click', (event) => { - event.stopPropagation(); - toggleComposeEmojiPanel(); - }); - } - - if ($threadSearchInput) { + if ($composeEmojiBtn) { + $composeEmojiBtn.addEventListener('click', (event) => { + event.stopPropagation(); + toggleComposeEmojiPanel(); + }); + } + + const GIF_SEARCH_DEBOUNCE_MS = 500; + let gifSearchTimeout = null; + let gifSearchRequestID = 0; + let gifSending = false; + + function buildComposeGifPanel() { + if (!$composeGifPanel || $composeGifPanel.dataset.hydrated === 'true') return; + $composeGifPanel.innerHTML = ` + +
+
+ `; + $composeGifPanel.dataset.hydrated = 'true'; + const search = document.getElementById('compose-gif-search'); + if (search) { + search.addEventListener('input', () => scheduleComposeGifSearch(search.value)); + } + } + + function scheduleComposeGifSearch(query) { + if (gifSearchTimeout) clearTimeout(gifSearchTimeout); + gifSearchTimeout = setTimeout(() => { + gifSearchTimeout = null; + loadComposeGifs(query).catch(err => console.error('GIF search failed:', err)); + }, GIF_SEARCH_DEBOUNCE_MS); + } + + function closeComposeGifPanel() { + if (!$composeGifPanel) return; + if (gifSearchTimeout) { + clearTimeout(gifSearchTimeout); + gifSearchTimeout = null; + } + $composeGifPanel.classList.remove('show'); + if ($composeGifBtn) $composeGifBtn.setAttribute('aria-expanded', 'false'); + } + + function toggleComposeGifPanel() { + if (!$composeGifPanel || !$composeGifBtn) return; + if (!conversationSupportsMediaOutbound(activeConversation)) { + showThreadFeedback('GIFs are unavailable on this route right now.'); + return; + } + buildComposeGifPanel(); + const willOpen = !$composeGifPanel.classList.contains('show'); + closeComposeEmojiPanel(); + document.querySelectorAll('.emoji-picker.show').forEach(p => p.classList.remove('show')); + document.querySelectorAll('.emoji-full-panel.show').forEach(p => p.classList.remove('show')); + $composeGifPanel.classList.toggle('show', willOpen); + $composeGifBtn.setAttribute('aria-expanded', willOpen ? 'true' : 'false'); + if (willOpen) { + const input = document.getElementById('compose-gif-search'); + if (input) input.focus(); + if ($composeGifPanel.dataset.loaded !== 'true') { + loadComposeGifs('').catch(err => console.error('GIF search failed:', err)); + } + } + } + + async function loadComposeGifs(query) { + if (!$composeGifPanel) return; + const requestID = ++gifSearchRequestID; + const trimmed = String(query || '').trim(); + const status = document.getElementById('compose-gif-status'); + const grid = document.getElementById('compose-gif-grid'); + if (!status || !grid) return; + status.hidden = false; + status.textContent = 'Loading GIFs...'; + grid.innerHTML = ''; + + const endpoint = trimmed + ? `/api/gifs?q=${encodeURIComponent(trimmed)}&limit=32` + : '/api/gifs/trending?limit=32'; + try { + const payload = await fetchJSON(endpoint); + if (requestID !== gifSearchRequestID) return; + const results = Array.isArray(payload) ? payload : (payload.results || []); + renderComposeGifs(results); + $composeGifPanel.dataset.loaded = 'true'; + } catch (err) { + if (requestID !== gifSearchRequestID) return; + status.hidden = false; + status.textContent = 'GIF search failed.'; + throw err; + } + } + + function renderComposeGifs(results) { + const status = document.getElementById('compose-gif-status'); + const grid = document.getElementById('compose-gif-grid'); + if (!status || !grid) return; + grid.innerHTML = ''; + if (!Array.isArray(results) || results.length === 0) { + status.hidden = false; + status.textContent = 'No GIFs found.'; + return; + } + status.hidden = true; + results.forEach(result => { + if (!result || !result.preview_url || !result.url) return; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'gif-tile'; + const title = result.title || 'GIF'; + btn.title = title; + btn.setAttribute('aria-label', title); + const img = document.createElement('img'); + img.src = result.preview_url; + img.alt = title; + img.loading = 'lazy'; + btn.appendChild(img); + btn.addEventListener('click', () => { + sendComposeGif(result).catch(err => console.error('Failed to send GIF:', err)); + }); + grid.appendChild(btn); + }); + if (!grid.children.length) { + status.hidden = false; + status.textContent = 'No GIFs found.'; + } + } + + async function sendComposeGif(result) { + if (gifSending) return; + if (!activeConversation || !activeConvoId) return; + if (browserIsOffline()) { + showThreadFeedback(offlineFeedbackMessage('send'), 'send'); + return; + } + if (!conversationSupportsMediaOutbound(activeConversation)) { + showThreadFeedback('GIFs are unavailable on this route right now.'); + return; + } + if (pendingFile) { + showThreadFeedback('Send or remove the current attachment before choosing a GIF.'); + return; + } + + const sendConvoId = activeConvoId; + const sendConversation = activeConversation; + const activePlatform = sourcePlatformOf(sendConversation); + const text = $composeInput.value.trim(); + const originalText = $composeInput.value; + const supportsMediaCaption = activePlatform === 'whatsapp' || activePlatform === 'signal'; + + clearThreadFeedback(); + gifSending = true; + $composeInput.value = ''; + composeTextByConversation.delete(sendConvoId); + autoResize(); + + try { + const payload = { + conversation_id: sendConvoId, + url: result.url, + }; + if (supportsMediaCaption && text) payload.caption = text; + if (supportsMediaCaption && replyToMsg) payload.reply_to_id = replyToMsg.MessageID; + await postJSON('/api/send-gif', payload); + + if (text && !supportsMediaCaption) { + const textPayload = { + conversation_id: sendConvoId, + message: text, + }; + if (replyToMsg) textPayload.reply_to_id = replyToMsg.MessageID; + await postJSON('/api/send', textPayload); + } + + closeComposeGifPanel(); + clearReply(); + if (activeConvoId === sendConvoId) { + if (threadSearchMode === 'centered') { + clearThreadSearchUI(); + resetLoadedThreadState(); + await loadMessages(sendConvoId, {reset: true, forceBottom: true}); + } else { + await loadMessages(sendConvoId, {poll: true, forceBottom: true}); + } + } + await loadConversations(); + } catch (err) { + if (text) { + if (activeConvoId === sendConvoId) { + $composeInput.value = originalText; + } + rememberComposeText(sendConvoId, originalText); + } + showThreadFeedback(err.message || 'Failed to send GIF', 'send'); + throw err; + } finally { + gifSending = false; + autoResize(); + } + } + + if ($composeGifBtn) { + $composeGifBtn.addEventListener('click', (event) => { + event.stopPropagation(); + toggleComposeGifPanel(); + }); + } + + if ($threadSearchInput) { $threadSearchInput.addEventListener('input', scheduleThreadSearch); $threadSearchInput.addEventListener('keydown', async (event) => { if (event.key === 'Enter') { @@ -9261,22 +9586,61 @@

No conversations yet

autoResize(); } - // Paste handler: Ctrl+V / Cmd+V with image data - $composeInput.addEventListener('paste', e => { - const items = e.clipboardData?.items; - if (!items) return; + // Paste handler: Ctrl+V / Cmd+V with image data + $composeInput.addEventListener('paste', e => { + const items = e.clipboardData?.items; + if (!items) return; for (const item of items) { if (item.kind === 'file' && item.type.startsWith('image/')) { e.preventDefault(); const file = item.getAsFile(); if (file) setAttachment(file); return; - } - } - }); - - // File picker button - $attachBtn.addEventListener('click', () => { + } + } + }); + + let composeDragDepth = 0; + + function dragEventHasFiles(event) { + const types = event.dataTransfer?.types; + return !!types && Array.from(types).includes('Files'); + } + + function setComposeDragOver(active) { + if (!$composeBar) return; + $composeBar.classList.toggle('drag-over', !!active); + } + + if ($composeBar) { + $composeBar.addEventListener('dragenter', event => { + if (!dragEventHasFiles(event)) return; + event.preventDefault(); + composeDragDepth += 1; + setComposeDragOver(true); + }); + $composeBar.addEventListener('dragover', event => { + if (!dragEventHasFiles(event)) return; + event.preventDefault(); + event.dataTransfer.dropEffect = conversationSupportsMediaOutbound(activeConversation) ? 'copy' : 'none'; + }); + $composeBar.addEventListener('dragleave', event => { + if (!dragEventHasFiles(event)) return; + composeDragDepth = Math.max(0, composeDragDepth - 1); + if (composeDragDepth === 0) setComposeDragOver(false); + }); + $composeBar.addEventListener('drop', event => { + if (!dragEventHasFiles(event)) return; + event.preventDefault(); + composeDragDepth = 0; + setComposeDragOver(false); + const file = event.dataTransfer?.files?.[0]; + if (file) setAttachment(file); + }); + } + + // File picker button + $attachBtn.addEventListener('click', () => { if (!conversationSupportsMediaOutbound(activeConversation)) { showThreadFeedback('Attachments are unavailable on this route right now.'); return; @@ -10185,10 +10549,11 @@

No conversations yet

} // ─── Reactions ─── - window.sendReaction = async function(messageId, emoji) { - // Close pickers - document.querySelectorAll('.emoji-picker.show').forEach(p => p.classList.remove('show')); - document.querySelectorAll('.emoji-full-panel.show').forEach(p => p.classList.remove('show')); + window.sendReaction = async function(messageId, emoji) { + // Close pickers + document.querySelectorAll('.emoji-picker.show').forEach(p => p.classList.remove('show')); + document.querySelectorAll('.emoji-full-panel.show').forEach(p => p.classList.remove('show')); + closeComposeGifPanel(); try { await postJSON('/api/react', { message_id: messageId, @@ -10263,12 +10628,13 @@

No conversations yet

}; // Close react picker and full panel when clicking elsewhere - document.addEventListener('click', (e) => { - if (!e.target.closest('.emoji-picker') && !e.target.closest('.emoji-full-panel') && !e.target.closest('.msg-actions') && !e.target.closest('#compose-emoji-btn')) { - document.querySelectorAll('.emoji-picker.show').forEach(p => p.classList.remove('show')); - document.querySelectorAll('.emoji-full-panel.show').forEach(p => p.classList.remove('show')); - if ($composeEmojiBtn) $composeEmojiBtn.setAttribute('aria-expanded', 'false'); - } + document.addEventListener('click', (e) => { + if (!e.target.closest('.emoji-picker') && !e.target.closest('.emoji-full-panel') && !e.target.closest('.gif-compose-panel') && !e.target.closest('.msg-actions') && !e.target.closest('#compose-emoji-btn') && !e.target.closest('#compose-gif-btn')) { + document.querySelectorAll('.emoji-picker.show').forEach(p => p.classList.remove('show')); + document.querySelectorAll('.emoji-full-panel.show').forEach(p => p.classList.remove('show')); + if ($composeEmojiBtn) $composeEmojiBtn.setAttribute('aria-expanded', 'false'); + closeComposeGifPanel(); + } if ($threadSearchResults && !$threadSearchResults.hidden && !e.target.closest('.thread-search')) { clearThreadSearchResults(); } @@ -10382,12 +10748,13 @@

No conversations yet

$replyIndicator.classList.remove('show'); } - function clearTransientComposerState() { - clearReply(); - if (pendingFile) { - clearAttachment(); - } - } + function clearTransientComposerState() { + clearReply(); + if (pendingFile) { + clearAttachment(); + } + closeComposeGifPanel(); + } $replyClose.addEventListener('click', clearReply); @@ -10514,11 +10881,12 @@

No conversations yet

e.preventDefault(); // Close the context menu if open if ($contextMenu && !$contextMenu.hidden) { closeContextMenu(); return; } - // Close fullscreen image viewer if open - const fullscreen = document.querySelector('.msg-image-fullscreen'); - if (fullscreen) { fullscreen.remove(); return; } - // Close any open emoji pickers or full panels - const openPanels = document.querySelectorAll('.emoji-full-panel.show'); + // Close fullscreen image viewer if open + const fullscreen = document.querySelector('.msg-image-fullscreen'); + if (fullscreen) { fullscreen.remove(); return; } + if ($composeGifPanel && $composeGifPanel.classList.contains('show')) { closeComposeGifPanel(); return; } + // Close any open emoji pickers or full panels + const openPanels = document.querySelectorAll('.emoji-full-panel.show'); if (openPanels.length) { openPanels.forEach(p => p.classList.remove('show')); if ($composeEmojiBtn) $composeEmojiBtn.setAttribute('aria-expanded', 'false');