mirror of
https://github.com/yusufipk/YTChatHub.git
synced 2026-09-11 10:56:17 +00:00
feat: add URL detection and confirmation modal for chat message links
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user