Импорт групповых чатом, мелкие правки, внешний вид чата, GIF тип
This commit is contained in:
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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} />}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user