diff --git a/Dockerfile.web b/Dockerfile.web index cbf3686..aa1d6c9 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -13,10 +13,6 @@ RUN npm install --legacy-peer-deps # Copy source code COPY apps/web/ ./ -# Build args -ARG VITE_KLIPY_API_KEY -ENV VITE_KLIPY_API_KEY=$VITE_KLIPY_API_KEY - # Build web frontend RUN npm run build diff --git a/apps/server-net/src/Host/Controllers/AdminController.cs b/apps/server-net/src/Host/Controllers/AdminController.cs index 621adf8..c7d5704 100644 --- a/apps/server-net/src/Host/Controllers/AdminController.cs +++ b/apps/server-net/src/Host/Controllers/AdminController.cs @@ -62,7 +62,8 @@ public class AdminController : ControllerBase DisplayName = u.DisplayName, Avatar = u.Avatar, CreatedAt = u.CreatedAt, - LastOnlineAt = u.CreatedAt // fallback + IsOnline = Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(u.Id.ToString()), + LastOnlineAt = Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(u.Id.ToString()) ? DateTime.UtcNow : u.CreatedAt })); } @@ -101,7 +102,8 @@ public class AdminController : ControllerBase Bio = targetUser.Bio, Avatar = targetUser.Avatar, CreatedAt = targetUser.CreatedAt, - LastOnlineAt = targetUser.CreatedAt, // fallback + IsOnline = Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()), + LastOnlineAt = Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()) ? DateTime.UtcNow : targetUser.CreatedAt, Stats = new { MessagesCount = messagesCount, diff --git a/apps/server-net/src/Host/Controllers/MessagesController.cs b/apps/server-net/src/Host/Controllers/MessagesController.cs index ccae746..adaf211 100644 --- a/apps/server-net/src/Host/Controllers/MessagesController.cs +++ b/apps/server-net/src/Host/Controllers/MessagesController.cs @@ -93,8 +93,8 @@ public sealed class MessagesController : ControllerBase if (rUser != null) senders[r.UserId] = rUser; } var userObj = senders.TryGetValue(r.UserId, out var ru) - ? new { id = ru.Id, username = ru.Username, displayName = ru.DisplayName } - : new { id = r.UserId, username = "unknown", displayName = "Unknown" }; + ? new { id = ru.Id, username = ru.Username, displayName = ru.DisplayName, avatar = ru.Avatar } + : new { id = r.UserId, username = "unknown", displayName = "Unknown", avatar = (string?)null }; reactionsWithUser.Add(new { @@ -141,7 +141,7 @@ public sealed class MessagesController : ControllerBase displayName = s.DisplayName, avatar = s.Avatar } : null, - readBy = m.ReadBy.Select(r => r.UserId).ToList(), + readBy = m.ReadBy.Select(r => new { userId = r.UserId }).ToList(), reactions = reactionsWithUser }); } @@ -266,7 +266,10 @@ public sealed class MessagesController : ControllerBase var filteredMedia = m.Media.Where(media => { var mediaType = media.Type?.ToLower() ?? "file"; - if (filterType == "media") return mediaType == "image" || mediaType == "video"; + var isGif = mediaType == "image" && (media.Url.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase) || media.Url.EndsWith(".gif", StringComparison.OrdinalIgnoreCase)); + + if (filterType == "media") return (mediaType == "image" || mediaType == "video") && !isGif; + if (filterType == "gifs") return isGif; if (filterType == "files") return mediaType != "image" && mediaType != "video" && mediaType != "link"; return true; }).ToList(); diff --git a/apps/server-net/src/Host/Controllers/TelegramImportController.cs b/apps/server-net/src/Host/Controllers/TelegramImportController.cs index 811591b..0f6121f 100644 --- a/apps/server-net/src/Host/Controllers/TelegramImportController.cs +++ b/apps/server-net/src/Host/Controllers/TelegramImportController.cs @@ -117,7 +117,7 @@ public sealed class TelegramImportController : ControllerBase }); } - public sealed record ExecuteImportRequest(Guid Token, Dictionary Mapping); + public sealed record ExecuteImportRequest(Guid Token, Dictionary Mapping, string? GroupName); [HttpPost("execute")] public async Task Execute([FromBody] ExecuteImportRequest req, CancellationToken ct) @@ -161,7 +161,7 @@ public sealed class TelegramImportController : ControllerBase else { // Create a group - var command = new CreateChatCommand("Импортированный чат", ChatType.Group, chatMembers); + var command = new CreateChatCommand(req.GroupName ?? "Импортированный чат", ChatType.Group, chatMembers); var res = await _sender.Send(command, ct); if (res.IsFailure) return BadRequest(res.Error); chatId = res.Value; @@ -224,15 +224,25 @@ public sealed class TelegramImportController : ControllerBase Guid senderGuid = lastSenderGuid; string content = ""; - if (textNode != null) + var mainBodyNode = node.QuerySelector(".body"); + var isForwarded = node.QuerySelector(".forwarded") != null; + + // To avoid grabbing text from inside the forwarded block as main text, + // we can look for .text that is a direct child of the main .body + // The .forwarded block has its own .text + var contentTextNode = isForwarded + ? (node.QuerySelector(".body > .text") ?? node.QuerySelector(".text:not(.forwarded .text)")) + : node.QuerySelector(".text"); + + if (contentTextNode != null) { - var html = textNode.InnerHtml + var html = contentTextNode.InnerHtml .Replace("
", "\n", StringComparison.OrdinalIgnoreCase) .Replace("
", "\n", StringComparison.OrdinalIgnoreCase) .Replace("
", "\n", StringComparison.OrdinalIgnoreCase); var tempParser = new AngleSharp.Html.Parser.HtmlParser(); var tempDoc = tempParser.ParseDocument("
" + html + "
"); - content = tempDoc.Body.TextContent.Trim(); + content = tempDoc.Body?.TextContent.Trim() ?? ""; } DateTime createdAt = lastCreatedAt; @@ -282,6 +292,7 @@ public sealed class TelegramImportController : ControllerBase } // Parse forwards + Guid? forwardedFromId = null; var forwardedNode = node.QuerySelector(".forwarded.body"); if (forwardedNode != null) { @@ -294,6 +305,15 @@ public sealed class TelegramImportController : ControllerBase } var fwdName = fwdNameText != null ? fwdNameText.TextContent.Trim() : "Неизвестного"; + if (req.Mapping.TryGetValue(fwdName, out var mappedFwdId) && mappedFwdId != Guid.Empty) + { + forwardedFromId = mappedFwdId; + } + else if (fwdName == "Это я" || fwdName == req.Mapping.FirstOrDefault(x => x.Value == myId).Key) + { + forwardedFromId = myId; + } + var fwdTextNode = forwardedNode.QuerySelector(".text"); string fwdContent = ""; if (fwdTextNode != null) @@ -304,12 +324,20 @@ public sealed class TelegramImportController : ControllerBase .Replace("
", "\n", StringComparison.OrdinalIgnoreCase); var tempParser = new AngleSharp.Html.Parser.HtmlParser(); var tempDoc = tempParser.ParseDocument("
" + fHtml + "
"); - fwdContent = tempDoc.Body.TextContent.Trim(); + fwdContent = tempDoc.Body?.TextContent.Trim() ?? ""; } - content = string.IsNullOrEmpty(content) - ? $"[Переслано от {fwdName}]:\n{fwdContent}" - : $"{content}\n\n[Переслано от {fwdName}]:\n{fwdContent}"; + if (forwardedFromId == null) + { + // We don't have this user in the app, map to generic string + content = string.IsNullOrEmpty(content) + ? $"[Переслано от {fwdName}]:\n{fwdContent}" + : $"{content}\n\n[Переслано от {fwdName}]:\n{fwdContent}"; + } + else if (string.IsNullOrEmpty(content)) + { + content = fwdContent; + } } // Parse replies @@ -356,6 +384,15 @@ public sealed class TelegramImportController : ControllerBase if (firstHref != null) { messageType = GetMediaTypes(firstHref).mType; + if (mediaNodes[0].ClassName?.Contains("animated") == true || firstHref.EndsWith(".mp4")) + { + // Treat telegram animated gifs as image format in app (we auto-loop mp4 images) + // But web app specifically handles "image" and "video" and microlink crashes on mp4 video + // Let's just keep "video" or "image". Actually, telegram exports gifs as mp4. + // We should let our app player handle it. + if (mediaNodes[0].ClassName?.Contains("animated") == true) + messageType = "image"; + } } } @@ -370,7 +407,7 @@ public sealed class TelegramImportController : ControllerBase else { string finalContent = content; - targetMessage = Message.Import(chatId, senderGuid, finalContent, messageType, createdAt, replyToId, null); + targetMessage = Message.Import(chatId, senderGuid, finalContent, messageType, createdAt, replyToId, forwardedFromId); var idAttr = node.GetAttribute("id"); if (!string.IsNullOrEmpty(idAttr)) @@ -396,8 +433,10 @@ public sealed class TelegramImportController : ControllerBase ms.Position = 0; var types = GetMediaTypes(href); + var finalMType = types.mType; + if (mediaNode.ClassName?.Contains("animated") == true) finalMType = "image"; var fileId = await _fileStorage.UploadFileAsync(ms, Path.GetFileName(href), types.cType); - targetMessage.AddMedia(types.mType, $"/api/files/{fileId}", Path.GetFileName(href), zipEntry.Length); + targetMessage.AddMedia(finalMType, $"/api/files/{fileId}", Path.GetFileName(href), zipEntry.Length); } } } diff --git a/apps/server-net/src/Modules/Chats/Infrastructure/SignalR/ChatHub.cs b/apps/server-net/src/Modules/Chats/Infrastructure/SignalR/ChatHub.cs index 0f674d8..b6e7160 100644 --- a/apps/server-net/src/Modules/Chats/Infrastructure/SignalR/ChatHub.cs +++ b/apps/server-net/src/Modules/Chats/Infrastructure/SignalR/ChatHub.cs @@ -23,6 +23,7 @@ public sealed class ChatHub : Hub private static readonly ConcurrentDictionary> _groupCallParticipants = new(); public static int OnlineUsersCount => _userConnections.Count; + public static bool IsUserOnline(string userId) => _userConnections.ContainsKey(userId); private readonly ISender _sender; private readonly IUserContext _userContext; diff --git a/apps/server-net/src/Shared/Knot.Shared.Kernel/Configuration/SystemSettingsDto.cs b/apps/server-net/src/Shared/Knot.Shared.Kernel/Configuration/SystemSettingsDto.cs index fd1fcf8..dbe67bb 100644 --- a/apps/server-net/src/Shared/Knot.Shared.Kernel/Configuration/SystemSettingsDto.cs +++ b/apps/server-net/src/Shared/Knot.Shared.Kernel/Configuration/SystemSettingsDto.cs @@ -8,14 +8,14 @@ public class SystemSettingsDto public int MaxFileSizeMb { get; set; } = 100; // Calls - public bool EnableCalls { get; set; } = true; + public bool EnableCalls { get; set; } = false; public string TurnHost { get; set; } = string.Empty; public int TurnPort { get; set; } = 3478; public string TurnUser { get; set; } = string.Empty; public string TurnSecret { get; set; } = string.Empty; // Klipy - public bool EnableKlipy { get; set; } = true; + public bool EnableKlipy { get; set; } = false; public string KlipyApiKey { get; set; } = string.Empty; public string KlipyCustomerId { get; set; } = string.Empty; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index edced55..2c441f8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -121,7 +121,6 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal useEffect(() => { if (activeChat) { setMuted(isChatMuted(activeChat)); - setScrollReady(false); setActiveGroupCallParticipants([]); lastObservedMessageIdRef.current = null; } @@ -181,6 +180,10 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal }, []); // Первичная прокрутка при открытии чата или после загрузки (layout effect — до отрисовки) + useLayoutEffect(() => { + setScrollReady(false); + }, [activeChat]); + useLayoutEffect(() => { if (!isLoadingMessages && messagesContainerRef.current) { const container = messagesContainerRef.current; @@ -202,7 +205,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal } setScrollReady(true); } - }, [activeChat, isLoadingMessages && chatMessages.length === 0]); + }, [activeChat, isLoadingMessages]); useLayoutEffect(() => { if (prevScrollHeightRef.current > 0 && messagesContainerRef.current) { @@ -298,14 +301,15 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal }); }; - observeUnread(); + // Delay slight to let everything mount and be visible in DOM + setTimeout(observeUnread, 100); return () => observer.disconnect(); }, [activeChat, user?.id]); // Re-run observation when messages change (to catch new arrivals) useEffect(() => { - if (observerRef.current && !isLoadingMessages) { + if (observerRef.current) { if (!messagesContainerRef.current) return; const unreadElements = messagesContainerRef.current.querySelectorAll('.unread-detector'); unreadElements.forEach((el) => { @@ -315,7 +319,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal } }); } - }, [chatMessages.length, isLoadingMessages]); + }, [chatMessages, scrollReady]); // Scroll detection const checkScrollPosition = useCallback(() => { @@ -332,7 +336,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal const container = messagesContainerRef.current; if (container && container.scrollTop < 100 && activeChat && hasMoreMessages[activeChat] && !isLoadingMessages) { prevScrollHeightRef.current = container.scrollHeight; - useChatStore.getState().loadMessages(activeChat, false); + useChatStore.getState().loadMessages(activeChat, false, true); } }; @@ -790,7 +794,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.classList.add('highlight-message'); - setTimeout(() => el.classList.remove('highlight-message'), 3000); + setTimeout(() => el.classList.remove('highlight-message'), 5000); } setShowSearch(false); setSearchText(''); @@ -837,7 +841,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.classList.add('highlight-message'); - setTimeout(() => el.classList.remove('highlight-message'), 3000); + setTimeout(() => el.classList.remove('highlight-message'), 5000); } }} className="flex items-center gap-3 px-4 py-2 border-b border-border bg-surface-secondary/60 hover:bg-surface-hover transition-colors text-left w-full flex-shrink-0" @@ -867,7 +871,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
0 ? 'invisible' : ''}`} + className={`flex-1 overflow-y-auto overflow-x-hidden px-6 pt-6 pb-2 relative z-10 ${!scrollReady && !isLoadingMessages && chatMessages.length > 0 ? 'invisible' : ''}`} > {isLoadingMessages && chatMessages.length === 0 ? (
@@ -947,7 +951,12 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal initial={{ scale: 0, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0, opacity: 0 }} - onClick={() => scrollToBottom()} + onClick={() => { + scrollToBottom(false); + if (activeChat && unreadCount > 0) { + useChatStore.getState().markAllAsRead(activeChat); + } + }} className="absolute bottom-24 right-5 w-12 h-12 rounded-full bg-surface-secondary/95 backdrop-blur-md border border-border shadow-xl flex items-center justify-center text-zinc-400 hover:text-white hover:bg-surface-hover hover:scale-105 transition-all z-10" > @@ -986,7 +995,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.classList.add('highlight-message'); - setTimeout(() => el.classList.remove('highlight-message'), 3000); + setTimeout(() => el.classList.remove('highlight-message'), 5000); setProfileUserId(null); } }} @@ -1006,7 +1015,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.classList.add('highlight-message'); - setTimeout(() => el.classList.remove('highlight-message'), 3000); + setTimeout(() => el.classList.remove('highlight-message'), 5000); setShowGroupSettings(false); } }} diff --git a/apps/web/src/components/GroupSettings.tsx b/apps/web/src/components/GroupSettings.tsx index 519397d..d41bae2 100644 --- a/apps/web/src/components/GroupSettings.tsx +++ b/apps/web/src/components/GroupSettings.tsx @@ -56,9 +56,10 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); - const [activeTab, setActiveTab] = useState<'members' | 'media' | 'files' | 'links'>('members'); + const [activeTab, setActiveTab] = useState<'members' | 'gifs' | 'media' | 'files' | 'links'>('members'); const [tabLoading, setTabLoading] = useState(false); const [sharedMedia, setSharedMedia] = useState([]); + const [sharedGifs, setSharedGifs] = useState([]); const [sharedFiles, setSharedFiles] = useState([]); const [sharedLinks, setSharedLinks] = useState>([]); const [loadedTabs, setLoadedTabs] = useState>(new Set()); @@ -224,12 +225,13 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet .slice(0, 2) .toUpperCase(); - const loadTabData = async (tab: 'media' | 'files' | 'links') => { + const loadTabData = async (tab: 'gifs' | 'media' | 'files' | 'links') => { if (loadedTabs.has(tab)) return; setTabLoading(true); try { const data = await api.getSharedMedia(chat.id, tab); if (tab === 'media') setSharedMedia(data); + else if (tab === 'gifs') setSharedGifs(data); else if (tab === 'files') setSharedFiles(data); else setSharedLinks(data); setLoadedTabs(prev => new Set(prev).add(tab)); @@ -241,6 +243,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet }; useEffect(() => { + loadTabData('gifs'); loadTabData('media'); loadTabData('files'); loadTabData('links'); @@ -255,7 +258,16 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet createdAt: msg.createdAt }))); + const allGifs = sharedGifs.flatMap(msg => (msg.media || []).map(m => ({ + ...m, + url: getMediaUrl(m.url), + thumbnail: m.thumbnail ? getMediaUrl(m.thumbnail) : undefined, + messageId: msg.id, + createdAt: msg.createdAt + }))); + const sortedMedia = [...allMedia].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + const sortedGifs = [...allGifs].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); const sortedFiles = [...sharedFiles].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); const sortedLinks = [...sharedLinks].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); @@ -295,6 +307,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet const tabsConfig = [ { key: 'members' as const, label: t('membersCount') || 'Участники', icon: Users, count: chat.members.length }, + { key: 'gifs' as const, label: t('gifs') || 'GIF', icon: Play, count: sortedGifs.length }, { key: 'media' as const, label: t('mediaTab'), icon: ImageIcon, count: sortedMedia.length }, { key: 'files' as const, label: t('filesTab'), icon: FileText, count: sortedFiles.flatMap(msg => msg.media || []).length }, { key: 'links' as const, label: t('linksTab'), icon: LinkIcon, count: sortedLinks.flatMap(msg => msg.links || []).length }, @@ -303,7 +316,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet const availableTabs = tabsConfig.filter(tab => tab.key === 'members' || !loadedTabs.has(tab.key) || tab.count > 0); useEffect(() => { - if (loadedTabs.size === 3 && availableTabs.length > 0 && !availableTabs.find(t => t.key === activeTab)) { + if (loadedTabs.size === 4 && availableTabs.length > 0 && !availableTabs.find(t => t.key === activeTab)) { setActiveTab(availableTabs[0].key); } }, [loadedTabs, activeTab]); // availableTabs removed from dependencies to avoid infinite loops since its reference runs on every render @@ -640,6 +653,38 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet ))}
+ ) : activeTab === 'gifs' ? ( + sortedGifs.length > 0 ? ( + renderGrouped(sortedGifs, (m, idx) => ( +
{ + const eContext = { stopPropagation: () => {} } as any; + onGoToMessage?.(m.messageId); + }} + className="relative aspect-square bg-zinc-900 overflow-hidden cursor-pointer group" + > +
+ ), "grid grid-cols-3 gap-0.5 px-1") + ) : ( +
+

Нет GIF файлов

+
+ ) ) : activeTab === 'media' ? ( sortedMedia.length > 0 ? ( renderGrouped(sortedMedia, (m, idx) => ( @@ -757,7 +802,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet )} - ) : loadedTabs.size === 3 ? ( + ) : loadedTabs.size === 4 ? (

{(t('sharedPhotos' as any) || 'Нет вложений') as string}

diff --git a/apps/web/src/components/LinkPreview.tsx b/apps/web/src/components/LinkPreview.tsx index 41863e8..b5a48ce 100644 --- a/apps/web/src/components/LinkPreview.tsx +++ b/apps/web/src/components/LinkPreview.tsx @@ -34,17 +34,30 @@ export default function LinkPreview({ url }: LinkPreviewProps) { } catch (e) {} } - fetch(`https://api.microlink.io?url=${encodeURIComponent(url)}`) - .then(res => res.json()) - .then(res => { + const fetchWithRetry = async (targetUrl: string, attempts = 2) => { + for (let i = 0; i < attempts; i++) { + try { + const res = await fetch(`https://api.microlink.io?url=${encodeURIComponent(targetUrl)}`); + if (res.ok) { + return await res.json(); + } + } catch (error) { + if (i === attempts - 1) throw error; + } + } + throw new Error('Max retries reached'); + }; + + fetchWithRetry(url) + .then((res) => { if (isMounted && res.status === 'success' && res.data) { setData(res.data); sessionStorage.setItem(cacheKey, JSON.stringify(res.data)); } if (isMounted) setLoading(false); }) - .catch((err) => { - console.error('Failed to fetch link preview:', err); + .catch(() => { + // Suppress errors and stop after max attempts if (isMounted) setLoading(false); }); diff --git a/apps/web/src/components/MessageBubble.tsx b/apps/web/src/components/MessageBubble.tsx index 18e3a2f..bb0dd82 100644 --- a/apps/web/src/components/MessageBubble.tsx +++ b/apps/web/src/components/MessageBubble.tsx @@ -291,13 +291,20 @@ function MessageBubble({ const hasFile = media.some((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio'); const hasVideo = media.some((m) => m.type === 'video'); - const reactionGroups: Record = {}; + const reactionGroups: Record = {}; (message.reactions || []).forEach((r) => { if (!reactionGroups[r.emoji]) { - reactionGroups[r.emoji] = { count: 0, users: [], isMine: false }; + reactionGroups[r.emoji] = { count: 0, users: [], isMine: false, avatars: [] }; } reactionGroups[r.emoji].count++; - reactionGroups[r.emoji].users.push(r.user?.displayName || r.user?.username || ''); + const displayName = r.user?.displayName || r.user?.username || '?'; + reactionGroups[r.emoji].users.push(displayName); + if (reactionGroups[r.emoji].avatars.length < 3) { + reactionGroups[r.emoji].avatars.push({ + url: r.user?.avatar, + initials: displayName[0].toUpperCase() + }); + } if (r.userId === user?.id) reactionGroups[r.emoji].isMine = true; }); @@ -400,39 +407,76 @@ function MessageBubble({ )} +
+ {/* Reply */} {message.replyTo && (
{ + className={`mb-1.5 pl-2.5 py-0.5 border-l-[3px] cursor-pointer transition-colors -mx-1 px-1 rounded-sm ${ + isMine ? 'border-l-white/80 hover:bg-white/10' : 'border-l-knot-500 hover:bg-knot-500/10' + }`} + onClick={(e) => { + e.stopPropagation(); const el = document.getElementById(`msg-${message.replyToId}`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.classList.add('highlight-message'); - setTimeout(() => el.classList.remove('highlight-message'), 3000); + setTimeout(() => el.classList.remove('highlight-message'), 5000); } }} > -

+

{message.replyTo.sender?.displayName || message.replyTo.sender?.username}

-
+
{message.replyTo.isDeleted ? ( -

{t('messageDeleted')}

+

{t('messageDeleted')}

) : ( <> - {message.replyTo.media && message.replyTo.media.length > 0 && !message.quote && ( -
- {message.replyTo.media[0].type === 'image' ? ( - - ) : message.replyTo.media[0].type === 'video' ? ( -
+ {message.replyTo.media && message.replyTo.media.length > 0 && !message.quote && (() => { + const m = message.replyTo.media[0]; + const isMp4 = m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4'); + return ( +
+ {m.type === 'image' ? ( + isMp4 ? ( +
- )} -

{message.quote || message.replyTo.content || (message.replyTo.media && message.replyTo.media.length > 0 ? t('media') : '')}

+ ); + })()} +

+ {message.quote || message.replyTo.content || (message.replyTo.media && message.replyTo.media.length > 0 ? (() => { + const m = message.replyTo.media[0]; + if (m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4')) return 'GIF'; + if (m.type === 'image') return t('photo'); + if (m.type === 'video') return t('video'); + if (m.type === 'voice') return t('voice'); + return t('media'); + })() : '')} +

)}
@@ -442,9 +486,11 @@ function MessageBubble({ {/* Story Reply Quote */} {message.storyId && (
-

+

{t('story')}

@@ -462,36 +508,19 @@ function MessageBubble({ )}
)} -

+

{message.quote}

)} - - {/* Пузырь */} -
{/* Рендер пересланного сообщения */} {message.forwardedFrom && (
onViewProfile?.(message.forwardedFromId!)} > -
- {t('forwardedFrom')} -
-
+
{message.forwardedFrom.displayName || message.forwardedFrom.username}
@@ -522,16 +551,29 @@ function MessageBubble({ ? 'grid-cols-2' : 'grid-cols-1' }`}> - {galleryMedia.map((m, idx) => ( - m.type === 'image' ? ( - 1 ? 'aspect-square' : isSingleGif ? 'max-h-[260px]' : 'max-h-[500px]' - }`} - onClick={() => setLightboxData({ index: idx })} - /> + {galleryMedia.map((m, idx) => { + const isMp4Gif = m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4'); + return m.type === 'image' ? ( + isMp4Gif ? ( +
- ) - ))} + ); + })}
); @@ -701,10 +743,13 @@ function MessageBubble({ ))} {/* Текст */} - {message.content && ( + {message.content && (() => { + const onlyEmojiRegex = /^[\p{Extended_Pictographic}\s]+$/u; + const isOnlyEmojis = onlyEmojiRegex.test(message.content) && message.content.length <= 15; + return (
-

+

{renderFormattedText(message.content)}

{firstUrl && !hasImage && !hasVideo && !hasFile && ( @@ -713,21 +758,22 @@ function MessageBubble({
)}
- - {message.isEdited && {t('edited')}} + {message.isEdited && {t('edited')}} {message.scheduledAt && } {timeStr} {isMine && !message.scheduledAt && ( isRead ? ( - + ) : ( - + ) )}
- )} + ); + })()} {!message.content && (hasImage || hasVideo) && (
@@ -743,27 +789,44 @@ function MessageBubble({
)} -
- - {/* Реакции */} - {Object.keys(reactionGroups).length > 0 && ( -
- {Object.entries(reactionGroups).map(([emoji, data]) => ( - - ))} -
- )} + title={data.users.join(', ')} + > + {emoji} + {(data.avatars && data.avatars.length > 0) ? ( +
+ {data.avatars.map((av, idx) => ( + av.url ? ( + + ) : ( +
+ {av.initials} +
+ ) + ))} +
+ ) : ( + {data.count} + )} + {data.count > 1 && data.avatars && data.avatars.length > 0 && ( + {data.count} + )} + + ))} +
+ )} + {isMine && ( @@ -918,7 +981,7 @@ function MessageBubble({ {lightboxData && ( m.type === 'image' || m.type === 'video').map(m => ({ url: m.url, type: m.type }))} + images={media.filter(m => m.type === 'image' || m.type === 'video').map(m => ({ url: m.url, type: m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4') ? 'video' : m.type }))} initialIndex={lightboxData.index} onClose={() => setLightboxData(null)} /> diff --git a/apps/web/src/components/TelegramImportModal.tsx b/apps/web/src/components/TelegramImportModal.tsx index e094adc..1f60c5f 100644 --- a/apps/web/src/components/TelegramImportModal.tsx +++ b/apps/web/src/components/TelegramImportModal.tsx @@ -22,6 +22,7 @@ export default function TelegramImportModal({ isOpen, onClose, friends }: Telegr const [token, setToken] = useState(null); const [names, setNames] = useState([]); const [mapping, setMapping] = useState>({}); + const [groupName, setGroupName] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [importedState, setImportedState] = useState<{ count: number; text: string } | null>(null); @@ -65,7 +66,7 @@ export default function TelegramImportModal({ isOpen, onClose, friends }: Telegr setError(null); try { - const res = await api.executeTelegramImport({ token, mapping }) as any; + const res = await api.executeTelegramImport({ token, mapping, groupName }) as any; setImportedState({ count: res.messagesImported, text: 'Успешно импортировано' }); setStep(3); } catch (err: any) { @@ -83,6 +84,7 @@ export default function TelegramImportModal({ isOpen, onClose, friends }: Telegr setToken(null); setNames([]); setMapping({}); + setGroupName(''); setError(null); setImportedState(null); if (fileInputRef.current) fileInputRef.current.value = ''; @@ -199,6 +201,19 @@ export default function TelegramImportModal({ isOpen, onClose, friends }: Telegr ))} + {names.length > 2 && ( +
+

Название для группового чата

+ setGroupName(e.target.value)} + className="w-full h-11 px-3 bg-surface-secondary text-sm text-white rounded-lg border border-border focus:border-knot-500 outline-none transition-colors placeholder:text-zinc-600" + /> +
+ )} +
{t.lastSeen} - {formatDateTime(selectedUser.lastOnlineAt)} + + {selectedUser.isOnline ? (lang === 'ru' ? 'В сети' : 'Online') : formatDateTime(selectedUser.lastOnlineAt)} +
diff --git a/apps/web/src/pages/ChatPage.tsx b/apps/web/src/pages/ChatPage.tsx index 118099b..2dd83b3 100644 --- a/apps/web/src/pages/ChatPage.tsx +++ b/apps/web/src/pages/ChatPage.tsx @@ -172,8 +172,8 @@ export default function ChatPage() { removeReaction(data.messageId, data.chatId, data.userId, data.emoji); }); - socket.on('messages_read', (data: { chatId: string; userId: string; messageIds: string[] }) => { - markRead(data.chatId, data.userId, data.messageIds); + socket.on('messages_read', (data: any) => { + markRead(data.chatId || data.ChatId, data.userId || data.UserId, data.messageIds || data.MessageIds || []); }); socket.on('user_typing', (data: { chatId: string; userId: string }) => { diff --git a/apps/web/src/stores/chatStore.ts b/apps/web/src/stores/chatStore.ts index e297cac..21e4c93 100644 --- a/apps/web/src/stores/chatStore.ts +++ b/apps/web/src/stores/chatStore.ts @@ -22,7 +22,7 @@ interface ChatState { setDraft: (chatId: string, text: string) => void; getDraft: (chatId: string) => string; loadChats: () => Promise; - loadMessages: (chatId: string, reset?: boolean) => Promise; + loadMessages: (chatId: string, reset?: boolean, isHistory?: boolean) => Promise; addMessage: (message: Message) => void; updateMessage: (message: Message) => void; removeMessage: (messageId: string, chatId: string) => void; @@ -31,6 +31,7 @@ interface ChatState { addReaction: (messageId: string, chatId: string, userId: string, username: string, emoji: string) => void; removeReaction: (messageId: string, chatId: string, userId: string, emoji: string) => void; markRead: (chatId: string, userId: string, messageIds: string[]) => void; + markAllAsRead: (chatId: string) => void; addTypingUser: (chatId: string, userId: string) => void; removeTypingUser: (chatId: string, userId: string) => void; updateUserOnlineStatus: (userId: string, isOnline: boolean, lastSeen?: string) => void; @@ -113,10 +114,11 @@ export const useChatStore = create((set, get) => ({ } }, - loadMessages: async (chatId, reset = false) => { + loadMessages: async (chatId, reset = false, isHistory = false) => { try { const state = get(); - if (!reset && state.messages[chatId] && state.hasMoreMessages[chatId] === false) return; + if (!reset && !isHistory && state.messages[chatId] && state.messages[chatId].length > 0) return; + if (!reset && isHistory && state.messages[chatId] && state.hasMoreMessages[chatId] === false) return; if (state.isLoadingMessages) return; set({ isLoadingMessages: true }); @@ -392,10 +394,12 @@ export const useChatStore = create((set, get) => ({ const currentUserId = useAuthStore.getState().user?.id; set((state) => { const chatMessages = state.messages[chatId] || []; + let newlyReadCount = 0; const updateMsg = (m: Message) => { if (messageIds.includes(m.id)) { const alreadyRead = m.readBy?.some((r) => r.userId === userId); if (alreadyRead) return m; + if (userId === currentUserId && m.senderId !== currentUserId) newlyReadCount++; return { ...m, readBy: [...(m.readBy || []), { userId }] }; } return m; @@ -407,7 +411,7 @@ export const useChatStore = create((set, get) => ({ if (chat.id === chatId) { const updatedLastMessages = chat.messages?.map(updateMsg); if (userId === currentUserId) { - return { ...chat, messages: updatedLastMessages, unreadCount: 0 }; + return { ...chat, messages: updatedLastMessages, unreadCount: Math.max(0, (chat.unreadCount || 0) - newlyReadCount) }; } return { ...chat, messages: updatedLastMessages }; } @@ -424,6 +428,18 @@ export const useChatStore = create((set, get) => ({ }); }, + markAllAsRead: (chatId) => { + set((state) => { + const updatedChats = state.chats.map((chat) => { + if (chat.id === chatId) { + return { ...chat, unreadCount: 0 }; + } + return chat; + }); + return { chats: updatedChats }; + }); + }, + addTypingUser: (chatId, userId) => { set((state) => { const exists = state.typingUsers.some((t) => t.chatId === chatId && t.userId === userId); diff --git a/docker-compose.yml b/docker-compose.yml index b46691a..fa2ba66 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,8 +41,6 @@ services: build: context: . dockerfile: Dockerfile.web - args: - - VITE_KLIPY_API_KEY=${VITE_KLIPY_API_KEY} container_name: knot-web restart: always ports: