Импорт групповых чатом, мелкие правки, внешний вид чата, 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

View File

@@ -13,10 +13,6 @@ RUN npm install --legacy-peer-deps
# Copy source code
COPY apps/web/ ./
# Build args
ARG VITE_KLIPY_API_KEY
ENV VITE_KLIPY_API_KEY=$VITE_KLIPY_API_KEY
# Build web frontend
RUN npm run build

View File

@@ -62,7 +62,8 @@ public class AdminController : ControllerBase
DisplayName = u.DisplayName,
Avatar = u.Avatar,
CreatedAt = u.CreatedAt,
LastOnlineAt = u.CreatedAt // fallback
IsOnline = Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(u.Id.ToString()),
LastOnlineAt = Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(u.Id.ToString()) ? DateTime.UtcNow : u.CreatedAt
}));
}
@@ -101,7 +102,8 @@ public class AdminController : ControllerBase
Bio = targetUser.Bio,
Avatar = targetUser.Avatar,
CreatedAt = targetUser.CreatedAt,
LastOnlineAt = targetUser.CreatedAt, // fallback
IsOnline = Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()),
LastOnlineAt = Knot.Modules.Chats.Infrastructure.SignalR.ChatHub.IsUserOnline(targetUser.Id.ToString()) ? DateTime.UtcNow : targetUser.CreatedAt,
Stats = new
{
MessagesCount = messagesCount,

View File

@@ -93,8 +93,8 @@ public sealed class MessagesController : ControllerBase
if (rUser != null) senders[r.UserId] = rUser;
}
var userObj = senders.TryGetValue(r.UserId, out var ru)
? new { id = ru.Id, username = ru.Username, displayName = ru.DisplayName }
: new { id = r.UserId, username = "unknown", displayName = "Unknown" };
? new { id = ru.Id, username = ru.Username, displayName = ru.DisplayName, avatar = ru.Avatar }
: new { id = r.UserId, username = "unknown", displayName = "Unknown", avatar = (string?)null };
reactionsWithUser.Add(new
{
@@ -141,7 +141,7 @@ public sealed class MessagesController : ControllerBase
displayName = s.DisplayName,
avatar = s.Avatar
} : null,
readBy = m.ReadBy.Select(r => r.UserId).ToList(),
readBy = m.ReadBy.Select(r => new { userId = r.UserId }).ToList(),
reactions = reactionsWithUser
});
}
@@ -266,7 +266,10 @@ public sealed class MessagesController : ControllerBase
var filteredMedia = m.Media.Where(media => {
var mediaType = media.Type?.ToLower() ?? "file";
if (filterType == "media") return mediaType == "image" || mediaType == "video";
var isGif = mediaType == "image" && (media.Url.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase) || media.Url.EndsWith(".gif", StringComparison.OrdinalIgnoreCase));
if (filterType == "media") return (mediaType == "image" || mediaType == "video") && !isGif;
if (filterType == "gifs") return isGif;
if (filterType == "files") return mediaType != "image" && mediaType != "video" && mediaType != "link";
return true;
}).ToList();

View File

