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 @@