diff --git a/apps/server-net/uploads/b7d55a9a-c7cd-413f-92b8-e221ea3782ac.png b/apps/server-net/uploads/b7d55a9a-c7cd-413f-92b8-e221ea3782ac.png new file mode 100644 index 0000000..47d567b Binary files /dev/null and b/apps/server-net/uploads/b7d55a9a-c7cd-413f-92b8-e221ea3782ac.png differ diff --git a/apps/web/src/components/GroupSettings.tsx b/apps/web/src/components/GroupSettings.tsx index 2846096..57b5324 100644 --- a/apps/web/src/components/GroupSettings.tsx +++ b/apps/web/src/components/GroupSettings.tsx @@ -39,7 +39,7 @@ interface GroupSettingsProps { export default function GroupSettings({ chat, onClose }: GroupSettingsProps) { const { user } = useAuthStore(); const { updateChat } = useChatStore(); - const { t } = useLang(); + const { t, lang } = useLang(); const currentMember = chat.members.find((m) => m.user.id === user?.id); const [removeTargetId, setRemoveTargetId] = useState(null); @@ -240,17 +240,72 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) { }; useEffect(() => { - loadTabData(activeTab); - }, [activeTab]); + loadTabData('media'); + loadTabData('files'); + loadTabData('links'); + }, [chat.id]); const API_URL = import.meta.env.VITE_API_URL || ''; const allMedia = sharedMedia.flatMap(msg => (msg.media || []).map(m => ({ ...m, url: getMediaUrl(m.url), thumbnail: m.thumbnail ? getMediaUrl(m.thumbnail) : undefined, - messageId: msg.id + messageId: msg.id, + createdAt: msg.createdAt }))); + const sortedMedia = [...allMedia].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + const sortedFiles = [...sharedFiles].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + const sortedLinks = [...sharedLinks].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + const renderGrouped = ( + sortedItems: T[], + renderItem: (item: T, originalIndex: number) => React.ReactNode, + gridClass?: string + ) => { + let currentGroup: { dateStr: string; items: {item: T, idx: number}[] } | null = null; + const groups: { dateStr: string; items: {item: T, idx: number}[] }[] = []; + + sortedItems.forEach((item, idx) => { + const date = new Date(item.createdAt); + const dateStr = date.toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'long', year: 'numeric' }); + if (currentGroup?.dateStr !== dateStr) { + currentGroup = { dateStr, items: [] }; + groups.push(currentGroup); + } + currentGroup.items.push({item, idx}); + }); + + return ( +
+ {groups.map((g, i) => ( +
+
+ {g.dateStr} +
+
+ {g.items.map(({item, idx}) => renderItem(item, idx))} +
+
+ ))} +
+ ); + }; + + const tabsConfig = [ + { 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 }, + ]; + + const availableTabs = tabsConfig.filter(tab => !loadedTabs.has(tab.key) || tab.count > 0); + + useEffect(() => { + if (loadedTabs.size === 3 && 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 + return ( <> -
- {[ - { key: 'media' as const, label: t('mediaTab'), icon: ImageIcon }, - { key: 'files' as const, label: t('filesTab'), icon: FileText }, - { key: 'links' as const, label: t('linksTab'), icon: LinkIcon }, - ].map((tab) => ( - - ))} -
+ {availableTabs.length > 0 ? ( +
+
+ {availableTabs.map((tab) => ( + + ))} +
-
+
{tabLoading ? (
) : activeTab === 'media' ? ( - allMedia.length > 0 ? ( -
- {allMedia.map((m, idx) => ( + sortedMedia.length > 0 ? ( + renderGrouped(sortedMedia, (m, idx) => (
setLightboxIndex(idx)} @@ -486,18 +540,17 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) { /> )}
- ))} -
+ ), "grid grid-cols-3 gap-0.5 px-1") ) : (

{t('sharedPhotos')}

) ) : activeTab === 'files' ? ( - sharedFiles.length > 0 ? ( -
- {sharedFiles.flatMap((msg) => - (msg.media || []).map((m) => ( + sortedFiles.length > 0 ? ( + renderGrouped(sortedFiles, (msg, idx) => ( +
+ {(msg.media || []).map((m) => ( - )) - )} -
+ ))} +
+ )) ) : (

{t('sharedFiles')}

) ) : ( - sharedLinks.length > 0 ? ( -
- {sharedLinks.map((msg) => ( -
+ sortedLinks.length > 0 ? ( + renderGrouped(sortedLinks, (msg, idx) => ( +
{msg.links?.map((link, i) => ( {msg.content}

}
- ))} -
+ )) ) : (

{t('sharedLinks')}

) )} +
-
+ ) : loadedTabs.size === 3 ? ( +
+ +

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

+
+ ) : ( +
+ +
+ )} {/* Members */}
@@ -700,10 +761,7 @@ export default function GroupSettings({ chat, onClose }: GroupSettingsProps) { {lightboxIndex !== null && ( (msg.media || []).map(m => ({ - url: getMediaUrl(m.url), - type: m.type - })))} + images={sortedMedia.map((m) => ({ url: getMediaUrl(m.url), type: m.type }))} initialIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} /> diff --git a/apps/web/src/components/LinkPreview.tsx b/apps/web/src/components/LinkPreview.tsx new file mode 100644 index 0000000..eb3d631 --- /dev/null +++ b/apps/web/src/components/LinkPreview.tsx @@ -0,0 +1,99 @@ +import { useState, useEffect } from 'react'; + +interface LinkPreviewProps { + url: string; +} + +interface MicrolinkData { + publisher?: string; + title?: string; + description?: string; + image?: { url: string }; + logo?: { url: string }; +} + +export default function LinkPreview({ url }: LinkPreviewProps) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let isMounted = true; + setLoading(true); + + // Check cache first to avoid rate limiting + const cacheKey = `link_preview_${url}`; + const cached = sessionStorage.getItem(cacheKey); + if (cached) { + try { + const parsed = JSON.parse(cached); + if (isMounted) { + setData(parsed); + setLoading(false); + } + return; + } catch (e) {} + } + + fetch(`https://api.microlink.io?url=${encodeURIComponent(url)}`) + .then(res => res.json()) + .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); + if (isMounted) setLoading(false); + }); + + return () => { + isMounted = false; + }; + }, [url]); + + if (loading) { + return ( +
+ Загрузка предпросмотра... +
+ ); + } + + if (!data || (!data.title && !data.description && !data.image)) { + return null; + } + + // Domain for publisher fallback + let domain = data.publisher; + if (!domain) { + try { + domain = new URL(url).hostname.replace(/^www\./, ''); + } catch (e) {} + } + + return ( +
e.stopPropagation()} + > +
+
+ {data.logo?.url && } + {domain} +
+ {data.title &&
{data.title}
} + {data.description &&
{data.description}
} +
+ {data.image?.url && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/web/src/components/MessageBubble.tsx b/apps/web/src/components/MessageBubble.tsx index f701c59..4df6815 100644 --- a/apps/web/src/components/MessageBubble.tsx +++ b/apps/web/src/components/MessageBubble.tsx @@ -27,6 +27,7 @@ import { useLang } from '../lib/i18n'; import { extractWaveform, getMediaUrl } from '../lib/utils'; import type { Message, MediaItem, Reaction, ChatMember } from '../lib/types'; import ImageLightbox from './ImageLightbox'; +import LinkPreview from './LinkPreview'; interface MessageBubbleProps { message: Message; @@ -303,11 +304,28 @@ function MessageBubble({ const senderName = message.sender?.displayName || message.sender?.username || ''; const senderAvatar = message.sender?.avatar; + const firstUrlMatch = message.content?.match(/https?:\/\/[^\s]+/); + const firstUrl = firstUrlMatch ? firstUrlMatch[0] : null; + const renderFormattedText = (text: string) => { if (!text) return text; - const parts = text.split(/(\*\*[\s\S]*?\*\*|\*[\s\S]*?\*|_[\s\S]*?_|~[\s\S]*?~|`[\s\S]*?`|@\w+)/g); + const parts = text.split(/(\*\*[\s\S]*?\*\*|\*[\s\S]*?\*|_[\s\S]*?_|~[\s\S]*?~|`[\s\S]*?`|@\w+|https?:\/\/[^\s]+)/g); return parts.map((part, i) => { + if (part.match(/^https?:\/\/[^\s]+$/)) { + return ( + e.stopPropagation()} + > + {part} + + ); + } if (part.startsWith('**') && part.endsWith('**')) return {part.slice(2, -2)}; if (part.startsWith('_') && part.endsWith('_')) return {part.slice(1, -1)}; if (part.startsWith('*') && part.endsWith('*')) return {part.slice(1, -1)}; @@ -457,7 +475,7 @@ function MessageBubble({ 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 + 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' @@ -473,7 +491,7 @@ function MessageBubble({
{t('forwardedFrom')}
-
+
{message.forwardedFrom.displayName || message.forwardedFrom.username}
@@ -481,7 +499,12 @@ function MessageBubble({ {/* Изображения и Видео (Галерея) */} {(hasImage || hasVideo) && ( -
+
m.type === 'image' || m.type === 'video').length >= 3 ? 'grid-cols-3' : media.filter(m => m.type === 'image' || m.type === 'video').length === 2 @@ -669,11 +692,18 @@ function MessageBubble({ {/* Текст */} {message.content && ( -
-

- {renderFormattedText(message.content)} -

- +
+

+ {renderFormattedText(message.content)} +

+ {firstUrl && !hasImage && !hasVideo && !hasFile && ( +
+ +
+ )} +
+ {message.isEdited && {t('edited')}} {message.scheduledAt && } diff --git a/apps/web/src/components/UserProfile.tsx b/apps/web/src/components/UserProfile.tsx index acecd15..f1815fe 100644 --- a/apps/web/src/components/UserProfile.tsx +++ b/apps/web/src/components/UserProfile.tsx @@ -68,9 +68,49 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is const allMedia = sharedMedia.flatMap(msg => (msg.media || []).map(m => ({ ...m, url: getMediaUrl(m.url), - messageId: msg.id + 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 sortedFiles = [...sharedFiles].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + const sortedLinks = [...sharedLinks].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + const renderGrouped = ( + sortedItems: T[], + renderItem: (item: T, originalIndex: number) => React.ReactNode, + gridClass?: string + ) => { + let currentGroup: { dateStr: string; items: {item: T, idx: number}[] } | null = null; + const groups: { dateStr: string; items: {item: T, idx: number}[] }[] = []; + + sortedItems.forEach((item, idx) => { + const date = new Date(item.createdAt); + const dateStr = date.toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'long', year: 'numeric' }); + if (currentGroup?.dateStr !== dateStr) { + currentGroup = { dateStr, items: [] }; + groups.push(currentGroup); + } + currentGroup.items.push({item, idx}); + }); + + return ( +
+ {groups.map((g, i) => ( +
+
+ {g.dateStr} +
+
+ {g.items.map(({item, idx}) => renderItem(item, idx))} +
+
+ ))} +
+ ); + }; + useEffect(() => { loadProfile(); if (!isSelf) { @@ -101,8 +141,12 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is }, [chatId, userId, loadedTabs]); useEffect(() => { - loadTabData(activeTab); - }, [activeTab, loadTabData]); + const tabsToLoad: MediaTab[] = ['publications']; + if (chatId) { + tabsToLoad.push('media', 'files', 'links'); + } + tabsToLoad.forEach(tab => loadTabData(tab)); + }, [chatId, loadTabData]); const loadProfile = async () => { try { @@ -252,13 +296,23 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is .slice(0, 2) .toUpperCase(); - const tabs: { key: MediaTab; label: string; icon: React.ElementType }[] = [ - { key: 'publications', label: (t('publicationsTab') || 'Публикации') as string, icon: Play }, - { key: 'media', label: t('mediaTab') as string, icon: ImageIcon }, - { key: 'files', label: t('filesTab') as string, icon: FileText }, - { key: 'links', label: t('linksTab') as string, icon: LinkIcon }, + const tabsConfig = [ + { key: 'publications' as const, label: t('publicationsTab') || 'Публикации', icon: Play, count: sortedStories.length }, + ...(chatId ? [ + { 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 }, + ] : []), ]; + const availableTabs = tabsConfig.filter(tab => !loadedTabs.has(tab.key) || tab.count > 0); + + useEffect(() => { + if (loadedTabs.size === tabsConfig.length && availableTabs.length > 0 && !availableTabs.find(t => t.key === activeTab)) { + setActiveTab(availableTabs[0].key as any); + } + }, [loadedTabs, activeTab, tabsConfig.length]); + return ( <> - {profile.isOnline && ( -
-
-
-
- )} - {isSelf && ( -
+
{/* Медиа / Файлы / Ссылки */} -
-
- {[ - { key: 'publications' as const, label: t('publicationsTab') || 'Публикации', icon: Play }, - { key: 'media' as const, label: t('mediaTab'), icon: ImageIcon }, - { key: 'files' as const, label: t('filesTab'), icon: FileText }, - { key: 'links' as const, label: t('linksTab'), icon: LinkIcon }, - ].map((tab) => ( - - ))} -
+ {availableTabs.length > 0 ? ( +
+
+ {availableTabs.map((tab) => ( + + ))} +
-
- {tabLoading ? ( -
-
-
- ) : activeTab === 'publications' ? ( - userStories.length > 0 ? ( -
- {userStories.map((s, idx) => ( +
+ {tabLoading ? ( +
+
+
+ ) : activeTab === 'publications' ? ( + sortedStories.length > 0 ? ( + renderGrouped(sortedStories, (s, idx) => (
{ @@ -605,17 +650,15 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is {s.viewCount}
- ))} -
+ ), "grid grid-cols-3 gap-0.5 px-1") ) : (

Нет публикаций

) ) : activeTab === 'media' ? ( - allMedia.length > 0 ? ( -
- {allMedia.map((m, idx) => ( + sortedMedia.length > 0 ? ( + renderGrouped(sortedMedia, (m, idx) => (
- ))} -
+ ), "grid grid-cols-3 gap-0.5 px-1") ) : (

{t('sharedPhotos') as string}

) ) : activeTab === 'files' ? ( - sharedFiles.length > 0 ? ( -
- {sharedFiles.flatMap((msg) => - (msg.media || []).map((m) => ( + sortedFiles.length > 0 ? ( + renderGrouped(sortedFiles, (msg, idx) => ( +
+ {(msg.media || []).map((m) => ( - )) - )} -
+ ))} +
+ )) ) : (

{t('sharedFiles') as string}

) ) : ( - sharedLinks.length > 0 ? ( -
+ ) : loadedTabs.size === tabsConfig.length ? ( +
+ +

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

+
+ ) : ( +
+
+
+ )}
) : (
@@ -754,7 +804,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is {lightboxIndex !== null && ( ({ url: getMediaUrl(m.url), type: m.type }))} + images={sortedMedia.map((m) => ({ url: getMediaUrl(m.url), type: m.type }))} initialIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} /> @@ -770,7 +820,7 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is displayName: profile.displayName, avatar: profile.avatar }, - stories: userStories, + stories: sortedStories, hasUnviewed: false }]} initialUserIndex={0} diff --git a/apps/web/tsconfig.tsbuildinfo b/apps/web/tsconfig.tsbuildinfo index 723f7f8..f4312e0 100644 --- a/apps/web/tsconfig.tsbuildinfo +++ b/apps/web/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/avatar.tsx","./src/components/callmodal.tsx","./src/components/chatlistitem.tsx","./src/components/chatview.tsx","./src/components/confirmmodal.tsx","./src/components/datepicker.tsx","./src/components/emojipicker.tsx","./src/components/forwardmodal.tsx","./src/components/groupcallmodal.tsx","./src/components/groupsettings.tsx","./src/components/imagelightbox.tsx","./src/components/messagebubble.tsx","./src/components/messageinput.tsx","./src/components/newchatmodal.tsx","./src/components/notificationprovider.tsx","./src/components/sidemenu.tsx","./src/components/sidebar.tsx","./src/components/storyviewer.tsx","./src/components/typingindicator.tsx","./src/components/userprofile.tsx","./src/lib/api.ts","./src/lib/hooks.ts","./src/lib/i18n.ts","./src/lib/imagecrop.ts","./src/lib/socket.ts","./src/lib/sounds.ts","./src/lib/types.ts","./src/lib/utils.ts","./src/pages/authpage.tsx","./src/pages/chatpage.tsx","./src/stores/authstore.ts","./src/stores/chatstore.ts","./src/stores/notificationstore.ts","./src/stores/themestore.ts","./src/stores/usestorystore.ts"],"version":"5.9.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/avatar.tsx","./src/components/callmodal.tsx","./src/components/chatlistitem.tsx","./src/components/chatview.tsx","./src/components/confirmmodal.tsx","./src/components/datepicker.tsx","./src/components/emojipicker.tsx","./src/components/forwardmodal.tsx","./src/components/groupcallmodal.tsx","./src/components/groupsettings.tsx","./src/components/imagelightbox.tsx","./src/components/linkpreview.tsx","./src/components/messagebubble.tsx","./src/components/messageinput.tsx","./src/components/newchatmodal.tsx","./src/components/notificationprovider.tsx","./src/components/sidemenu.tsx","./src/components/sidebar.tsx","./src/components/storyviewer.tsx","./src/components/typingindicator.tsx","./src/components/userprofile.tsx","./src/lib/api.ts","./src/lib/hooks.ts","./src/lib/i18n.ts","./src/lib/imagecrop.ts","./src/lib/socket.ts","./src/lib/sounds.ts","./src/lib/types.ts","./src/lib/utils.ts","./src/pages/authpage.tsx","./src/pages/chatpage.tsx","./src/stores/authstore.ts","./src/stores/chatstore.ts","./src/stores/notificationstore.ts","./src/stores/themestore.ts","./src/stores/usestorystore.ts"],"version":"5.9.3"} \ No newline at end of file