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