From 578ca88f6e5ad1aaeb2cd8e3ee77f857bf2689cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Sat, 11 Oct 2025 19:08:41 +0300 Subject: [PATCH] feat: add URL detection and confirmation modal for chat message links --- client/app/dashboard/page.tsx | 113 ++++++++++++++++++++++++++++++++-- client/app/globals.css | 102 ++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+), 6 deletions(-) diff --git a/client/app/dashboard/page.tsx b/client/app/dashboard/page.tsx index 816e86c..5c8a3d2 100644 --- a/client/app/dashboard/page.tsx +++ b/client/app/dashboard/page.tsx @@ -3,6 +3,36 @@ import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import type { ChatMessage } from '@shared/chat'; +// URL regex for detecting links (http/https) +const URL_REGEX = /(https?:\/\/[^\s]+)/gi; + +function parseTextWithLinks(text: string): Array<{ type: 'text' | 'link'; content: string }> { + const parts: Array<{ type: 'text' | 'link'; content: string }> = []; + let lastIndex = 0; + const matches = text.matchAll(URL_REGEX); + + for (const match of matches) { + const url = match[0]; + const index = match.index!; + + // Add text before the URL + if (index > lastIndex) { + parts.push({ type: 'text', content: text.slice(lastIndex, index) }); + } + + // Add the URL + parts.push({ type: 'link', content: url }); + lastIndex = index + url.length; + } + + // Add remaining text + if (lastIndex < text.length) { + parts.push({ type: 'text', content: text.slice(lastIndex) }); + } + + return parts.length > 0 ? parts : [{ type: 'text', content: text }]; +} + const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4100'; const POLL_INTERVAL = 2500; @@ -14,6 +44,7 @@ export default function DashboardPage() { const { messages, refresh, error: pollError } = useChatMessages(); const { selection, status: overlayStatus } = useOverlaySelection(); const { connected, liveId, connect, disconnect, connecting } = useConnection(); + const [confirmUrl, setConfirmUrl] = useState(null); const handleSelect = useCallback( async (message: ChatMessage) => { @@ -59,6 +90,21 @@ export default function DashboardPage() { return 'Live'; }, [pollError, overlayStatus]); + const handleLinkClick = useCallback((url: string) => { + setConfirmUrl(url); + }, []); + + const handleConfirmOpen = useCallback(() => { + if (confirmUrl) { + window.open(confirmUrl, '_blank', 'noopener,noreferrer'); + setConfirmUrl(null); + } + }, [confirmUrl]); + + const handleCancelOpen = useCallback(() => { + setConfirmUrl(null); + }, []); + const superChats = useMemo(() => messages.filter(m => m.superChat), [messages]); const newMembers = useMemo(() => messages.filter(m => m.membershipGift || m.membershipGiftPurchase), [messages]); const regularMessages = useMemo(() => messages.filter(m => !m.superChat && !m.membershipGift && !m.membershipGiftPurchase), [messages]); @@ -129,6 +175,7 @@ export default function DashboardPage() { message={message} isSelected={selection?.id === message.id} onSelect={() => handleSelect(message)} + onLinkClick={handleLinkClick} /> )} emptyState={ @@ -152,6 +199,7 @@ export default function DashboardPage() { message={message} isSelected={selection?.id === message.id} onSelect={() => handleSelect(message)} + onLinkClick={handleLinkClick} /> )} emptyState={ @@ -174,6 +222,7 @@ export default function DashboardPage() { message={message} isSelected={selection?.id === message.id} onSelect={() => handleSelect(message)} + onLinkClick={handleLinkClick} /> )} emptyState={ @@ -187,6 +236,23 @@ export default function DashboardPage() { )} + + {confirmUrl && ( +
+
e.stopPropagation()}> +

Open External Link?

+

{confirmUrl}

+
+ + +
+
+
+ )} ); } @@ -392,8 +458,35 @@ type ChatItemProps = { message: ChatMessage; isSelected: boolean; onSelect: () => void; + onLinkClick: (url: string) => void; }; +function MessageText({ text, onLinkClick }: { text: string; onLinkClick: (url: string) => void }) { + const parts = parseTextWithLinks(text); + return ( + <> + {parts.map((part, i) => + part.type === 'link' ? ( + { + e.preventDefault(); + e.stopPropagation(); + onLinkClick(part.content); + }} + > + {part.content} + + ) : ( + {part.content} + ) + )} + + ); +} + type ChatListPanelProps = { messages: ChatMessage[]; renderItem: (message: ChatMessage) => React.ReactNode; @@ -438,7 +531,7 @@ function ChatListPanel({ messages, renderItem, emptyState }: ChatListPanelProps) ); } -function ChatItem({ message, isSelected, onSelect }: ChatItemProps) { +function ChatItem({ message, isSelected, onSelect, onLinkClick }: ChatItemProps) { return ( ); } -function MemberItem({ message, isSelected, onSelect }: ChatItemProps) { +function MemberItem({ message, isSelected, onSelect, onLinkClick }: ChatItemProps) { return ( ); } diff --git a/client/app/globals.css b/client/app/globals.css index 2ee0f0b..dd07cd5 100644 --- a/client/app/globals.css +++ b/client/app/globals.css @@ -996,6 +996,108 @@ main { display: inline-block; } +/* Message links */ +.message-link { + color: #60a5fa; + text-decoration: underline; + cursor: pointer; + transition: color 0.2s; +} + +.message-link:hover { + color: #93c5fd; +} + +/* Confirmation modal */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + animation: fadeIn 0.2s ease-out; +} + +.modal { + background: #1f1f1f; + border: 1px solid #3a3a3a; + border-radius: 12px; + padding: 2rem; + max-width: 500px; + width: 90%; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + animation: slideUp 0.3s ease-out; +} + +@keyframes slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.modal__title { + margin: 0 0 1rem 0; + font-size: 1.25rem; + font-weight: 600; + color: #fff; +} + +.modal__url { + margin: 0 0 1.5rem 0; + padding: 0.75rem; + background: #0f0f0f; + border: 1px solid #2a2a2a; + border-radius: 6px; + color: #60a5fa; + word-break: break-all; + font-family: monospace; + font-size: 0.9rem; +} + +.modal__actions { + display: flex; + gap: 0.75rem; + justify-content: flex-end; +} + +.modal__btn { + padding: 0.625rem 1.25rem; + border: none; + border-radius: 6px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + font-size: 0.95rem; +} + +.modal__btn--cancel { + background: #2a2a2a; + color: #fff; +} + +.modal__btn--cancel:hover { + background: #3a3a3a; +} + +.modal__btn--confirm { + background: #60a5fa; + color: #0f0f0f; +} + +.modal__btn--confirm:hover { + background: #93c5fd; +} + .overlay__superchat, .overlay__membership { padding: 0.6rem 1rem;