Кэш для GIF, подсветка цитат и ответов
This commit is contained in:
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 =>
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -181,7 +159,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
|||||||
>
|
>
|
||||||
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'}`}
|
||||||
@@ -213,16 +191,6 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
|||||||
{/* 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,8 +235,6 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user