@@ -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")]
public async Task<IActionResult> Execute([FromBody] ExecuteImportRequest req, CancellationToken ct)
@@ -161,7 +161,7 @@ public sealed class TelegramImportController : ControllerBase
else
{
// Create a group
var command = new CreateChatCommand("Импортированный чат", ChatType.Group, chatMembers);
var command = new CreateChatCommand(req.GroupName ?? "Импортированный чат", ChatType.Group, chatMembers);
var res = await _sender.Send(command, ct);
if (res.IsFailure) return BadRequest(res.Error);
chatId = res.Value;
@@ -224,15 +224,25 @@ public sealed class TelegramImportController : ControllerBase
Guid senderGuid = lastSenderGuid;
string content = "";
if (textNode != null)
var mainBodyNode = node.QuerySelector(".body");
var isForwarded = node.QuerySelector(".forwarded") != null;
// To avoid grabbing text from inside the forwarded block as main text,
// we can look for .text that is a direct child of the main .body
// The .forwarded block has its own .text
var contentTextNode = isForwarded
? (node.QuerySelector(".body > .text") ?? node.QuerySelector(".text:not(.forwarded .text)"))
: node.QuerySelector(".text");
if (contentTextNode != null)
{
var html = textNode.InnerHtml
var html = contentTextNode.InnerHtml
.Replace("<br>", "\n", StringComparison.OrdinalIgnoreCase)
.Replace("<br/>", "\n", StringComparison.OrdinalIgnoreCase)
.Replace("<br />", "\n", StringComparison.OrdinalIgnoreCase);
var tempParser = new AngleSharp.Html.Parser.HtmlParser();
var tempDoc = tempParser.ParseDocument("<div>" + html + "</div>");
content = tempDoc.Body.TextContent.Trim();
content = tempDoc.Body?.TextContent.Trim() ?? "";
}
DateTime createdAt = lastCreatedAt;
@@ -282,6 +292,7 @@ public sealed class TelegramImportController : ControllerBase
}
// Parse forwards
Guid? forwardedFromId = null;
var forwardedNode = node.QuerySelector(".forwarded.body");
if (forwardedNode != null)
{
@@ -294,6 +305,15 @@ public sealed class TelegramImportController : ControllerBase
}
var fwdName = fwdNameText != null ? fwdNameText.TextContent.Trim() : "Неизвестного";
if (req.Mapping.TryGetValue(fwdName, out var mappedFwdId) && mappedFwdId != Guid.Empty)
{
forwardedFromId = mappedFwdId;
}
else if (fwdName == "Это я" || fwdName == req.Mapping.FirstOrDefault(x => x.Value == myId).Key)
{
forwardedFromId = myId;
}
var fwdTextNode = forwardedNode.QuerySelector(".text");
string fwdContent = "";
if (fwdTextNode != null)
@@ -304,12 +324,20 @@ public sealed class TelegramImportController : ControllerBase
.Replace("<br />", "\n", StringComparison.OrdinalIgnoreCase);
var tempParser = new AngleSharp.Html.Parser.HtmlParser();
var tempDoc = tempParser.ParseDocument("<div>" + fHtml + "</div>");
fwdContent = tempDoc.Body.TextContent.Trim();
fwdContent = tempDoc.Body?.TextContent.Trim() ?? "";
}
content = string.IsNullOrEmpty(content)
? $"[Переслано от {fwdName}]:\n{fwdContent}"
: $"{content}\n\n[Переслано от {fwdName}]:\n{fwdContent}";
if (forwardedFromId == null)
{
// We don't have this user in the app, map to generic string
content = string.IsNullOrEmpty(content)
? $"[Переслано от {fwdName}]:\n{fwdContent}"
: $"{content}\n\n[Переслано от {fwdName}]:\n{fwdContent}";
}
else if (string.IsNullOrEmpty(content))
{
content = fwdContent;
}
}
// Parse replies
@@ -356,6 +384,15 @@ public sealed class TelegramImportController : ControllerBase
if (firstHref != null)
{
messageType = GetMediaTypes(firstHref).mType;
if (mediaNodes[0].ClassName?.Contains("animated") == true || firstHref.EndsWith(".mp4"))
{
// Treat telegram animated gifs as image format in app (we auto-loop mp4 images)
// But web app specifically handles "image" and "video" and microlink crashes on mp4 video
// Let's just keep "video" or "image". Actually, telegram exports gifs as mp4.
// We should let our app player handle it.
if (mediaNodes[0].ClassName?.Contains("animated") == true)
messageType = "image";
}
}
}
@@ -370,7 +407,7 @@ public sealed class TelegramImportController : ControllerBase
else
{
string finalContent = content;
targetMessage = Message.Import(chatId, senderGuid, finalContent, messageType, createdAt, replyToId, null);
targetMessage = Message.Import(chatId, senderGuid, finalContent, messageType, createdAt, replyToId, forwardedFromId);
var idAttr = node.GetAttribute("id");
if (!string.IsNullOrEmpty(idAttr))
@@ -396,8 +433,10 @@ public sealed class TelegramImportController : ControllerBase
ms.Position = 0;
var types = GetMediaTypes(href);
var finalMType = types.mType;
if (mediaNode.ClassName?.Contains("animated") == true) finalMType = "image";
var fileId = await _fileStorage.UploadFileAsync(ms, Path.GetFileName(href), types.cType);
targetMessage.AddMedia(types.mType, $"/api/files/{fileId}", Path.GetFileName(href), zipEntry.Length);
targetMessage.AddMedia(finalMType, $"/api/files/{fileId}", Path.GetFileName(href), zipEntry.Length);
}
}
}

