feat: add URL detection and confirmation modal for chat message links

This commit is contained in:
Yusuf İpek
2025-10-11 19:08:41 +03:00
parent 68d2f0f25c
commit 578ca88f6e
2 changed files with 209 additions and 6 deletions
+107 -6
View File
@@ -3,6 +3,36 @@
import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import type { ChatMessage } from '@shared/chat'; 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 BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL ?? 'http://localhost:4100';
const POLL_INTERVAL = 2500; const POLL_INTERVAL = 2500;
@@ -14,6 +44,7 @@ export default function DashboardPage() {
const { messages, refresh, error: pollError } = useChatMessages(); const { messages, refresh, error: pollError } = useChatMessages();
const { selection, status: overlayStatus } = useOverlaySelection(); const { selection, status: overlayStatus } = useOverlaySelection();
const { connected, liveId, connect, disconnect, connecting } = useConnection(); const { connected, liveId, connect, disconnect, connecting } = useConnection();
const [confirmUrl, setConfirmUrl] = useState<string | null>(null);
const handleSelect = useCallback( const handleSelect = useCallback(
async (message: ChatMessage) => { async (message: ChatMessage) => {
@@ -59,6 +90,21 @@ export default function DashboardPage() {
return 'Live'; return 'Live';
}, [pollError, overlayStatus]); }, [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 superChats = useMemo(() => messages.filter(m => m.superChat), [messages]);
const newMembers = useMemo(() => messages.filter(m => m.membershipGift || m.membershipGiftPurchase), [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]); const regularMessages = useMemo(() => messages.filter(m => !m.superChat && !m.membershipGift && !m.membershipGiftPurchase), [messages]);
@@ -129,6 +175,7 @@ export default function DashboardPage() {
message={message} message={message}
isSelected={selection?.id === message.id} isSelected={selection?.id === message.id}
onSelect={() => handleSelect(message)} onSelect={() => handleSelect(message)}
onLinkClick={handleLinkClick}
/> />
)} )}
emptyState={ emptyState={
@@ -152,6 +199,7 @@ export default function DashboardPage() {
message={message} message={message}
isSelected={selection?.id === message.id} isSelected={selection?.id === message.id}
onSelect={() => handleSelect(message)} onSelect={() => handleSelect(message)}
onLinkClick={handleLinkClick}
/> />
)} )}
emptyState={ emptyState={
@@ -174,6 +222,7 @@ export default function DashboardPage() {
message={message} message={message}
isSelected={selection?.id === message.id} isSelected={selection?.id === message.id}
onSelect={() => handleSelect(message)} onSelect={() => handleSelect(message)}
onLinkClick={handleLinkClick}
/> />
)} )}
emptyState={ emptyState={
@@ -187,6 +236,23 @@ export default function DashboardPage() {
</section> </section>
</> </>
)} )}
{confirmUrl && (
<div className="modal-overlay" onClick={handleCancelOpen}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h3 className="modal__title">Open External Link?</h3>
<p className="modal__url">{confirmUrl}</p>
<div className="modal__actions">
<button className="modal__btn modal__btn--cancel" onClick={handleCancelOpen}>
Cancel
</button>
<button className="modal__btn modal__btn--confirm" onClick={handleConfirmOpen}>
Open Link
</button>
</div>
</div>
</div>
)}
</main> </main>
); );
} }
@@ -392,8 +458,35 @@ type ChatItemProps = {
message: ChatMessage; message: ChatMessage;
isSelected: boolean; isSelected: boolean;
onSelect: () => void; 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' ? (
<a
key={i}
href="#"
className="message-link"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onLinkClick(part.content);
}}
>
{part.content}
</a>
) : (
<span key={i}>{part.content}</span>
)
)}
</>
);
}
type ChatListPanelProps = { type ChatListPanelProps = {
messages: ChatMessage[]; messages: ChatMessage[];
renderItem: (message: ChatMessage) => React.ReactNode; 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 ( return (
<button <button
className={isSelected ? 'chatItem chatItem--active' : 'chatItem'} className={isSelected ? 'chatItem chatItem--active' : 'chatItem'}
@@ -480,16 +573,20 @@ function ChatItem({ message, isSelected, onSelect }: ChatItemProps) {
r.emojiUrl ? ( r.emojiUrl ? (
<img key={i} src={r.emojiUrl} alt={r.emojiAlt || 'emoji'} className="chatItem__emoji" /> <img key={i} src={r.emojiUrl} alt={r.emojiAlt || 'emoji'} className="chatItem__emoji" />
) : ( ) : (
<span key={i}>{r.text}</span> <span key={i}><MessageText text={r.text || ''} onLinkClick={onLinkClick} /></span>
) )
)} )}
</p> </p>
) : message.text && <p className="chatItem__text">{message.text}</p>} ) : message.text && (
<p className="chatItem__text">
<MessageText text={message.text} onLinkClick={onLinkClick} />
</p>
)}
</button> </button>
); );
} }
function MemberItem({ message, isSelected, onSelect }: ChatItemProps) { function MemberItem({ message, isSelected, onSelect, onLinkClick }: ChatItemProps) {
return ( return (
<button <button
className={isSelected ? 'memberItem memberItem--active' : 'memberItem'} className={isSelected ? 'memberItem memberItem--active' : 'memberItem'}
@@ -515,11 +612,15 @@ function MemberItem({ message, isSelected, onSelect }: ChatItemProps) {
r.emojiUrl ? ( r.emojiUrl ? (
<img key={i} src={r.emojiUrl} alt={r.emojiAlt || 'emoji'} className="memberItem__emoji" /> <img key={i} src={r.emojiUrl} alt={r.emojiAlt || 'emoji'} className="memberItem__emoji" />
) : ( ) : (
<span key={i}>{r.text}</span> <span key={i}><MessageText text={r.text || ''} onLinkClick={onLinkClick} /></span>
) )
)} )}
</p> </p>
) : message.text && <p className="memberItem__text">{message.text}</p>} ) : message.text && (
<p className="memberItem__text">
<MessageText text={message.text} onLinkClick={onLinkClick} />
</p>
)}
</button> </button>
); );
} }
+102
View File
@@ -996,6 +996,108 @@ main {
display: inline-block; 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__superchat,
.overlay__membership { .overlay__membership {
padding: 0.6rem 1rem; padding: 0.6rem 1rem;