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 (
) : (
- {r.text}
+
)
)}
- ) : message.text && {message.text}
}
+ ) : message.text && (
+
+
+
+ )}
);
}
-function MemberItem({ message, isSelected, onSelect }: ChatItemProps) {
+function MemberItem({ message, isSelected, onSelect, onLinkClick }: ChatItemProps) {
return (
) : (
- {r.text}
+
)
)}
- ) : message.text && {message.text}
}
+ ) : message.text && (
+
+
+
+ )}
);
}
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;