Импорт групповых чатом, мелкие правки, внешний вид чата, GIF тип

This commit is contained in:
Халимов Рустам
2026-03-17 00:57:01 +03:00
parent 97b70250de
commit 1fd6ef0c48
19 changed files with 358 additions and 144 deletions
-4
View File
@@ -13,10 +13,6 @@ RUN npm install --legacy-peer-deps
# Copy source code # Copy source code
COPY apps/web/ ./ COPY apps/web/ ./
# Build args
ARG VITE_KLIPY_API_KEY
ENV VITE_KLIPY_API_KEY=$VITE_KLIPY_API_KEY
# Build web frontend # Build web frontend
RUN npm run build RUN npm run build
@@ -62,7 +62,8 @@ public class AdminController : ControllerBase
DisplayName = u.DisplayName, DisplayName = u.DisplayName,
Avatar = u.Avatar, Avatar = u.Avatar,
CreatedAt = u.CreatedAt, 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, Bio = targetUser.Bio,
Avatar = targetUser.Avatar, Avatar = targetUser.Avatar,
CreatedAt = targetUser.CreatedAt, 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 Stats = new
{ {
MessagesCount = messagesCount, MessagesCount = messagesCount,
@@ -93,8 +93,8 @@ public sealed class MessagesController : ControllerBase
if (rUser != null) senders[r.UserId] = rUser; if (rUser != null) senders[r.UserId] = rUser;
} }
var userObj = senders.TryGetValue(r.UserId, out var ru) var userObj = senders.TryGetValue(r.UserId, out var ru)
? new { id = ru.Id, username = ru.Username, displayName = ru.DisplayName } ? new { id = ru.Id, username = ru.Username, displayName = ru.DisplayName, avatar = ru.Avatar }
: new { id = r.UserId, username = "unknown", displayName = "Unknown" }; : new { id = r.UserId, username = "unknown", displayName = "Unknown", avatar = (string?)null };
reactionsWithUser.Add(new reactionsWithUser.Add(new
{ {
@@ -141,7 +141,7 @@ public sealed class MessagesController : ControllerBase
displayName = s.DisplayName, displayName = s.DisplayName,
avatar = s.Avatar avatar = s.Avatar
} : null, } : null,
readBy = m.ReadBy.Select(r => r.UserId).ToList(), readBy = m.ReadBy.Select(r => new { userId = r.UserId }).ToList(),
reactions = reactionsWithUser reactions = reactionsWithUser
}); });
} }
@@ -266,7 +266,10 @@ public sealed class MessagesController : ControllerBase
var filteredMedia = m.Media.Where(media => { var filteredMedia = m.Media.Where(media => {
var mediaType = media.Type?.ToLower() ?? "file"; 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"; if (filterType == "files") return mediaType != "image" && mediaType != "video" && mediaType != "link";
return true; return true;
}).ToList(); }).ToList();
@@ -117,7 +117,7 @@ public sealed class TelegramImportController : ControllerBase
}); });
} }
public sealed record ExecuteImportRequest(Guid Token, Dictionary<string, Guid> Mapping); public sealed record ExecuteImportRequest(Guid Token, Dictionary<string, Guid> Mapping, string? GroupName);
[HttpPost("execute")] [HttpPost("execute")]
public async Task<IActionResult> Execute([FromBody] ExecuteImportRequest req, CancellationToken ct) public async Task<IActionResult> Execute([FromBody] ExecuteImportRequest req, CancellationToken ct)
@@ -161,7 +161,7 @@ public sealed class TelegramImportController : ControllerBase
else else
{ {
// Create a group // 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); var res = await _sender.Send(command, ct);
if (res.IsFailure) return BadRequest(res.Error); if (res.IsFailure) return BadRequest(res.Error);
chatId = res.Value; chatId = res.Value;
@@ -224,15 +224,25 @@ public sealed class TelegramImportController : ControllerBase
Guid senderGuid = lastSenderGuid; Guid senderGuid = lastSenderGuid;
string content = ""; 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("<br>", "\n", StringComparison.OrdinalIgnoreCase) .Replace("<br>", "\n", StringComparison.OrdinalIgnoreCase)
.Replace("<br/>", "\n", StringComparison.OrdinalIgnoreCase) .Replace("<br/>", "\n", StringComparison.OrdinalIgnoreCase)
.Replace("<br />", "\n", StringComparison.OrdinalIgnoreCase); .Replace("<br />", "\n", StringComparison.OrdinalIgnoreCase);
var tempParser = new AngleSharp.Html.Parser.HtmlParser(); var tempParser = new AngleSharp.Html.Parser.HtmlParser();
var tempDoc = tempParser.ParseDocument("<div>" + html + "</div>"); var tempDoc = tempParser.ParseDocument("<div>" + html + "</div>");
content = tempDoc.Body.TextContent.Trim(); content = tempDoc.Body?.TextContent.Trim() ?? "";
} }
DateTime createdAt = lastCreatedAt; DateTime createdAt = lastCreatedAt;
@@ -282,6 +292,7 @@ public sealed class TelegramImportController : ControllerBase
} }
// Parse forwards // Parse forwards
Guid? forwardedFromId = null;
var forwardedNode = node.QuerySelector(".forwarded.body"); var forwardedNode = node.QuerySelector(".forwarded.body");
if (forwardedNode != null) if (forwardedNode != null)
{ {
@@ -294,6 +305,15 @@ public sealed class TelegramImportController : ControllerBase
} }
var fwdName = fwdNameText != null ? fwdNameText.TextContent.Trim() : "Неизвестного"; 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"); var fwdTextNode = forwardedNode.QuerySelector(".text");
string fwdContent = ""; string fwdContent = "";
if (fwdTextNode != null) if (fwdTextNode != null)
@@ -304,13 +324,21 @@ public sealed class TelegramImportController : ControllerBase
.Replace("<br />", "\n", StringComparison.OrdinalIgnoreCase); .Replace("<br />", "\n", StringComparison.OrdinalIgnoreCase);
var tempParser = new AngleSharp.Html.Parser.HtmlParser(); var tempParser = new AngleSharp.Html.Parser.HtmlParser();
var tempDoc = tempParser.ParseDocument("<div>" + fHtml + "</div>"); var tempDoc = tempParser.ParseDocument("<div>" + fHtml + "</div>");
fwdContent = tempDoc.Body.TextContent.Trim(); fwdContent = tempDoc.Body?.TextContent.Trim() ?? "";
} }
if (forwardedFromId == null)
{
// We don't have this user in the app, map to generic string
content = string.IsNullOrEmpty(content) content = string.IsNullOrEmpty(content)
? $"[Переслано от {fwdName}]:\n{fwdContent}" ? $"[Переслано от {fwdName}]:\n{fwdContent}"
: $"{content}\n\n[Переслано от {fwdName}]:\n{fwdContent}"; : $"{content}\n\n[Переслано от {fwdName}]:\n{fwdContent}";
} }
else if (string.IsNullOrEmpty(content))
{
content = fwdContent;
}
}
// Parse replies // Parse replies
Guid? replyToId = null; Guid? replyToId = null;
@@ -356,6 +384,15 @@ public sealed class TelegramImportController : ControllerBase
if (firstHref != null) if (firstHref != null)
{ {
messageType = GetMediaTypes(firstHref).mType; 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 else
{ {
string finalContent = content; 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"); var idAttr = node.GetAttribute("id");
if (!string.IsNullOrEmpty(idAttr)) if (!string.IsNullOrEmpty(idAttr))
@@ -396,8 +433,10 @@ public sealed class TelegramImportController : ControllerBase
ms.Position = 0; ms.Position = 0;
var types = GetMediaTypes(href); 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); 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);
} }
} }
} }
@@ -23,6 +23,7 @@ public sealed class ChatHub : Hub
private static readonly ConcurrentDictionary<string, ConcurrentDictionary<string, ParticipantInfo>> _groupCallParticipants = new(); private static readonly ConcurrentDictionary<string, ConcurrentDictionary<string, ParticipantInfo>> _groupCallParticipants = new();
public static int OnlineUsersCount => _userConnections.Count; public static int OnlineUsersCount => _userConnections.Count;
public static bool IsUserOnline(string userId) => _userConnections.ContainsKey(userId);
private readonly ISender _sender; private readonly ISender _sender;
private readonly IUserContext _userContext; private readonly IUserContext _userContext;
@@ -8,14 +8,14 @@ public class SystemSettingsDto
public int MaxFileSizeMb { get; set; } = 100; public int MaxFileSizeMb { get; set; } = 100;
// Calls // Calls
public bool EnableCalls { get; set; } = true; public bool EnableCalls { get; set; } = false;
public string TurnHost { get; set; } = string.Empty; public string TurnHost { get; set; } = string.Empty;
public int TurnPort { get; set; } = 3478; public int TurnPort { get; set; } = 3478;
public string TurnUser { get; set; } = string.Empty; public string TurnUser { get; set; } = string.Empty;
public string TurnSecret { get; set; } = string.Empty; public string TurnSecret { get; set; } = string.Empty;
// Klipy // Klipy
public bool EnableKlipy { get; set; } = true; public bool EnableKlipy { get; set; } = false;
public string KlipyApiKey { get; set; } = string.Empty; public string KlipyApiKey { get; set; } = string.Empty;
public string KlipyCustomerId { get; set; } = string.Empty; public string KlipyCustomerId { get; set; } = string.Empty;
+21 -12
View File
@@ -121,7 +121,6 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
useEffect(() => { useEffect(() => {
if (activeChat) { if (activeChat) {
setMuted(isChatMuted(activeChat)); setMuted(isChatMuted(activeChat));
setScrollReady(false);
setActiveGroupCallParticipants([]); setActiveGroupCallParticipants([]);
lastObservedMessageIdRef.current = null; lastObservedMessageIdRef.current = null;
} }
@@ -181,6 +180,10 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
}, []); }, []);
// Первичная прокрутка при открытии чата или после загрузки (layout effect — до отрисовки) // Первичная прокрутка при открытии чата или после загрузки (layout effect — до отрисовки)
useLayoutEffect(() => {
setScrollReady(false);
}, [activeChat]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!isLoadingMessages && messagesContainerRef.current) { if (!isLoadingMessages && messagesContainerRef.current) {
const container = messagesContainerRef.current; const container = messagesContainerRef.current;
@@ -202,7 +205,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
} }
setScrollReady(true); setScrollReady(true);
} }
}, [activeChat, isLoadingMessages && chatMessages.length === 0]); }, [activeChat, isLoadingMessages]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (prevScrollHeightRef.current > 0 && messagesContainerRef.current) { 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(); return () => observer.disconnect();
}, [activeChat, user?.id]); }, [activeChat, user?.id]);
// Re-run observation when messages change (to catch new arrivals) // Re-run observation when messages change (to catch new arrivals)
useEffect(() => { useEffect(() => {
if (observerRef.current && !isLoadingMessages) { if (observerRef.current) {
if (!messagesContainerRef.current) return; if (!messagesContainerRef.current) return;
const unreadElements = messagesContainerRef.current.querySelectorAll('.unread-detector'); const unreadElements = messagesContainerRef.current.querySelectorAll('.unread-detector');
unreadElements.forEach((el) => { unreadElements.forEach((el) => {
@@ -315,7 +319,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
} }
}); });
} }
}, [chatMessages.length, isLoadingMessages]); }, [chatMessages, scrollReady]);
// Scroll detection // Scroll detection
const checkScrollPosition = useCallback(() => { const checkScrollPosition = useCallback(() => {
@@ -332,7 +336,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
const container = messagesContainerRef.current; const container = messagesContainerRef.current;
if (container && container.scrollTop < 100 && activeChat && hasMoreMessages[activeChat] && !isLoadingMessages) { if (container && container.scrollTop < 100 && activeChat && hasMoreMessages[activeChat] && !isLoadingMessages) {
prevScrollHeightRef.current = container.scrollHeight; 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) { 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'), 3000); setTimeout(() => el.classList.remove('highlight-message'), 5000);
} }
setShowSearch(false); setShowSearch(false);
setSearchText(''); setSearchText('');
@@ -837,7 +841,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
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'), 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" 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
<div <div
ref={messagesContainerRef} ref={messagesContainerRef}
onScroll={handleScroll} onScroll={handleScroll}
className={`flex-1 overflow-y-auto px-6 pt-6 pb-2 relative z-10 ${!scrollReady && !isLoadingMessages && chatMessages.length > 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 ? ( {isLoadingMessages && chatMessages.length === 0 ? (
<div className="flex justify-center py-8"> <div className="flex justify-center py-8">
@@ -947,7 +951,12 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
initial={{ scale: 0, opacity: 0 }} initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }} animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }} 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" 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"
> >
<ArrowDown size={22} className="text-accent hover:text-accent-light transition-colors" /> <ArrowDown size={22} className="text-accent hover:text-accent-light transition-colors" />
@@ -986,7 +995,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
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'), 3000); setTimeout(() => el.classList.remove('highlight-message'), 5000);
setProfileUserId(null); setProfileUserId(null);
} }
}} }}
@@ -1006,7 +1015,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
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'), 3000); setTimeout(() => el.classList.remove('highlight-message'), 5000);
setShowGroupSettings(false); setShowGroupSettings(false);
} }
}} }}
+49 -4
View File
@@ -56,9 +56,10 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<UserPresence[]>([]); const [searchResults, setSearchResults] = useState<UserPresence[]>([]);
const [isSearching, setIsSearching] = useState(false); 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 [tabLoading, setTabLoading] = useState(false);
const [sharedMedia, setSharedMedia] = useState<Message[]>([]); const [sharedMedia, setSharedMedia] = useState<Message[]>([]);
const [sharedGifs, setSharedGifs] = useState<Message[]>([]);
const [sharedFiles, setSharedFiles] = useState<Message[]>([]); const [sharedFiles, setSharedFiles] = useState<Message[]>([]);
const [sharedLinks, setSharedLinks] = useState<Array<Message & { links?: string[] }>>([]); const [sharedLinks, setSharedLinks] = useState<Array<Message & { links?: string[] }>>([]);
const [loadedTabs, setLoadedTabs] = useState<Set<string>>(new Set()); const [loadedTabs, setLoadedTabs] = useState<Set<string>>(new Set());
@@ -224,12 +225,13 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
.slice(0, 2) .slice(0, 2)
.toUpperCase(); .toUpperCase();
const loadTabData = async (tab: 'media' | 'files' | 'links') => { const loadTabData = async (tab: 'gifs' | 'media' | 'files' | 'links') => {
if (loadedTabs.has(tab)) return; if (loadedTabs.has(tab)) return;
setTabLoading(true); setTabLoading(true);
try { try {
const data = await api.getSharedMedia(chat.id, tab); const data = await api.getSharedMedia(chat.id, tab);
if (tab === 'media') setSharedMedia(data); if (tab === 'media') setSharedMedia(data);
else if (tab === 'gifs') setSharedGifs(data);
else if (tab === 'files') setSharedFiles(data); else if (tab === 'files') setSharedFiles(data);
else setSharedLinks(data); else setSharedLinks(data);
setLoadedTabs(prev => new Set(prev).add(tab)); setLoadedTabs(prev => new Set(prev).add(tab));
@@ -241,6 +243,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
}; };
useEffect(() => { useEffect(() => {
loadTabData('gifs');
loadTabData('media'); loadTabData('media');
loadTabData('files'); loadTabData('files');
loadTabData('links'); loadTabData('links');
@@ -255,7 +258,16 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
createdAt: msg.createdAt 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 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 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()); 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 = [ const tabsConfig = [
{ key: 'members' as const, label: t('membersCount') || 'Участники', icon: Users, count: chat.members.length }, { 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: '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: '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 }, { 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); const availableTabs = tabsConfig.filter(tab => tab.key === 'members' || !loadedTabs.has(tab.key) || tab.count > 0);
useEffect(() => { 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); setActiveTab(availableTabs[0].key);
} }
}, [loadedTabs, activeTab]); // availableTabs removed from dependencies to avoid infinite loops since its reference runs on every render }, [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
))} ))}
</div> </div>
</div> </div>
) : activeTab === 'gifs' ? (
sortedGifs.length > 0 ? (
renderGrouped(sortedGifs, (m, idx) => (
<div
key={m.id}
onClick={() => {
const eContext = { stopPropagation: () => {} } as any;
onGoToMessage?.(m.messageId);
}}
className="relative aspect-square bg-zinc-900 overflow-hidden cursor-pointer group"
>
<video
src={getMediaUrl(m.url)}
autoPlay
loop
muted
playsInline
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
<button
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(m.messageId); }}
className="absolute bottom-2 right-2 px-2 py-1 rounded-md bg-black/60 backdrop-blur-sm flex items-center justify-center text-white text-[10px] font-medium opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/80 shadow-md"
>
{t('showInChat')}
</button>
</div>
), "grid grid-cols-3 gap-0.5 px-1")
) : (
<div className="flex items-center justify-center py-10 px-4 text-center">
<p className="text-xs text-zinc-500 italic">Нет GIF файлов</p>
</div>
)
) : activeTab === 'media' ? ( ) : activeTab === 'media' ? (
sortedMedia.length > 0 ? ( sortedMedia.length > 0 ? (
renderGrouped(sortedMedia, (m, idx) => ( renderGrouped(sortedMedia, (m, idx) => (
@@ -757,7 +802,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
)} )}
</div> </div>
</div> </div>
) : loadedTabs.size === 3 ? ( ) : loadedTabs.size === 4 ? (
<div className="mx-4 mb-6 flex flex-col items-center justify-center py-10 px-4 text-center border border-white/5 bg-black/20 rounded-2xl backdrop-blur-xl"> <div className="mx-4 mb-6 flex flex-col items-center justify-center py-10 px-4 text-center border border-white/5 bg-black/20 rounded-2xl backdrop-blur-xl">
<ImageIcon size={32} className="text-zinc-600 mb-3" /> <ImageIcon size={32} className="text-zinc-600 mb-3" />
<p className="text-sm text-zinc-500">{(t('sharedPhotos' as any) || 'Нет вложений') as string}</p> <p className="text-sm text-zinc-500">{(t('sharedPhotos' as any) || 'Нет вложений') as string}</p>
+18 -5
View File
@@ -34,17 +34,30 @@ export default function LinkPreview({ url }: LinkPreviewProps) {
} catch (e) {} } catch (e) {}
} }
fetch(`https://api.microlink.io?url=${encodeURIComponent(url)}`) const fetchWithRetry = async (targetUrl: string, attempts = 2) => {
.then(res => res.json()) for (let i = 0; i < attempts; i++) {
.then(res => { 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) { if (isMounted && res.status === 'success' && res.data) {
setData(res.data); setData(res.data);
sessionStorage.setItem(cacheKey, JSON.stringify(res.data)); sessionStorage.setItem(cacheKey, JSON.stringify(res.data));
} }
if (isMounted) setLoading(false); if (isMounted) setLoading(false);
}) })
.catch((err) => { .catch(() => {
console.error('Failed to fetch link preview:', err); // Suppress errors and stop after max attempts
if (isMounted) setLoading(false); if (isMounted) setLoading(false);
}); });
+126 -63
View File
@@ -291,13 +291,20 @@ function MessageBubble({
const hasFile = media.some((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio'); 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 hasVideo = media.some((m) => m.type === 'video');
const reactionGroups: Record<string, { count: number; users: string[]; isMine: boolean }> = {}; const reactionGroups: Record<string, { count: number; users: string[]; isMine: boolean; avatars: { url?: string | null, initials: string }[] }> = {};
(message.reactions || []).forEach((r) => { (message.reactions || []).forEach((r) => {
if (!reactionGroups[r.emoji]) { 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].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; if (r.userId === user?.id) reactionGroups[r.emoji].isMine = true;
}); });
@@ -400,39 +407,76 @@ function MessageBubble({
</button> </button>
)} )}
<div
id={`msg-${message.id}`}
onContextMenu={handleContextMenu}
onDoubleClick={handleReply}
title={t('reply') ? `${t('reply')} (Double Click)` : 'Double click to reply'}
className={`cursor-pointer rounded-[1.25rem] overflow-hidden transition-all duration-300 ${
hasImage && !message.content && !message.forwardedFrom && !message.replyTo
? 'p-0 shadow-none border-none'
: isMine
? 'bubble-sent text-white shadow-sm px-3.5 py-2 hover:shadow-md hover:brightness-105 rounded-br-sm'
: 'bubble-received text-zinc-100 shadow-sm px-3.5 py-2 hover:shadow-md hover:brightness-105 rounded-bl-[4px]'
}`}
>
{/* Reply */} {/* Reply */}
{message.replyTo && ( {message.replyTo && (
<div <div
className="mx-3 mb-1 px-3 py-1.5 rounded-lg border-l-2 border-knot-500 bg-knot-500/10 max-w-full cursor-pointer hover:bg-knot-500/20 transition-colors" className={`mb-1.5 pl-2.5 py-0.5 border-l-[3px] cursor-pointer transition-colors -mx-1 px-1 rounded-sm ${
onClick={() => { 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}`); const el = document.getElementById(`msg-${message.replyToId}`);
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'), 3000); setTimeout(() => el.classList.remove('highlight-message'), 5000);
} }
}} }}
> >
<p className="text-xs font-medium text-knot-400 truncate"> <p className={`text-[13.5px] font-semibold mb-0.5 truncate ${isMine ? 'text-white' : 'text-knot-500'}`}>
{message.replyTo.sender?.displayName || message.replyTo.sender?.username} {message.replyTo.sender?.displayName || message.replyTo.sender?.username}
</p> </p>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1.5">
{message.replyTo.isDeleted ? ( {message.replyTo.isDeleted ? (
<p className="text-xs text-zinc-500 italic truncate">{t('messageDeleted')}</p> <p className="text-[13px] text-white/50 italic truncate">{t('messageDeleted')}</p>
) : ( ) : (
<> <>
{message.replyTo.media && message.replyTo.media.length > 0 && !message.quote && ( {message.replyTo.media && message.replyTo.media.length > 0 && !message.quote && (() => {
<div className="w-8 h-8 rounded bg-black/20 overflow-hidden flex-shrink-0"> const m = message.replyTo.media[0];
{message.replyTo.media[0].type === 'image' ? ( const isMp4 = m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4');
<img src={message.replyTo.media[0].url} className="w-full h-full object-cover" alt="" /> return (
) : message.replyTo.media[0].type === 'video' ? ( <div className="w-8 h-8 rounded bg-black/20 overflow-hidden flex-shrink-0 relative">
<div className="w-full h-full flex items-center justify-center bg-black/40"><Play size={10} className="text-white" /></div> {m.type === 'image' ? (
isMp4 ? (
<video src={m.url} className="w-full h-full object-cover" muted playsInline />
) : ( ) : (
<div className="w-full h-full flex items-center justify-center"><FileText size={10} className="text-zinc-500" /></div> <img src={m.url} className="w-full h-full object-cover" alt="" />
)
) : m.type === 'video' ? (
<>
<video src={m.url} className="w-full h-full object-cover" muted playsInline />
<div className="absolute inset-0 flex items-center justify-center bg-black/40"><Play size={10} className="text-white" /></div>
</>
) : (
<div className="w-full h-full flex items-center justify-center"><FileText size={10} className="text-white/50" /></div>
)} )}
</div> </div>
)} );
<p className="text-xs text-zinc-400 truncate">{message.quote || message.replyTo.content || (message.replyTo.media && message.replyTo.media.length > 0 ? t('media') : '')}</p> })()}
<p className={`text-[13px] truncate ${isMine ? 'text-white/80' : 'text-zinc-600 dark:text-zinc-300'}`}>
{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');
})() : '')}
</p>
</> </>
)} )}
</div> </div>
@@ -442,9 +486,11 @@ function MessageBubble({
{/* Story Reply Quote */} {/* Story Reply Quote */}
{message.storyId && ( {message.storyId && (
<div <div
className={`mx-3 mb-1 px-3 py-1.5 rounded-lg border-l-2 ${isMine ? 'border-white/50 bg-white/10' : 'border-accent bg-accent/10'} max-w-full`} className={`mb-1.5 pl-2.5 py-0.5 border-l-[3px] 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'
}`}
> >
<p className={`text-xs font-bold uppercase tracking-wider ${isMine ? 'text-white' : 'text-accent'}`}> <p className={`text-[11px] font-bold uppercase tracking-wider mb-1 ${isMine ? 'text-white/80' : 'text-knot-500/80'}`}>
{t('story')} {t('story')}
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -462,36 +508,19 @@ function MessageBubble({
)} )}
</div> </div>
)} )}
<p className={`text-xs truncate ${isMine ? 'text-white/80' : 'text-zinc-400'}`}> <p className={`text-[13px] truncate ${isMine ? 'text-white/80' : 'text-zinc-600 dark:text-zinc-300'}`}>
{message.quote} {message.quote}
</p> </p>
</div> </div>
</div> </div>
)} )}
{/* Пузырь */}
<div
id={`msg-${message.id}`}
onContextMenu={handleContextMenu}
onDoubleClick={handleReply}
title={t('reply') ? `${t('reply')} (Double Click)` : 'Double click to reply'}
className={`cursor-pointer rounded-[1.25rem] overflow-hidden transition-all duration-300 ${hasImage && !message.content && !message.forwardedFrom
? 'p-0 shadow-none border-none'
: isMine
? 'bubble-sent text-white shadow-sm px-4 py-2.5 hover:shadow-md hover:brightness-105'
: 'bubble-received text-zinc-100 shadow-sm px-4 py-2.5 hover:shadow-md hover:brightness-105'
}`}
>
{/* Рендер пересланного сообщения */} {/* Рендер пересланного сообщения */}
{message.forwardedFrom && ( {message.forwardedFrom && (
<div <div
className="mb-2 text-[13px] opacity-90 border-l-[2px] border-white/40 pl-3 py-0.5 cursor-pointer hover:bg-white/5 transition-colors -mx-1 px-1 rounded-sm" className="mb-1.5 text-[14px] opacity-90 border-l-[3px] border-white/40 pl-2.5 py-0.5 cursor-pointer hover:bg-white/5 transition-colors -mx-1 px-1 rounded-sm"
onClick={() => onViewProfile?.(message.forwardedFromId!)} onClick={() => onViewProfile?.(message.forwardedFromId!)}
> >
<div className="text-[11px] font-bold uppercase tracking-wider opacity-70 leading-none mb-1"> <div className={`font-semibold ${isMine ? 'text-white' : 'text-knot-500'}`}>
{t('forwardedFrom')}
</div>
<div className={`font-semibold ${isMine ? 'text-white' : 'text-accent-light'}`}>
{message.forwardedFrom.displayName || message.forwardedFrom.username} {message.forwardedFrom.displayName || message.forwardedFrom.username}
</div> </div>
</div> </div>
@@ -522,16 +551,29 @@ function MessageBubble({
? 'grid-cols-2' ? 'grid-cols-2'
: 'grid-cols-1' : 'grid-cols-1'
}`}> }`}>
{galleryMedia.map((m, idx) => ( {galleryMedia.map((m, idx) => {
m.type === 'image' ? ( const isMp4Gif = m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4');
return m.type === 'image' ? (
isMp4Gif ? (
<video
key={m.id}
src={m.url}
autoPlay
loop
muted
playsInline
className={`w-full h-full object-cover cursor-pointer hover:brightness-90 transition-all ${galleryMedia.length > 1 ? 'aspect-square' : isSingleGif ? 'max-h-[260px]' : 'max-h-[500px]'}`}
onClick={() => setLightboxData({ index: idx })}
/>
) : (
<img <img
key={m.id} key={m.id}
src={m.url} src={m.url}
alt="" alt=""
className={`w-full h-full object-cover cursor-pointer hover:brightness-90 transition-all ${galleryMedia.length > 1 ? 'aspect-square' : isSingleGif ? 'max-h-[260px]' : 'max-h-[500px]' className={`w-full h-full object-cover cursor-pointer hover:brightness-90 transition-all ${galleryMedia.length > 1 ? 'aspect-square' : isSingleGif ? 'max-h-[260px]' : 'max-h-[500px]'}`}
}`}
onClick={() => setLightboxData({ index: idx })} onClick={() => setLightboxData({ index: idx })}
/> />
)
) : ( ) : (
<div <div
key={m.id} key={m.id}
@@ -547,8 +589,8 @@ function MessageBubble({
<Play size={galleryMedia.length > 1 ? 24 : 48} className="text-white opacity-80" /> <Play size={galleryMedia.length > 1 ? 24 : 48} className="text-white opacity-80" />
</div> </div>
</div> </div>
) );
))} })}
</div> </div>
</div> </div>
); );
@@ -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 (
<div className="flex items-end gap-2 text-sm w-full"> <div className="flex items-end gap-2 text-sm w-full">
<div className="flex-1 min-w-0 w-full"> <div className="flex-1 min-w-0 w-full">
<p className="whitespace-pre-wrap break-words leading-relaxed w-full"> <p className={`whitespace-pre-wrap break-words leading-relaxed w-full ${isOnlyEmojis ? 'text-5xl my-1' : ''}`}>
{renderFormattedText(message.content)} {renderFormattedText(message.content)}
</p> </p>
{firstUrl && !hasImage && !hasVideo && !hasFile && ( {firstUrl && !hasImage && !hasVideo && !hasFile && (
@@ -713,21 +758,22 @@ function MessageBubble({
</div> </div>
)} )}
</div> </div>
<span className={`text-[10px] flex-shrink-0 flex items-center gap-0.5 self-end mb-0.5 ${isMine ? 'text-white/50' : 'text-zinc-500' <span className={`text-[10.5px] flex-shrink-0 flex items-center gap-0.5 self-end float-right leading-none ${isOnlyEmojis ? '-mb-1' : 'mb-0.5'} ${isMine ? 'text-white/60' : 'text-zinc-500'
}`}> }`}>
{message.isEdited && <span>{t('edited')}</span>} {message.isEdited && <span className="mr-0.5">{t('edited')}</span>}
{message.scheduledAt && <Clock size={11} className="text-amber-400 mr-0.5" />} {message.scheduledAt && <Clock size={11} className="text-amber-400 mr-0.5" />}
{timeStr} {timeStr}
{isMine && !message.scheduledAt && ( {isMine && !message.scheduledAt && (
isRead ? ( isRead ? (
<CheckCheck size={13} className="text-sky-300 ml-0.5" /> <CheckCheck size={14} className="text-sky-300 ml-0.5" />
) : ( ) : (
<Check size={13} className="ml-0.5" /> <Check size={14} className="ml-0.5" />
) )
)} )}
</span> </span>
</div> </div>
)} );
})()}
{!message.content && (hasImage || hasVideo) && ( {!message.content && (hasImage || hasVideo) && (
<div className={`flex justify-end px-3 py-1 ${hasImage ? '-mt-8 relative z-10' : ''}`}> <div className={`flex justify-end px-3 py-1 ${hasImage ? '-mt-8 relative z-10' : ''}`}>
@@ -743,28 +789,45 @@ function MessageBubble({
</span> </span>
</div> </div>
)} )}
</div>
{/* Реакции */} {/* Реакции */}
{Object.keys(reactionGroups).length > 0 && ( {Object.keys(reactionGroups).length > 0 && (
<div className="flex flex-wrap gap-1 mt-1 mx-1"> <div className={`flex flex-wrap gap-1 mt-1.5 ${isMine ? 'justify-end' : 'justify-start'}`}>
{Object.entries(reactionGroups).map(([emoji, data]) => ( {Object.entries(reactionGroups).map(([emoji, data]) => (
<button <button
key={emoji} key={emoji}
onClick={() => handleReaction(emoji)} onClick={(e) => { e.stopPropagation(); handleReaction(emoji); }}
className={`flex items-center gap-1 px-2 py-0.5 rounded-full text-xs transition-colors ${data.isMine className={`flex items-center gap-1.5 px-2.5 py-1 ${hasImage && !message.content ? 'backdrop-blur-md bg-black/40 text-white' : (isMine ? 'glass-panel text-white border-white/10 shadow-sm' : 'bg-surface-tertiary text-zinc-200 border-white/5 shadow-sm')} rounded-full transition-colors border ${
? 'bg-knot-500/30 border border-knot-500/50' data.isMine
: 'bg-surface-tertiary border border-border hover:border-zinc-600' ? (isMine ? 'bg-white/20 border-white/30' : 'bg-knot-500/20 border-knot-500/40')
: (isMine ? 'hover:bg-white/10' : 'hover:border-white/20')
}`} }`}
title={data.users.join(', ')} title={data.users.join(', ')}
> >
<span>{emoji}</span> <span className="text-[17px] leading-none">{emoji}</span>
<span className="text-zinc-400">{data.count}</span> {(data.avatars && data.avatars.length > 0) ? (
<div className="flex -space-x-1.5 ml-0.5">
{data.avatars.map((av, idx) => (
av.url ? (
<img key={idx} src={av.url} className="w-5 h-5 rounded-full border-[1.5px] border-black/20 object-cover" />
) : (
<div key={idx} className="w-5 h-5 rounded-full border-[1.5px] border-black/20 bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-[9px] font-bold">
{av.initials}
</div>
)
))}
</div>
) : (
<span className="text-[12px] font-medium opacity-80 tabular-nums">{data.count}</span>
)}
{data.count > 1 && data.avatars && data.avatars.length > 0 && (
<span className="text-[12px] font-bold opacity-80 tabular-nums ml-1.5 mr-0.5">{data.count}</span>
)}
</button> </button>
))} ))}
</div> </div>
)} )}
</div> </div>
</div>
{isMine && ( {isMine && (
<div className="w-8 flex-shrink-0 ml-2 self-end"> <div className="w-8 flex-shrink-0 ml-2 self-end">
@@ -918,7 +981,7 @@ function MessageBubble({
<AnimatePresence> <AnimatePresence>
{lightboxData && ( {lightboxData && (
<ImageLightbox <ImageLightbox
images={media.filter(m => 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} initialIndex={lightboxData.index}
onClose={() => setLightboxData(null)} onClose={() => setLightboxData(null)}
/> />
@@ -22,6 +22,7 @@ export default function TelegramImportModal({ isOpen, onClose, friends }: Telegr
const [token, setToken] = useState<string | null>(null); const [token, setToken] = useState<string | null>(null);
const [names, setNames] = useState<string[]>([]); const [names, setNames] = useState<string[]>([]);
const [mapping, setMapping] = useState<Record<string, string>>({}); const [mapping, setMapping] = useState<Record<string, string>>({});
const [groupName, setGroupName] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [importedState, setImportedState] = useState<{ count: number; text: string } | null>(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); setError(null);
try { 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: 'Успешно импортировано' }); setImportedState({ count: res.messagesImported, text: 'Успешно импортировано' });
setStep(3); setStep(3);
} catch (err: any) { } catch (err: any) {
@@ -83,6 +84,7 @@ export default function TelegramImportModal({ isOpen, onClose, friends }: Telegr
setToken(null); setToken(null);
setNames([]); setNames([]);
setMapping({}); setMapping({});
setGroupName('');
setError(null); setError(null);
setImportedState(null); setImportedState(null);
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) fileInputRef.current.value = '';
@@ -199,6 +201,19 @@ export default function TelegramImportModal({ isOpen, onClose, friends }: Telegr
))} ))}
</div> </div>
{names.length > 2 && (
<div className="pt-2">
<h4 className="text-sm font-medium text-white mb-2">Название для группового чата</h4>
<input
type="text"
placeholder="Например, Моя группа"
value={groupName}
onChange={(e) => 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"
/>
</div>
)}
<div className="pt-4 flex items-center justify-end gap-3"> <div className="pt-4 flex items-center justify-end gap-3">
<button <button
onClick={reset} onClick={reset}
@@ -209,7 +224,7 @@ export default function TelegramImportModal({ isOpen, onClose, friends }: Telegr
</button> </button>
<button <button
onClick={handleExecute} onClick={handleExecute}
disabled={loading || names.some(n => !mapping[n])} disabled={loading || names.some(n => !mapping[n]) || (names.length > 2 && !groupName.trim())}
className="px-6 py-2.5 bg-knot-500 hover:bg-knot-600 disabled:bg-surface-tertiary disabled:text-zinc-500 text-white text-sm font-medium rounded-xl transition-colors flex items-center gap-2" className="px-6 py-2.5 bg-knot-500 hover:bg-knot-600 disabled:bg-surface-tertiary disabled:text-zinc-500 text-white text-sm font-medium rounded-xl transition-colors flex items-center gap-2"
> >
{loading ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />} {loading ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}
+12 -2
View File
@@ -25,7 +25,7 @@ interface UserProfileProps {
isSelf?: boolean; isSelf?: boolean;
} }
type MediaTab = 'publications' | 'media' | 'files' | 'links'; type MediaTab = 'publications' | 'gifs' | 'media' | 'files' | 'links';
export default function UserProfile({ userId, chatId, onClose, onGoToMessage, isSelf }: UserProfileProps) { export default function UserProfile({ userId, chatId, onClose, onGoToMessage, isSelf }: UserProfileProps) {
const { user: authUser, config } = useAuthStore(); const { user: authUser, config } = useAuthStore();
@@ -78,6 +78,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
// Shared media state // Shared media state
const [sharedMedia, setSharedMedia] = useState<Message[]>([]); const [sharedMedia, setSharedMedia] = useState<Message[]>([]);
const [sharedGifs, setSharedGifs] = useState<Message[]>([]);
const [sharedFiles, setSharedFiles] = useState<Message[]>([]); const [sharedFiles, setSharedFiles] = useState<Message[]>([]);
const [sharedLinks, setSharedLinks] = useState<Array<Message & { links?: string[] }>>([]); const [sharedLinks, setSharedLinks] = useState<Array<Message & { links?: string[] }>>([]);
const [tabLoading, setTabLoading] = useState(false); const [tabLoading, setTabLoading] = useState(false);
@@ -116,8 +117,16 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
createdAt: msg.createdAt createdAt: msg.createdAt
}))); })));
const allGifs = sharedGifs.flatMap(msg => (msg.media || []).map(m => ({
...m,
url: getMediaUrl(m.url),
messageId: msg.id,
createdAt: msg.createdAt
})));
const sortedStories = [...userStories].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); const sortedStories = [...userStories].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const sortedMedia = [...allMedia].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); 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 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()); const sortedLinks = [...sharedLinks].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
@@ -173,6 +182,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
} else if (chatId) { // Only load media/files/links if chatId is available } else if (chatId) { // Only load media/files/links if chatId is available
const data = await api.getSharedMedia(chatId, tab); const data = await api.getSharedMedia(chatId, tab);
if (tab === 'media') setSharedMedia(data); if (tab === 'media') setSharedMedia(data);
else if (tab === 'gifs') setSharedGifs(data);
else if (tab === 'files') setSharedFiles(data); else if (tab === 'files') setSharedFiles(data);
else setSharedLinks(data); else setSharedLinks(data);
} }
@@ -187,7 +197,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
useEffect(() => { useEffect(() => {
const tabsToLoad: MediaTab[] = ['publications']; const tabsToLoad: MediaTab[] = ['publications'];
if (chatId) { if (chatId) {
tabsToLoad.push('media', 'files', 'links'); tabsToLoad.push('gifs', 'media', 'files', 'links');
} }
tabsToLoad.forEach(tab => loadTabData(tab)); tabsToLoad.forEach(tab => loadTabData(tab));
}, [chatId, loadTabData]); }, [chatId, loadTabData]);
+5 -6
View File
@@ -260,8 +260,8 @@ class ApiClient {
return this.request<{ isPinned: boolean }>(`/chats/${chatId}/pin`, { method: 'POST' }); return this.request<{ isPinned: boolean }>(`/chats/${chatId}/pin`, { method: 'POST' });
} }
async getSharedMedia(chatId: string, type: 'media' | 'files' | 'links') { async getSharedMedia(chatId: string, type: 'media' | 'gifs' | 'files' | 'links') {
return this.request<Message[]>(`/messages/chat/${chatId}/shared?type=${type}`); return this.request<any[]>(`/messages/chat/${chatId}/shared?type=${type}`);
} }
@@ -358,12 +358,11 @@ class ApiClient {
return res; return res;
} }
async executeTelegramImport(data: { token: string; mapping: Record<string, string> }) { async executeTelegramImport(req: { token: string; mapping: Record<string, string>; groupName?: string }) {
const res = await this.request('/import/telegram/execute', { return this.request('/import/telegram/execute', {
method: 'POST', method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(req),
}); });
return res;
} }
// Friends // Friends
+2
View File
@@ -168,6 +168,7 @@ const translations = {
you: 'вы', you: 'вы',
// User profile // User profile
mediaTab: 'Медиа', mediaTab: 'Медиа',
gifs: 'GIF',
filesTab: 'Файлы', filesTab: 'Файлы',
linksTab: 'Ссылки', linksTab: 'Ссылки',
profileTitle: 'Профиль', profileTitle: 'Профиль',
@@ -437,6 +438,7 @@ const translations = {
no: 'No', no: 'No',
you: 'you', you: 'you',
mediaTab: 'Media', mediaTab: 'Media',
gifs: 'GIF',
filesTab: 'Files', filesTab: 'Files',
linksTab: 'Links', linksTab: 'Links',
profileTitle: 'Profile', profileTitle: 'Profile',
+1 -1
View File
@@ -48,7 +48,7 @@ export interface Reaction {
id: string; id: string;
emoji: string; emoji: string;
userId: string; userId: string;
user: { id: string; username: string; displayName: string }; user: { id: string; username: string; displayName: string; avatar?: string | null };
} }
export interface MessageSender { export interface MessageSender {
+4 -1
View File
@@ -34,6 +34,7 @@ interface AppUser {
bio?: string; bio?: string;
createdAt: string; createdAt: string;
lastOnlineAt: string; lastOnlineAt: string;
isOnline?: boolean;
stats?: { stats?: {
messagesCount: number; messagesCount: number;
mediaCount: number; mediaCount: number;
@@ -635,7 +636,9 @@ export default function AdminPage() {
</div> </div>
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col gap-1"> <div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col gap-1">
<span className="text-xs text-gray-500 uppercase">{t.lastSeen}</span> <span className="text-xs text-gray-500 uppercase">{t.lastSeen}</span>
<span className="text-white">{formatDateTime(selectedUser.lastOnlineAt)}</span> <span className={selectedUser.isOnline ? "text-green-400 font-medium" : "text-white"}>
{selectedUser.isOnline ? (lang === 'ru' ? 'В сети' : 'Online') : formatDateTime(selectedUser.lastOnlineAt)}
</span>
</div> </div>
</div> </div>
+2 -2
View File
@@ -172,8 +172,8 @@ export default function ChatPage() {
removeReaction(data.messageId, data.chatId, data.userId, data.emoji); removeReaction(data.messageId, data.chatId, data.userId, data.emoji);
}); });
socket.on('messages_read', (data: { chatId: string; userId: string; messageIds: string[] }) => { socket.on('messages_read', (data: any) => {
markRead(data.chatId, data.userId, data.messageIds); markRead(data.chatId || data.ChatId, data.userId || data.UserId, data.messageIds || data.MessageIds || []);
}); });
socket.on('user_typing', (data: { chatId: string; userId: string }) => { socket.on('user_typing', (data: { chatId: string; userId: string }) => {
+20 -4
View File
@@ -22,7 +22,7 @@ interface ChatState {
setDraft: (chatId: string, text: string) => void; setDraft: (chatId: string, text: string) => void;
getDraft: (chatId: string) => string; getDraft: (chatId: string) => string;
loadChats: () => Promise<void>; loadChats: () => Promise<void>;
loadMessages: (chatId: string, reset?: boolean) => Promise<void>; loadMessages: (chatId: string, reset?: boolean, isHistory?: boolean) => Promise<void>;
addMessage: (message: Message) => void; addMessage: (message: Message) => void;
updateMessage: (message: Message) => void; updateMessage: (message: Message) => void;
removeMessage: (messageId: string, chatId: string) => 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; addReaction: (messageId: string, chatId: string, userId: string, username: string, emoji: string) => void;
removeReaction: (messageId: string, chatId: string, userId: string, emoji: string) => void; removeReaction: (messageId: string, chatId: string, userId: string, emoji: string) => void;
markRead: (chatId: string, userId: string, messageIds: string[]) => void; markRead: (chatId: string, userId: string, messageIds: string[]) => void;
markAllAsRead: (chatId: string) => void;
addTypingUser: (chatId: string, userId: string) => void; addTypingUser: (chatId: string, userId: string) => void;
removeTypingUser: (chatId: string, userId: string) => void; removeTypingUser: (chatId: string, userId: string) => void;
updateUserOnlineStatus: (userId: string, isOnline: boolean, lastSeen?: string) => void; updateUserOnlineStatus: (userId: string, isOnline: boolean, lastSeen?: string) => void;
@@ -113,10 +114,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
} }
}, },
loadMessages: async (chatId, reset = false) => { loadMessages: async (chatId, reset = false, isHistory = false) => {
try { try {
const state = get(); 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; if (state.isLoadingMessages) return;
set({ isLoadingMessages: true }); set({ isLoadingMessages: true });
@@ -392,10 +394,12 @@ export const useChatStore = create<ChatState>((set, get) => ({
const currentUserId = useAuthStore.getState().user?.id; const currentUserId = useAuthStore.getState().user?.id;
set((state) => { set((state) => {
const chatMessages = state.messages[chatId] || []; const chatMessages = state.messages[chatId] || [];
let newlyReadCount = 0;
const updateMsg = (m: Message) => { const updateMsg = (m: Message) => {
if (messageIds.includes(m.id)) { if (messageIds.includes(m.id)) {
const alreadyRead = m.readBy?.some((r) => r.userId === userId); const alreadyRead = m.readBy?.some((r) => r.userId === userId);
if (alreadyRead) return m; if (alreadyRead) return m;
if (userId === currentUserId && m.senderId !== currentUserId) newlyReadCount++;
return { ...m, readBy: [...(m.readBy || []), { userId }] }; return { ...m, readBy: [...(m.readBy || []), { userId }] };
} }
return m; return m;
@@ -407,7 +411,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
if (chat.id === chatId) { if (chat.id === chatId) {
const updatedLastMessages = chat.messages?.map(updateMsg); const updatedLastMessages = chat.messages?.map(updateMsg);
if (userId === currentUserId) { 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 }; return { ...chat, messages: updatedLastMessages };
} }
@@ -424,6 +428,18 @@ export const useChatStore = create<ChatState>((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) => { addTypingUser: (chatId, userId) => {
set((state) => { set((state) => {
const exists = state.typingUsers.some((t) => t.chatId === chatId && t.userId === userId); const exists = state.typingUsers.some((t) => t.chatId === chatId && t.userId === userId);
-2
View File
@@ -41,8 +41,6 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile.web dockerfile: Dockerfile.web
args:
- VITE_KLIPY_API_KEY=${VITE_KLIPY_API_KEY}
container_name: knot-web container_name: knot-web
restart: always restart: always
ports: ports: