From 33007b3c7258a8029e502e2846004f3562817efa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A5=D0=B0=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A0=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=BC?= Date: Mon, 16 Mar 2026 15:53:57 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9A=D1=8D=D1=88=20=D0=B4=D0=BB=D1=8F=20GIF,?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=D1=81=D0=B2=D0=B5=D1=82=D0=BA=D0=B0=20?= =?UTF-8?q?=D1=86=D0=B8=D1=82=D0=B0=D1=82=20=D0=B8=20=D0=BE=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/Host/Controllers/ConfigController.cs | 2 - .../src/Host/Controllers/KlipyController.cs | 82 +++++++++++ apps/server-net/src/Host/Program.cs | 2 + apps/web/src/components/EmojiPicker.tsx | 128 +++++++----------- apps/web/src/components/MessageBubble.tsx | 2 +- apps/web/src/components/MessageInput.tsx | 7 + apps/web/src/index.css | 11 ++ apps/web/src/lib/api.ts | 9 ++ 8 files changed, 159 insertions(+), 84 deletions(-) create mode 100644 apps/server-net/src/Host/Controllers/KlipyController.cs diff --git a/apps/server-net/src/Host/Controllers/ConfigController.cs b/apps/server-net/src/Host/Controllers/ConfigController.cs index 4261480..b1618bd 100644 --- a/apps/server-net/src/Host/Controllers/ConfigController.cs +++ b/apps/server-net/src/Host/Controllers/ConfigController.cs @@ -24,8 +24,6 @@ public class ConfigController : ControllerBase { conf.EnableCalls, conf.EnableKlipy, - KlipyApiKey = conf.EnableKlipy ? conf.KlipyApiKey : null, - KlipyCustomerId = conf.EnableKlipy ? conf.KlipyCustomerId : null, conf.MaxFileSizeMb, conf.MaxGroupMembers, conf.EnableConfederation diff --git a/apps/server-net/src/Host/Controllers/KlipyController.cs b/apps/server-net/src/Host/Controllers/KlipyController.cs new file mode 100644 index 0000000..4d73aec --- /dev/null +++ b/apps/server-net/src/Host/Controllers/KlipyController.cs @@ -0,0 +1,82 @@ +using Knot.Shared.Kernel.Configuration; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using System.Text.Json; + +namespace Host.Controllers; + +[Authorize] +[ApiController] +[Route("api/[controller]")] +public class KlipyController : ControllerBase +{ + private readonly ISettingsService _settings; + private readonly IMemoryCache _cache; + private readonly IHttpClientFactory _httpClientFactory; + + public KlipyController(ISettingsService settings, IMemoryCache cache, IHttpClientFactory httpClientFactory) + { + _settings = settings; + _cache = cache; + _httpClientFactory = httpClientFactory; + } + + [HttpGet("trending")] + public async Task GetTrending() + { + var conf = _settings.Current; + if (!conf.EnableKlipy || string.IsNullOrEmpty(conf.KlipyApiKey)) + return BadRequest(new { error = "Klipy is disabled or not configured." }); + + if (_cache.TryGetValue("klipy_trending", out JsonElement cachedResult)) + { + return Ok(cachedResult); + } + + try + { + var client = _httpClientFactory.CreateClient(); + var url = $"https://api.klipy.com/api/v1/{conf.KlipyApiKey}/gifs/trending?page=1&per_page=30&customer_id={conf.KlipyCustomerId ?? "anonymous"}"; + var result = await client.GetFromJsonAsync(url); + + _cache.Set("klipy_trending", result, TimeSpan.FromMinutes(60)); // Cache trending for 60 minutes + return Ok(result); + } + catch (Exception ex) + { + return StatusCode(500, new { error = "Failed to fetch from Klipy", details = ex.Message }); + } + } + + [HttpGet("search")] + public async Task Search([FromQuery] string q) + { + var conf = _settings.Current; + if (!conf.EnableKlipy || string.IsNullOrEmpty(conf.KlipyApiKey)) + return BadRequest(new { error = "Klipy is disabled or not configured." }); + + if (string.IsNullOrWhiteSpace(q)) + return BadRequest(new { error = "Query is empty." }); + + var cacheKey = $"klipy_search_{q.ToLowerInvariant()}"; + if (_cache.TryGetValue(cacheKey, out JsonElement cachedResult)) + { + return Ok(cachedResult); + } + + try + { + var client = _httpClientFactory.CreateClient(); + var url = $"https://api.klipy.com/api/v1/{conf.KlipyApiKey}/gifs/search?page=1&per_page=30&q={Uri.EscapeDataString(q)}&customer_id={conf.KlipyCustomerId ?? "anonymous"}"; + var result = await client.GetFromJsonAsync(url); + + _cache.Set(cacheKey, result, TimeSpan.FromMinutes(15)); // Cache searches for 15 minutes + return Ok(result); + } + catch (Exception ex) + { + return StatusCode(500, new { error = "Failed to fetch from Klipy", details = ex.Message }); + } + } +} diff --git a/apps/server-net/src/Host/Program.cs b/apps/server-net/src/Host/Program.cs index 4f5ab94..1488300 100644 --- a/apps/server-net/src/Host/Program.cs +++ b/apps/server-net/src/Host/Program.cs @@ -79,6 +79,8 @@ builder.Services.AddRouting(options => options.LowercaseQueryStrings = true; }); +builder.Services.AddMemoryCache(); +builder.Services.AddHttpClient(); builder.Services.AddControllers() .AddJsonOptions(options => diff --git a/apps/web/src/components/EmojiPicker.tsx b/apps/web/src/components/EmojiPicker.tsx index 5a1ef74..f5880d7 100644 --- a/apps/web/src/components/EmojiPicker.tsx +++ b/apps/web/src/components/EmojiPicker.tsx @@ -5,6 +5,7 @@ import data from '@emoji-mart/data'; import { Search, TrendingUp, Loader2 } from 'lucide-react'; import { useLang } from '../lib/i18n'; import { useAuthStore } from '../stores/authStore'; +import { api } from '../lib/api'; interface KlipyGif { id: string; @@ -22,18 +23,9 @@ interface EmojiPickerProps { onClose: () => void; } -const getKlipyKey = (config: any) => { - return config?.klipyApiKey || import.meta.env.VITE_KLIPY_API_KEY || ''; -}; -const getCustomerId = (config: any, user: any) => { - return config?.klipyCustomerId || user?.id || 'anonymous'; -}; - export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPickerProps) { const { lang, t } = useLang(); - const { config, user } = useAuthStore(); - const klipyApiKey = getKlipyKey(config); - const klipyCustomerId = getCustomerId(config, user); + const { config } = useAuthStore(); const [tab, setTab] = useState<'emoji' | 'gif'>('emoji'); const [gifQuery, setGifQuery] = useState(''); const [gifs, setGifs] = useState([]); @@ -57,17 +49,10 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic // Load trending GIFs (Klipy) useEffect(() => { - if (tab === 'gif' && klipyApiKey && !initialFetchDone.current) { + if (tab === 'gif' && config?.enableKlipy && !initialFetchDone.current) { initialFetchDone.current = true; setGifLoading(true); - fetch(`https://api.klipy.com/api/v1/${klipyApiKey}/gifs/trending?page=1&per_page=30&customer_id=${klipyCustomerId}`) - .then(async (r) => { - if (!r.ok) { - const text = await r.text(); - throw new Error(`Klipy error: ${r.status} ${text.substring(0, 100)}`); - } - return r.json(); - }) + api.getTrendingGifs() .then(d => { setTrendingGifs(extractGifs(d)); setGifLoading(false); @@ -78,23 +63,16 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic setGifLoading(false); }); } - }, [tab, klipyApiKey, klipyCustomerId]); + }, [tab, config?.enableKlipy]); const searchGifs = useCallback((q: string) => { - if (!klipyApiKey || !q.trim()) { + if (!config?.enableKlipy || !q.trim()) { setGifs([]); setGifLoading(false); return; } setGifLoading(true); - fetch(`https://api.klipy.com/api/v1/${klipyApiKey}/gifs/search?page=1&per_page=30&q=${encodeURIComponent(q)}&customer_id=${klipyCustomerId}`) - .then(async (r) => { - if (!r.ok) { - const text = await r.text(); - throw new Error(`Klipy error: ${r.status} ${text.substring(0, 100)}`); - } - return r.json(); - }) + api.searchKlipyGifs(q) .then(d => { setGifs(extractGifs(d)); setGifLoading(false); @@ -104,7 +82,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic setGifs([]); setGifLoading(false); }); - }, [klipyApiKey, klipyCustomerId]); + }, [config?.enableKlipy]); const handleGifSearch = (q: string) => { setGifQuery(q); @@ -173,56 +151,46 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic visibility: pos ? 'visible' : 'hidden', }} > - {/* Tabs */} -
- - {config?.enableKlipy && (klipyApiKey || onSelectGif) && ( - - )} -
+ {/* Tabs */} +
+ + {config?.enableKlipy && onSelectGif && ( + + )} +
- {/* Emoji tab */} - {tab === 'emoji' && ( - onSelect(e.native)} - theme="dark" - locale={lang === 'ru' ? 'ru' : 'en'} - set="native" - previewPosition="none" - skinTonePosition="search" - perLine={9} - emojiSize={28} - emojiButtonSize={36} - maxFrequentRows={2} - navPosition="bottom" - dynamicWidth={false} - /> - )} + {/* Emoji tab */} + {tab === 'emoji' && ( + onSelect(e.native)} + theme="dark" + locale={lang === 'ru' ? 'ru' : 'en'} + set="native" + previewPosition="none" + skinTonePosition="search" + perLine={9} + emojiSize={28} + emojiButtonSize={36} + maxFrequentRows={2} + navPosition="bottom" + dynamicWidth={false} + /> + )} - {/* GIF tab */} - {config?.enableKlipy && tab === 'gif' && ( -
- {!klipyApiKey ? ( -
-

Klipy API Key required

-

{t('openConsoleRun')}

- - VITE_KLIPY_API_KEY in .env - -
- ) : ( - <> + {/* GIF tab */} + {config?.enableKlipy && tab === 'gif' && ( +
@@ -267,11 +235,9 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
)}
- +
)}
- )} - , document.body )} diff --git a/apps/web/src/components/MessageBubble.tsx b/apps/web/src/components/MessageBubble.tsx index 2b3dd3e..18e3a2f 100644 --- a/apps/web/src/components/MessageBubble.tsx +++ b/apps/web/src/components/MessageBubble.tsx @@ -409,7 +409,7 @@ function MessageBubble({ if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.classList.add('highlight-message'); - setTimeout(() => el.classList.remove('highlight-message'), 2000); + setTimeout(() => el.classList.remove('highlight-message'), 3000); } }} > diff --git a/apps/web/src/components/MessageInput.tsx b/apps/web/src/components/MessageInput.tsx index 337bd61..e2312ae 100644 --- a/apps/web/src/components/MessageInput.tsx +++ b/apps/web/src/components/MessageInput.tsx @@ -139,6 +139,13 @@ export default function MessageInput({ chatId }: MessageInputProps) { } }, [editingMessage]); + // При ответе - фокус на поле ввода + useEffect(() => { + if (replyTo) { + inputRef.current?.focus(); + } + }, [replyTo]); + // Load draft when switching chats useEffect(() => { if (!editingMessage) { diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 705d5ee..6b74a82 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -306,3 +306,14 @@ em-emoji-picker { --border-radius: 0 0 16px 16px; border: none !important; } + +/* Highlight for quoted messages */ +@keyframes highlight-glow { + 0% { box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.8), 0 0 20px rgba(99, 102, 241, 0.6); } + 20% { box-shadow: 0 0 0 4px rgba(99, 102, 241, 1), 0 0 30px rgba(99, 102, 241, 0.8); } + 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0), 0 0 0 rgba(99, 102, 241, 0); } +} + +.highlight-message { + animation: highlight-glow 3s ease-out forwards; +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index dd47d83..07c9778 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -382,6 +382,15 @@ class ApiClient { async getIceServers() { return this.request<{ iceServers: RTCIceServer[] }>('/webrtc/ice-servers'); } + + // Klipy + async getTrendingGifs() { + return this.request('/klipy/trending'); + } + + async searchKlipyGifs(query: string) { + return this.request(`/klipy/search?q=${encodeURIComponent(query)}`); + } } export const api = new ApiClient();