Кэш для GIF, подсветка цитат и ответов

This commit is contained in:
Халимов Рустам
2026-03-16 15:53:57 +03:00
parent 239b08b566
commit 33007b3c72
8 changed files with 159 additions and 84 deletions
@@ -24,8 +24,6 @@ public class ConfigController : ControllerBase
{ {
conf.EnableCalls, conf.EnableCalls,
conf.EnableKlipy, conf.EnableKlipy,
KlipyApiKey = conf.EnableKlipy ? conf.KlipyApiKey : null,
KlipyCustomerId = conf.EnableKlipy ? conf.KlipyCustomerId : null,
conf.MaxFileSizeMb, conf.MaxFileSizeMb,
conf.MaxGroupMembers, conf.MaxGroupMembers,
conf.EnableConfederation conf.EnableConfederation
@@ -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<IActionResult> 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<JsonElement>(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<IActionResult> 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<JsonElement>(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 });
}
}
}
+2
View File
@@ -79,6 +79,8 @@ builder.Services.AddRouting(options =>
options.LowercaseQueryStrings = true; options.LowercaseQueryStrings = true;
}); });
builder.Services.AddMemoryCache();
builder.Services.AddHttpClient();
builder.Services.AddControllers() builder.Services.AddControllers()
.AddJsonOptions(options => .AddJsonOptions(options =>
+47 -81
View File
@@ -5,6 +5,7 @@ import data from '@emoji-mart/data';
import { Search, TrendingUp, Loader2 } from 'lucide-react'; import { Search, TrendingUp, Loader2 } from 'lucide-react';
import { useLang } from '../lib/i18n'; import { useLang } from '../lib/i18n';
import { useAuthStore } from '../stores/authStore'; import { useAuthStore } from '../stores/authStore';
import { api } from '../lib/api';
interface KlipyGif { interface KlipyGif {
id: string; id: string;
@@ -22,18 +23,9 @@ interface EmojiPickerProps {
onClose: () => void; 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) { export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPickerProps) {
const { lang, t } = useLang(); const { lang, t } = useLang();
const { config, user } = useAuthStore(); const { config } = useAuthStore();
const klipyApiKey = getKlipyKey(config);
const klipyCustomerId = getCustomerId(config, user);
const [tab, setTab] = useState<'emoji' | 'gif'>('emoji'); const [tab, setTab] = useState<'emoji' | 'gif'>('emoji');
const [gifQuery, setGifQuery] = useState(''); const [gifQuery, setGifQuery] = useState('');
const [gifs, setGifs] = useState<KlipyGif[]>([]); const [gifs, setGifs] = useState<KlipyGif[]>([]);
@@ -57,17 +49,10 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
// Load trending GIFs (Klipy) // Load trending GIFs (Klipy)
useEffect(() => { useEffect(() => {
if (tab === 'gif' && klipyApiKey && !initialFetchDone.current) { if (tab === 'gif' && config?.enableKlipy && !initialFetchDone.current) {
initialFetchDone.current = true; initialFetchDone.current = true;
setGifLoading(true); setGifLoading(true);
fetch(`https://api.klipy.com/api/v1/${klipyApiKey}/gifs/trending?page=1&per_page=30&customer_id=${klipyCustomerId}`) api.getTrendingGifs()
.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();
})
.then(d => { .then(d => {
setTrendingGifs(extractGifs(d)); setTrendingGifs(extractGifs(d));
setGifLoading(false); setGifLoading(false);
@@ -78,23 +63,16 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
setGifLoading(false); setGifLoading(false);
}); });
} }
}, [tab, klipyApiKey, klipyCustomerId]); }, [tab, config?.enableKlipy]);
const searchGifs = useCallback((q: string) => { const searchGifs = useCallback((q: string) => {
if (!klipyApiKey || !q.trim()) { if (!config?.enableKlipy || !q.trim()) {
setGifs([]); setGifs([]);
setGifLoading(false); setGifLoading(false);
return; return;
} }
setGifLoading(true); setGifLoading(true);
fetch(`https://api.klipy.com/api/v1/${klipyApiKey}/gifs/search?page=1&per_page=30&q=${encodeURIComponent(q)}&customer_id=${klipyCustomerId}`) api.searchKlipyGifs(q)
.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();
})
.then(d => { .then(d => {
setGifs(extractGifs(d)); setGifs(extractGifs(d));
setGifLoading(false); setGifLoading(false);
@@ -104,7 +82,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
setGifs([]); setGifs([]);
setGifLoading(false); setGifLoading(false);
}); });
}, [klipyApiKey, klipyCustomerId]); }, [config?.enableKlipy]);
const handleGifSearch = (q: string) => { const handleGifSearch = (q: string) => {
setGifQuery(q); setGifQuery(q);
@@ -173,56 +151,46 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
visibility: pos ? 'visible' : 'hidden', visibility: pos ? 'visible' : 'hidden',
}} }}
> >
{/* Tabs */} {/* Tabs */}
<div className="flex border-b border-white/10"> <div className="flex border-b border-white/10">
<button <button
onClick={() => setTab('emoji')} onClick={() => setTab('emoji')}
className={`flex-1 py-2.5 text-xs font-semibold tracking-wide transition-colors ${tab === 'emoji' ? 'text-white border-b-2 border-accent' : 'text-zinc-500 hover:text-zinc-300'}`} className={`flex-1 py-2.5 text-xs font-semibold tracking-wide transition-colors ${tab === 'emoji' ? 'text-white border-b-2 border-accent' : 'text-zinc-500 hover:text-zinc-300'}`}
> >
EMOJI EMOJI
</button> </button>
{config?.enableKlipy && (klipyApiKey || onSelectGif) && ( {config?.enableKlipy && onSelectGif && (
<button <button
onClick={() => { setTab('gif'); setTimeout(() => gifSearchRef.current?.focus(), 100); }} onClick={() => { setTab('gif'); setTimeout(() => gifSearchRef.current?.focus(), 100); }}
className={`flex-1 py-2.5 text-xs font-semibold tracking-wide transition-colors ${tab === 'gif' ? 'text-white border-b-2 border-accent' : 'text-zinc-500 hover:text-zinc-300'}`} className={`flex-1 py-2.5 text-xs font-semibold tracking-wide transition-colors ${tab === 'gif' ? 'text-white border-b-2 border-accent' : 'text-zinc-500 hover:text-zinc-300'}`}
> >
GIF GIF
</button> </button>
)} )}
</div> </div>
{/* Emoji tab */} {/* Emoji tab */}
{tab === 'emoji' && ( {tab === 'emoji' && (
<Picker <Picker
data={data} data={data}
onEmojiSelect={(e: { native: string }) => onSelect(e.native)} onEmojiSelect={(e: { native: string }) => onSelect(e.native)}
theme="dark" theme="dark"
locale={lang === 'ru' ? 'ru' : 'en'} locale={lang === 'ru' ? 'ru' : 'en'}
set="native" set="native"
previewPosition="none" previewPosition="none"
skinTonePosition="search" skinTonePosition="search"
perLine={9} perLine={9}
emojiSize={28} emojiSize={28}
emojiButtonSize={36} emojiButtonSize={36}
maxFrequentRows={2} maxFrequentRows={2}
navPosition="bottom" navPosition="bottom"
dynamicWidth={false} dynamicWidth={false}
/> />
)} )}
{/* GIF tab */} {/* GIF tab */}
{config?.enableKlipy && tab === 'gif' && ( {config?.enableKlipy && tab === 'gif' && (
<div className="flex flex-col h-[calc(100%-41px)]"> <div className="flex flex-col h-[calc(100%-41px)]">
{!klipyApiKey ? (
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
<p className="text-sm text-zinc-400 mb-2">Klipy API Key required</p>
<p className="text-xs text-zinc-500 mb-3">{t('openConsoleRun')}</p>
<code className="text-xs bg-black/30 px-3 py-1.5 rounded-lg text-knot-400">
VITE_KLIPY_API_KEY in .env
</code>
</div>
) : (
<>
<div className="p-2"> <div className="p-2">
<div className="relative"> <div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" /> <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
@@ -267,11 +235,9 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
</div> </div>
)} )}
</div> </div>
</> </div>
)} )}
</div> </div>
)}
</div>
</>, </>,
document.body document.body
)} )}
+1 -1
View File
@@ -409,7 +409,7 @@ function MessageBubble({
if (el) { if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('highlight-message'); el.classList.add('highlight-message');
setTimeout(() => el.classList.remove('highlight-message'), 2000); setTimeout(() => el.classList.remove('highlight-message'), 3000);
} }
}} }}
> >
+7
View File
@@ -139,6 +139,13 @@ export default function MessageInput({ chatId }: MessageInputProps) {
} }
}, [editingMessage]); }, [editingMessage]);
// При ответе - фокус на поле ввода
useEffect(() => {
if (replyTo) {
inputRef.current?.focus();
}
}, [replyTo]);
// Load draft when switching chats // Load draft when switching chats
useEffect(() => { useEffect(() => {
if (!editingMessage) { if (!editingMessage) {
+11
View File
@@ -306,3 +306,14 @@ em-emoji-picker {
--border-radius: 0 0 16px 16px; --border-radius: 0 0 16px 16px;
border: none !important; 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;
}
+9
View File
@@ -382,6 +382,15 @@ class ApiClient {
async getIceServers() { async getIceServers() {
return this.request<{ iceServers: RTCIceServer[] }>('/webrtc/ice-servers'); return this.request<{ iceServers: RTCIceServer[] }>('/webrtc/ice-servers');
} }
// Klipy
async getTrendingGifs() {
return this.request<any>('/klipy/trending');
}
async searchKlipyGifs(query: string) {
return this.request<any>(`/klipy/search?q=${encodeURIComponent(query)}`);
}
} }
export const api = new ApiClient(); export const api = new ApiClient();