View File

@@ -23,6 +23,7 @@ public sealed class ChatHub : Hub
private static readonly ConcurrentDictionary<string, ConcurrentDictionary<string, ParticipantInfo>> _groupCallParticipants = new();
public static int OnlineUsersCount => _userConnections.Count;
public static bool IsUserOnline(string userId) => _userConnections.ContainsKey(userId);
private readonly ISender _sender;
private readonly IUserContext _userContext;

View File

@@ -8,14 +8,14 @@ public class SystemSettingsDto
public int MaxFileSizeMb { get; set; } = 100;
// Calls
public bool EnableCalls { get; set; } = true;
public bool EnableCalls { get; set; } = false;
public string TurnHost { get; set; } = string.Empty;
public int TurnPort { get; set; } = 3478;
public string TurnUser { get; set; } = string.Empty;
public string TurnSecret { get; set; } = string.Empty;
// Klipy
public bool EnableKlipy { get; set; } = true;
public bool EnableKlipy { get; set; } = false;
public string KlipyApiKey { get; set; } = string.Empty;
public string KlipyCustomerId { get; set; } = string.Empty;

View File

@@ -121,7 +121,6 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
useEffect(() => {
if (activeChat) {
setMuted(isChatMuted(activeChat));
setScrollReady(false);
setActiveGroupCallParticipants([]);
lastObservedMessageIdRef.current = null;
}
@@ -181,6 +180,10 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
}, []);
// Первичная прокрутка при открытии чата или после загрузки (layout effect — до отрисовки)
useLayoutEffect(() => {
setScrollReady(false);
}, [activeChat]);
useLayoutEffect(() => {
if (!isLoadingMessages && messagesContainerRef.current) {
const container = messagesContainerRef.current;
@@ -202,7 +205,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
}
setScrollReady(true);
}
}, [activeChat, isLoadingMessages && chatMessages.length === 0]);
}, [activeChat, isLoadingMessages]);
useLayoutEffect(() => {
if (prevScrollHeightRef.current > 0 && messagesContainerRef.current) {
@@ -298,14 +301,15 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
});
};
observeUnread();
// Delay slight to let everything mount and be visible in DOM
setTimeout(observeUnread, 100);
return () => observer.disconnect();
}, [activeChat, user?.id]);
// Re-run observation when messages change (to catch new arrivals)
useEffect(() => {
if (observerRef.current && !isLoadingMessages) {
if (observerRef.current) {
if (!messagesContainerRef.current) return;
const unreadElements = messagesContainerRef.current.querySelectorAll('.unread-detector');
unreadElements.forEach((el) => {
@@ -315,7 +319,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
}
});
}
}, [chatMessages.length, isLoadingMessages]);
}, [chatMessages, scrollReady]);
// Scroll detection
const checkScrollPosition = useCallback(() => {
@@ -332,7 +336,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
const container = messagesContainerRef.current;
if (container && container.scrollTop < 100 && activeChat && hasMoreMessages[activeChat] && !isLoadingMessages) {
prevScrollHeightRef.current = container.scrollHeight;
useChatStore.getState().loadMessages(activeChat, false);
useChatStore.getState().loadMessages(activeChat, false, true);
}
};
@@ -790,7 +794,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('highlight-message');
setTimeout(() => el.classList.remove('highlight-message'), 3000);
setTimeout(() => el.classList.remove('highlight-message'), 5000);
}
setShowSearch(false);
setSearchText('');
@@ -837,7 +841,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('highlight-message');
setTimeout(() => el.classList.remove('highlight-message'), 3000);
setTimeout(() => el.classList.remove('highlight-message'), 5000);
}
}}
className="flex items-center gap-3 px-4 py-2 border-b border-border bg-surface-secondary/60 hover:bg-surface-hover transition-colors text-left w-full flex-shrink-0"
@@ -867,7 +871,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
<div
ref={messagesContainerRef}
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 ? (
<div className="flex justify-center py-8">
@@ -947,7 +951,12 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
onClick={() => scrollToBottom()}
onClick={() => {
scrollToBottom(false);
if (activeChat && unreadCount > 0) {
useChatStore.getState().markAllAsRead(activeChat);
}
}}
className="absolute bottom-24 right-5 w-12 h-12 rounded-full bg-surface-secondary/95 backdrop-blur-md border border-border shadow-xl flex items-center justify-center text-zinc-400 hover:text-white hover:bg-surface-hover hover:scale-105 transition-all z-10"
>
<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) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('highlight-message');
setTimeout(() => el.classList.remove('highlight-message'), 3000);
setTimeout(() => el.classList.remove('highlight-message'), 5000);
setProfileUserId(null);
}
}}
@@ -1006,7 +1015,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('highlight-message');
setTimeout(() => el.classList.remove('highlight-message'), 3000);
setTimeout(() => el.classList.remove('highlight-message'), 5000);
setShowGroupSettings(false);
}
}}

View File

@@ -56,9 +56,10 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<UserPresence[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [activeTab, setActiveTab] = useState<'members' | 'media' | 'files' | 'links'>('members');
const [activeTab, setActiveTab] = useState<'members' | 'gifs' | 'media' | 'files' | 'links'>('members');
const [tabLoading, setTabLoading] = useState(false);
const [sharedMedia, setSharedMedia] = useState<Message[]>([]);
const [sharedGifs, setSharedGifs] = useState<Message[]>([]);
const [sharedFiles, setSharedFiles] = useState<Message[]>([]);
const [sharedLinks, setSharedLinks] = useState<Array<Message & { links?: string[] }>>([]);
const [loadedTabs, setLoadedTabs] = useState<Set<string>>(new Set());
@@ -224,12 +225,13 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
.slice(0, 2)
.toUpperCase();
const loadTabData = async (tab: 'media' | 'files' | 'links') => {
const loadTabData = async (tab: 'gifs' | 'media' | 'files' | 'links') => {
if (loadedTabs.has(tab)) return;
setTabLoading(true);
try {
const data = await api.getSharedMedia(chat.id, tab);
if (tab === 'media') setSharedMedia(data);
else if (tab === 'gifs') setSharedGifs(data);
else if (tab === 'files') setSharedFiles(data);
else setSharedLinks(data);
setLoadedTabs(prev => new Set(prev).add(tab));
@@ -241,6 +243,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
};
useEffect(() => {
loadTabData('gifs');
loadTabData('media');
loadTabData('files');
loadTabData('links');
@@ -255,7 +258,16 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
createdAt: msg.createdAt
})));
const allGifs = sharedGifs.flatMap(msg => (msg.media || []).map(m => ({
...m,
url: getMediaUrl(m.url),
thumbnail: m.thumbnail ? getMediaUrl(m.thumbnail) : undefined,
messageId: msg.id,
createdAt: msg.createdAt
})));
const sortedMedia = [...allMedia].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const sortedGifs = [...allGifs].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const sortedFiles = [...sharedFiles].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const sortedLinks = [...sharedLinks].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
@@ -295,6 +307,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
const tabsConfig = [
{ key: 'members' as const, label: t('membersCount') || 'Участники', icon: Users, count: chat.members.length },
{ key: 'gifs' as const, label: t('gifs') || 'GIF', icon: Play, count: sortedGifs.length },
{ key: 'media' as const, label: t('mediaTab'), icon: ImageIcon, count: sortedMedia.length },
{ key: 'files' as const, label: t('filesTab'), icon: FileText, count: sortedFiles.flatMap(msg => msg.media || []).length },
{ key: 'links' as const, label: t('linksTab'), icon: LinkIcon, count: sortedLinks.flatMap(msg => msg.links || []).length },
@@ -303,7 +316,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
const availableTabs = tabsConfig.filter(tab => tab.key === 'members' || !loadedTabs.has(tab.key) || tab.count > 0);
useEffect(() => {
if (loadedTabs.size === 3 && availableTabs.length > 0 && !availableTabs.find(t => t.key === activeTab)) {
if (loadedTabs.size === 4 && availableTabs.length > 0 && !availableTabs.find(t => t.key === activeTab)) {
setActiveTab(availableTabs[0].key);
}
}, [loadedTabs, activeTab]); // availableTabs removed from dependencies to avoid infinite loops since its reference runs on every render
@@ -640,6 +653,38 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
))}
</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' ? (
sortedMedia.length > 0 ? (
renderGrouped(sortedMedia, (m, idx) => (
@@ -757,7 +802,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
)}
</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">
<ImageIcon size={32} className="text-zinc-600 mb-3" />
<p className="text-sm text-zinc-500">{(t('sharedPhotos' as any) || 'Нет вложений') as string}</p>

View File

@@ -34,17 +34,30 @@ export default function LinkPreview({ url }: LinkPreviewProps) {
} catch (e) {}
}
fetch(`https://api.microlink.io?url=${encodeURIComponent(url)}`)
.then(res => res.json())
.then(res => {
const fetchWithRetry = async (targetUrl: string, attempts = 2) => {
for (let i = 0; i < attempts; i++) {
try {
const res = await fetch(`https://api.microlink.io?url=${encodeURIComponent(targetUrl)}`);
if (res.ok) {
return await res.json();
}
} catch (error) {
if (i === attempts - 1) throw error;
}
}
throw new Error('Max retries reached');
};
fetchWithRetry(url)
.then((res) => {
if (isMounted && res.status === 'success' && res.data) {
setData(res.data);
sessionStorage.setItem(cacheKey, JSON.stringify(res.data));
}
if (isMounted) setLoading(false);
})
.catch((err) => {
console.error('Failed to fetch link preview:', err);
.catch(() => {
// Suppress errors and stop after max attempts
if (isMounted) setLoading(false);
});

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 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) => {
if (!reactionGroups[r.emoji]) {
reactionGroups[r.emoji] = { count: 0, users: [], isMine: false };
reactionGroups[r.emoji] = { count: 0, users: [], isMine: false, avatars: [] };
}
reactionGroups[r.emoji].count++;
reactionGroups[r.emoji].users.push(r.user?.displayName || r.user?.username || '');
const displayName = r.user?.displayName || r.user?.username || '?';
reactionGroups[r.emoji].users.push(displayName);
if (reactionGroups[r.emoji].avatars.length < 3) {
reactionGroups[r.emoji].avatars.push({
url: r.user?.avatar,
initials: displayName[0].toUpperCase()
});
}
if (r.userId === user?.id) reactionGroups[r.emoji].isMine = true;
});
@@ -400,39 +407,76 @@ function MessageBubble({
</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 */}
{message.replyTo && (
<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"
onClick={() => {
className={`mb-1.5 pl-2.5 py-0.5 border-l-[3px] cursor-pointer transition-colors -mx-1 px-1 rounded-sm ${
isMine ? 'border-l-white/80 hover:bg-white/10' : 'border-l-knot-500 hover:bg-knot-500/10'
}`}
onClick={(e) => {
e.stopPropagation();
const el = document.getElementById(`msg-${message.replyToId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('highlight-message');
setTimeout(() => el.classList.remove('highlight-message'), 3000);
setTimeout(() => el.classList.remove('highlight-message'), 5000);
}
}}
>
<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}
</p>
<div className="flex items-center gap-1">
<div className="flex items-center gap-1.5">
{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 && (
<div className="w-8 h-8 rounded bg-black/20 overflow-hidden flex-shrink-0">
{message.replyTo.media[0].type === 'image' ? (
<img src={message.replyTo.media[0].url} className="w-full h-full object-cover" alt="" />
) : message.replyTo.media[0].type === 'video' ? (
<div className="w-full h-full flex items-center justify-center bg-black/40"><Play size={10} className="text-white" /></div>
{message.replyTo.media && message.replyTo.media.length > 0 && !message.quote && (() => {
const m = message.replyTo.media[0];
const isMp4 = m.type === 'image' && m.url?.toLowerCase().endsWith('.mp4');
return (
<div className="w-8 h-8 rounded bg-black/20 overflow-hidden flex-shrink-0 relative">
{m.type === 'image' ? (
isMp4 ? (
<video src={m.url} className="w-full h-full object-cover" muted playsInline />
) : (
<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-zinc-500" /></div>
<div className="w-full h-full flex items-center justify-center"><FileText size={10} className="text-white/50" /></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>
@@ -442,9 +486,11 @@ function MessageBubble({
{/* Story Reply Quote */}
{message.storyId && (
<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')}
</p>
<div className="flex items-center gap-2">
@@ -462,36 +508,19 @@ function MessageBubble({
)}
</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}
</p>
</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 && (
<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!)}
>
<div className="text-[11px] font-bold uppercase tracking-wider opacity-70 leading-none mb-1">
{t('forwardedFrom')}
</div>
<div className={`font-semibold ${isMine ? 'text-white' : 'text-accent-light'}`}>
<div className={`font-semibold ${isMine ? 'text-white' : 'text-knot-500'}`}>
{message.forwardedFrom.displayName || message.forwardedFrom.username}
</div>
</div>
@@ -522,16 +551,29 @@ function MessageBubble({
? 'grid-cols-2'
: 'grid-cols-1'
}`}>
{galleryMedia.map((m, idx) => (
m.type === 'image' ? (
<img
key={m.id}
src={m.url}
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]'
}`}
onClick={() => setLightboxData({ index: idx })}
/>
{galleryMedia.map((m, idx) => {
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
key={m.id}
src={m.url}
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]'}`}
onClick={() => setLightboxData({ index: idx })}
/>
)
) : (
<div
key={m.id}
@@ -547,8 +589,8 @@ function MessageBubble({
<Play size={galleryMedia.length > 1 ? 24 : 48} className="text-white opacity-80" />
</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-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)}
</p>
{firstUrl && !hasImage && !hasVideo && !hasFile && (
@@ -713,21 +758,22 @@ function MessageBubble({
</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" />}
{timeStr}
{isMine && !message.scheduledAt && (
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>
</div>
)}
);
})()}
{!message.content && (hasImage || hasVideo) && (
<div className={`flex justify-end px-3 py-1 ${hasImage ? '-mt-8 relative z-10' : ''}`}>
@@ -743,27 +789,44 @@ function MessageBubble({
</span>
</div>
)}
</div>
{/* Реакции */}
{Object.keys(reactionGroups).length > 0 && (
<div className="flex flex-wrap gap-1 mt-1 mx-1">
{Object.entries(reactionGroups).map(([emoji, data]) => (
<button
key={emoji}
onClick={() => handleReaction(emoji)}
className={`flex items-center gap-1 px-2 py-0.5 rounded-full text-xs transition-colors ${data.isMine
? 'bg-knot-500/30 border border-knot-500/50'
: 'bg-surface-tertiary border border-border hover:border-zinc-600'
{/* Реакции */}
{Object.keys(reactionGroups).length > 0 && (
<div className={`flex flex-wrap gap-1 mt-1.5 ${isMine ? 'justify-end' : 'justify-start'}`}>
{Object.entries(reactionGroups).map(([emoji, data]) => (
<button
key={emoji}
onClick={(e) => { e.stopPropagation(); handleReaction(emoji); }}
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 ${
data.isMine
? (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(', ')}
>
<span>{emoji}</span>
<span className="text-zinc-400">{data.count}</span>
</button>
))}
</div>
)}
title={data.users.join(', ')}
>
<span className="text-[17px] leading-none">{emoji}</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>
))}
</div>
)}
</div>
</div>
{isMine && (
@@ -918,7 +981,7 @@ function MessageBubble({
<AnimatePresence>
{lightboxData && (
<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}
onClose={() => setLightboxData(null)}
/>

View File

@@ -22,6 +22,7 @@ export default function TelegramImportModal({ isOpen, onClose, friends }: Telegr
const [token, setToken] = useState<string | null>(null);
const [names, setNames] = useState<string[]>([]);
const [mapping, setMapping] = useState<Record<string, string>>({});
const [groupName, setGroupName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<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);
try {
const res = await api.executeTelegramImport({ token, mapping }) as any;
const res = await api.executeTelegramImport({ token, mapping, groupName }) as any;
setImportedState({ count: res.messagesImported, text: 'Успешно импортировано' });
setStep(3);
} catch (err: any) {
@@ -83,6 +84,7 @@ export default function TelegramImportModal({ isOpen, onClose, friends }: Telegr
setToken(null);
setNames([]);
setMapping({});
setGroupName('');
setError(null);
setImportedState(null);
if (fileInputRef.current) fileInputRef.current.value = '';
@@ -199,6 +201,19 @@ export default function TelegramImportModal({ isOpen, onClose, friends }: Telegr
))}
</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">
<button
onClick={reset}
@@ -209,7 +224,7 @@ export default function TelegramImportModal({ isOpen, onClose, friends }: Telegr
</button>
<button
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"
>
{loading ? <Loader2 size={16} className="animate-spin" /> : <Check size={16} />}

View File

@@ -25,7 +25,7 @@ interface UserProfileProps {
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) {
const { user: authUser, config } = useAuthStore();
@@ -78,6 +78,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
// Shared media state
const [sharedMedia, setSharedMedia] = useState<Message[]>([]);
const [sharedGifs, setSharedGifs] = useState<Message[]>([]);
const [sharedFiles, setSharedFiles] = useState<Message[]>([]);
const [sharedLinks, setSharedLinks] = useState<Array<Message & { links?: string[] }>>([]);
const [tabLoading, setTabLoading] = useState(false);
@@ -116,8 +117,16 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
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 sortedMedia = [...allMedia].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const sortedGifs = [...allGifs].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const sortedFiles = [...sharedFiles].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const sortedLinks = [...sharedLinks].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
@@ -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
const data = await api.getSharedMedia(chatId, tab);
if (tab === 'media') setSharedMedia(data);
else if (tab === 'gifs') setSharedGifs(data);
else if (tab === 'files') setSharedFiles(data);
else setSharedLinks(data);
}
@@ -187,7 +197,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
useEffect(() => {
const tabsToLoad: MediaTab[] = ['publications'];
if (chatId) {
tabsToLoad.push('media', 'files', 'links');
tabsToLoad.push('gifs', 'media', 'files', 'links');
}
tabsToLoad.forEach(tab => loadTabData(tab));
}, [chatId, loadTabData]);

View File

@@ -260,8 +260,8 @@ class ApiClient {
return this.request<{ isPinned: boolean }>(`/chats/${chatId}/pin`, { method: 'POST' });
}
async getSharedMedia(chatId: string, type: 'media' | 'files' | 'links') {
return this.request<Message[]>(`/messages/chat/${chatId}/shared?type=${type}`);
async getSharedMedia(chatId: string, type: 'media' | 'gifs' | 'files' | 'links') {
return this.request<any[]>(`/messages/chat/${chatId}/shared?type=${type}`);
}
@@ -358,12 +358,11 @@ class ApiClient {
return res;
}
async executeTelegramImport(data: { token: string; mapping: Record<string, string> }) {
const res = await this.request('/import/telegram/execute', {
async executeTelegramImport(req: { token: string; mapping: Record<string, string>; groupName?: string }) {
return this.request('/import/telegram/execute', {
method: 'POST',
body: JSON.stringify(data),
body: JSON.stringify(req),
});
return res;
}
// Friends

View File

@@ -168,6 +168,7 @@ const translations = {
you: 'вы',
// User profile
mediaTab: 'Медиа',
gifs: 'GIF',
filesTab: 'Файлы',
linksTab: 'Ссылки',
profileTitle: 'Профиль',
@@ -437,6 +438,7 @@ const translations = {
no: 'No',
you: 'you',
mediaTab: 'Media',
gifs: 'GIF',
filesTab: 'Files',
linksTab: 'Links',
profileTitle: 'Profile',

View File

@@ -48,7 +48,7 @@ export interface Reaction {
id: string;
emoji: string;
userId: string;
user: { id: string; username: string; displayName: string };
user: { id: string; username: string; displayName: string; avatar?: string | null };
}
export interface MessageSender {

View File

@@ -34,6 +34,7 @@ interface AppUser {
bio?: string;
createdAt: string;
lastOnlineAt: string;
isOnline?: boolean;
stats?: {
messagesCount: number;
mediaCount: number;
@@ -635,7 +636,9 @@ export default function AdminPage() {
</div>
<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-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>

View File

@@ -172,8 +172,8 @@ export default function ChatPage() {
removeReaction(data.messageId, data.chatId, data.userId, data.emoji);
});
socket.on('messages_read', (data: { chatId: string; userId: string; messageIds: string[] }) => {
markRead(data.chatId, data.userId, data.messageIds);
socket.on('messages_read', (data: any) => {
markRead(data.chatId || data.ChatId, data.userId || data.UserId, data.messageIds || data.MessageIds || []);
});
socket.on('user_typing', (data: { chatId: string; userId: string }) => {

View File

@@ -22,7 +22,7 @@ interface ChatState {
setDraft: (chatId: string, text: string) => void;
getDraft: (chatId: string) => string;
loadChats: () => Promise<void>;
loadMessages: (chatId: string, reset?: boolean) => Promise<void>;
loadMessages: (chatId: string, reset?: boolean, isHistory?: boolean) => Promise<void>;
addMessage: (message: Message) => void;
updateMessage: (message: Message) => void;
removeMessage: (messageId: string, chatId: string) => void;
@@ -31,6 +31,7 @@ interface ChatState {
addReaction: (messageId: string, chatId: string, userId: string, username: string, emoji: string) => void;
removeReaction: (messageId: string, chatId: string, userId: string, emoji: string) => void;
markRead: (chatId: string, userId: string, messageIds: string[]) => void;
markAllAsRead: (chatId: string) => void;
addTypingUser: (chatId: string, userId: string) => void;
removeTypingUser: (chatId: string, userId: string) => void;
updateUserOnlineStatus: (userId: string, isOnline: boolean, lastSeen?: string) => void;
@@ -113,10 +114,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
}
},
loadMessages: async (chatId, reset = false) => {
loadMessages: async (chatId, reset = false, isHistory = false) => {
try {
const state = get();
if (!reset && state.messages[chatId] && state.hasMoreMessages[chatId] === false) return;
if (!reset && !isHistory && state.messages[chatId] && state.messages[chatId].length > 0) return;
if (!reset && isHistory && state.messages[chatId] && state.hasMoreMessages[chatId] === false) return;
if (state.isLoadingMessages) return;
set({ isLoadingMessages: true });
@@ -392,10 +394,12 @@ export const useChatStore = create<ChatState>((set, get) => ({
const currentUserId = useAuthStore.getState().user?.id;
set((state) => {
const chatMessages = state.messages[chatId] || [];
let newlyReadCount = 0;
const updateMsg = (m: Message) => {
if (messageIds.includes(m.id)) {
const alreadyRead = m.readBy?.some((r) => r.userId === userId);
if (alreadyRead) return m;
if (userId === currentUserId && m.senderId !== currentUserId) newlyReadCount++;
return { ...m, readBy: [...(m.readBy || []), { userId }] };
}
return m;
@@ -407,7 +411,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
if (chat.id === chatId) {
const updatedLastMessages = chat.messages?.map(updateMsg);
if (userId === currentUserId) {
return { ...chat, messages: updatedLastMessages, unreadCount: 0 };
return { ...chat, messages: updatedLastMessages, unreadCount: Math.max(0, (chat.unreadCount || 0) - newlyReadCount) };
}
return { ...chat, messages: updatedLastMessages };
}
@@ -424,6 +428,18 @@ export const useChatStore = create<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) => {
set((state) => {
const exists = state.typingUsers.some((t) => t.chatId === chatId && t.userId === userId);

View File

@@ -41,8 +41,6 @@ services:
build:
context: .
dockerfile: Dockerfile.web
args:
- VITE_KLIPY_API_KEY=${VITE_KLIPY_API_KEY}
container_name: knot-web
restart: always
ports: