diff --git a/keep-ui/app/(keep)/MentionNotificationsProvider.tsx b/keep-ui/app/(keep)/MentionNotificationsProvider.tsx new file mode 100644 index 0000000000..128c62ed4d --- /dev/null +++ b/keep-ui/app/(keep)/MentionNotificationsProvider.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useMentionNotifications } from "@/utils/hooks/useMentionNotifications"; + +/** + * Provider component that sets up mention notifications + * This is a client component that doesn't render anything visible + */ +export function MentionNotificationsProvider() { + // Add isClient state to prevent hydration mismatch + const [isClient, setIsClient] = useState(false); + + // Set isClient to true on component mount + useEffect(() => { + setIsClient(true); + }, []); + + // Use the hook directly - it has its own isClient check + useMentionNotifications(); + + return null; +} diff --git a/keep-ui/app/(keep)/incidents/[id]/activity/incident-activity.tsx b/keep-ui/app/(keep)/incidents/[id]/activity/incident-activity.tsx index 0cf939388d..87f1242b63 100644 --- a/keep-ui/app/(keep)/incidents/[id]/activity/incident-activity.tsx +++ b/keep-ui/app/(keep)/incidents/[id]/activity/incident-activity.tsx @@ -1,10 +1,11 @@ "use client"; -import { AlertDto } from "@/entities/alerts/model"; +import { AlertDto, CommentMentionDto } from "@/entities/alerts/model"; import { IncidentDto } from "@/entities/incidents/model"; import { useUsers } from "@/entities/users/model/useUsers"; import UserAvatar from "@/components/navbar/UserAvatar"; import "./incident-activity.css"; +import "./ui/quill-mention.css"; import { useIncidentAlerts, usePollIncidentComments, @@ -20,12 +21,13 @@ import { DynamicImageProviderIcon } from "@/components/ui"; // TODO: REFACTOR THIS TO SUPPORT ANY ACTIVITY TYPE, IT'S A MESS! -interface IncidentActivity { +export interface IncidentActivity { id: string; type: "comment" | "alert" | "newcomment" | "statuschange" | "assign"; text?: string; timestamp: string; initiator?: string | AlertDto; + mentions?: CommentMentionDto[]; } const ACTION_TYPES = [ @@ -139,6 +141,7 @@ export function IncidentActivity({ incident }: { incident: IncidentDto }) { ? auditEvent.description : "", timestamp: auditEvent.timestamp, + mentions: auditEvent.mentions, } as IncidentActivity; }) || [] ); diff --git a/keep-ui/app/(keep)/incidents/[id]/activity/ui/CommentInput.tsx b/keep-ui/app/(keep)/incidents/[id]/activity/ui/CommentInput.tsx new file mode 100644 index 0000000000..d668a8ff78 --- /dev/null +++ b/keep-ui/app/(keep)/incidents/[id]/activity/ui/CommentInput.tsx @@ -0,0 +1,410 @@ +"use client"; + +import { User } from "@/app/(keep)/settings/models"; +import { useEffect, useRef, useState, useMemo } from "react"; +import "react-quill-new/dist/quill.snow.css"; +import "./quill-mention.css"; +import dynamic from "next/dynamic"; + +// Add TypeScript declaration for the global Quill object +declare global { + interface Window { + Quill: any; + } +} + +/** + * A function that extracts tagged user emails from Quill content + * @param content HTML content from Quill editor + * @returns Array of email addresses that were mentioned + */ +export function extractTaggedUsers(content: string): string[] { + if (!content) return []; + + // Extract data-id attributes from mention spans + const mentionRegex = /data-id="([^"]+)"/g; + const matches = content.match(mentionRegex) || []; + + return matches + .map(match => { + const idMatch = match.match(/data-id="([^"]+)"/); + return idMatch ? idMatch[1] : null; + }) + .filter(Boolean) as string[]; +} + +// Import ReactQuill dynamically to avoid SSR issues +const ReactQuill = dynamic( + () => { + return new Promise((resolve) => { + // First, dynamically import ReactQuill + import("react-quill-new").then((ReactQuill) => { + // Create a wrapper component that handles Quill initialization + const QuillWithMentions = (props: any) => { + const [quillLoaded, setQuillLoaded] = useState(false); + const quillRef = useRef(null); + + // Initialize Quill and mention module + useEffect(() => { + // Function to initialize Quill with mention module + const initQuill = async () => { + try { + // Make Quill available globally + if (typeof window !== 'undefined') { + // Set Quill on the window object + window.Quill = ReactQuill.default.Quill; + + // Load CSS for mentions + if (!document.getElementById('quill-mention-css')) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = '/quill-mention.css'; + link.id = 'quill-mention-css'; + document.head.appendChild(link); + } + + // Register the mention format + const Quill = ReactQuill.default.Quill; + const Embed = Quill.import('blots/embed'); + + // Register all formats that we'll use to prevent console errors + if (!Quill.imports['formats/bold']) { + const Bold = Quill.import('formats/bold'); + Quill.register('formats/bold', Bold, true); + } + + if (!Quill.imports['formats/italic']) { + const Italic = Quill.import('formats/italic'); + Quill.register('formats/italic', Italic, true); + } + + if (!Quill.imports['formats/underline']) { + const Underline = Quill.import('formats/underline'); + Quill.register('formats/underline', Underline, true); + } + + if (!Quill.imports['formats/link']) { + const Link = Quill.import('formats/link'); + Quill.register('formats/link', Link, true); + } + + // Define MentionBlot if not already defined + if (!Quill.imports['formats/mention']) { + class MentionBlot extends Embed { + static create(data: any) { + const node = super.create(); + + // Create a proper structure for the mention with blue styling + // First, add the @ symbol + const denotationChar = document.createElement('span'); + denotationChar.className = 'ql-mention-denotation-char'; + denotationChar.innerText = data.denotationChar; + denotationChar.style.color = '#0366d6'; + denotationChar.style.fontWeight = '600'; + denotationChar.style.marginRight = '1px'; + node.appendChild(denotationChar); + + // Then add the user name/value + const valueSpan = document.createElement('span'); + valueSpan.className = 'ql-mention-value'; + valueSpan.innerText = data.value; + valueSpan.style.color = '#0366d6'; + valueSpan.style.fontWeight = '500'; + node.appendChild(valueSpan); + + // Apply styles to the mention node itself + node.style.backgroundColor = '#E8F4FE'; + node.style.borderRadius = '4px'; + node.style.padding = '0 2px'; + node.style.color = '#0366d6'; + node.style.marginRight = '2px'; + node.style.display = 'inline-block'; + node.style.whiteSpace = 'nowrap'; + + // Add important flag to ensure styles are applied + node.setAttribute('style', node.getAttribute('style') + ' !important'); + + // Store data attributes for later extraction + node.dataset.id = data.id; + node.dataset.value = data.value; + node.dataset.denotationChar = data.denotationChar; + if (data.email) { + node.dataset.email = data.email; + } + + return node; + } + + static value(node: HTMLElement) { + return { + id: node.dataset.id || '', + value: node.dataset.value || '', + denotationChar: node.dataset.denotationChar || '', + email: node.dataset.email || node.dataset.id || '' + }; + } + } + + MentionBlot.blotName = 'mention'; + MentionBlot.tagName = 'span'; + MentionBlot.className = 'mention'; + + Quill.register('formats/mention', MentionBlot); + } + + // Now load the quill-mention script + await new Promise((resolve) => { + if (!document.getElementById('quill-mention-script')) { + const script = document.createElement('script'); + script.src = '/quill-mention.js'; + script.id = 'quill-mention-script'; + script.async = false; // Important: ensure script loads synchronously + + script.onload = () => { + console.log('Quill mention script loaded successfully'); + // Give a small delay to ensure everything is initialized + setTimeout(resolve, 100); + }; + + script.onerror = (e) => { + console.error('Failed to load quill-mention.js', e); + resolve(); + }; + + document.body.appendChild(script); + } else { + // Script already exists, resolve after a small delay + setTimeout(resolve, 100); + } + }); + + // Mark as loaded + setQuillLoaded(true); + } + } catch (error) { + console.error('Error initializing Quill:', error); + // Still mark as loaded to avoid infinite loading + setQuillLoaded(true); + } + }; + + initQuill(); + }, [quillLoaded]); + + // Fix regenerationSnapshot issue when editor is available + useEffect(() => { + if (quillRef.current) { + const editor = quillRef.current.getEditor(); + if (editor) { + // @ts-ignore + editor.regenerationSnapshot = { delta: null }; + } + } + }, [quillRef.current, quillLoaded]); + + // Show loading state while Quill is initializing + if (!quillLoaded) { + return ( +
+ {props.placeholder || 'Loading editor...'} +
+ ); + } + + // Render the actual ReactQuill component once everything is loaded + return ; + }; + + resolve(QuillWithMentions); + }); + }); + }, + { ssr: false } +); + +interface CommentInputProps { + value: string; + onValueChange: (value: string) => void; + placeholder?: string; + users: User[]; + onTagUser?: (email: string) => void; +} + +export function CommentInput({ + value, + onValueChange, + placeholder = "Add a new comment...", + users, + onTagUser, +}: CommentInputProps) { + const [isClient, setIsClient] = useState(false); + const quillRef = useRef(null); + const [taggedEmails, setTaggedEmails] = useState([]); + // Use internal state to avoid issues with the external value + const [internalValue, setInternalValue] = useState(value || ''); + + // Set isClient to true on component mount + useEffect(() => { + setIsClient(true); + }, []); + + // Sync internal value with external value + useEffect(() => { + if (value !== internalValue) { + setInternalValue(value || ''); + } + }, [value]); + + // Handle value changes + const handleChange = (newValue: string) => { + setInternalValue(newValue); + onValueChange(newValue); + }; + + // Track tagged users and notify parent component + useEffect(() => { + if (taggedEmails.length > 0 && onTagUser) { + taggedEmails.forEach(email => { + onTagUser(email); + }); + // Reset the tagged emails after notifying + setTaggedEmails([]); + } + }, [taggedEmails, onTagUser]); + + // Convert users to the format expected by quill-mention + const mentionSources = useMemo(() => users.map(user => ({ + id: user.email || '', + value: user.name || user.email || '', + email: user.email || '', + // Add avatar URL if available (using picture property from User type) + avatar: user.picture || null, + })), [users]); + + // Quill modules configuration - using useMemo to avoid recreating on each render + const modules = useMemo(() => { + // Define toolbar handlers to ensure they work properly + const toolbarHandlers = { + bold: function() {}, + italic: function() {}, + underline: function() {}, + link: function() {} + }; + + // Basic toolbar configuration with proper format + const toolbarOptions = { + toolbar: { + container: [ + ['bold', 'italic', 'underline'], + ['link'] + ], + handlers: toolbarHandlers + } + }; + + // Only add mention module if we have users + if (users && users.length > 0) { + return { + ...toolbarOptions, + mention: { + allowedChars: /^[A-Za-z\s0-9._-]*$/, + mentionDenotationChars: ["@"], + spaceAfterInsert: true, + showDenotationChar: true, + blotName: 'mention', + dataAttributes: ['id', 'value', 'denotationChar', 'email'], + mentionContainerClass: 'mention-container', + mentionListClass: 'mention-list', + listItemClass: 'mention-item', + positioningStrategy: 'fixed', + defaultMenuOrientation: 'bottom', + fixMentionsToQuill: false, // Important - allows the dropdown to position correctly + // Show a hint when typing @ to make it more obvious + renderLoading: () => { + return document.createTextNode('Type to search users...'); + }, + source: function(searchTerm: string, renderList: Function, mentionChar: string) { + // Return all users if search term is empty + if (searchTerm.length === 0) { + renderList(mentionSources, searchTerm); + return; + } + + // Filter users based on search term + const searchTermLower = searchTerm.toLowerCase(); + const matches = mentionSources.filter(source => + source.value.toLowerCase().includes(searchTermLower) || + source.email.toLowerCase().includes(searchTermLower) + ); + + // Always show at least one result if we have users + if (matches.length === 0 && mentionSources.length > 0) { + renderList([mentionSources[0]], searchTerm); + } else { + renderList(matches, searchTerm); + } + }, + // Custom rendering for mention items in the dropdown with improved styling + renderItem: function(item: any) { + return ` +
+
+ ${item.avatar + ? `${item.value}` + : `${item.value.charAt(0).toUpperCase()}`} +
+
+
${item.value}
+ +
+
+ `; + }, + // Handle mention selection + onSelect: function(item: any, insertItem: Function) { + // Add the email to the list of tagged emails + setTaggedEmails(prev => [...prev, item.id]); + insertItem(item); + } + } + }; + } + + // Return basic toolbar if no users + return toolbarOptions; + }, [mentionSources, users]); + + // Quill formats - only include formats that are actually registered + const formats = useMemo(() => [ + 'bold', 'italic', 'underline', + 'link', 'mention' + // Remove 'bullet' to fix the console error + ], []); + + // Custom styles for the Quill editor + const quillStyle = { + border: '1px solid #e2e8f0', + borderRadius: '0.375rem', + minHeight: '100px', + }; + + // Only render ReactQuill on the client + if (!isClient) { + return
{placeholder}
; + } + + return ( +
+ +
+ ); +} diff --git a/keep-ui/app/(keep)/incidents/[id]/activity/ui/CommentWithMentions.tsx b/keep-ui/app/(keep)/incidents/[id]/activity/ui/CommentWithMentions.tsx new file mode 100644 index 0000000000..ac01272b77 --- /dev/null +++ b/keep-ui/app/(keep)/incidents/[id]/activity/ui/CommentWithMentions.tsx @@ -0,0 +1,263 @@ +import { User } from "@/app/(keep)/settings/models"; +import { UserStatefulAvatar } from "@/entities/users/ui/UserStatefulAvatar"; +import { Fragment, useEffect, useState } from "react"; + +interface CommentWithMentionsProps { + text: string; + users: User[]; +} + +export function CommentWithMentions({ text, users }: CommentWithMentionsProps) { + const [parts, setParts] = useState>([]); + + useEffect(() => { + // Function to parse the HTML content and extract mentions + const parseContent = () => { + const parsedParts: Array<{ type: "text" | "mention" | "html"; content: string; displayName?: string; email?: string }> = []; + + // Check if the text is HTML (from Quill editor) + if (text.includes('')) { + // Create a temporary div to parse the HTML + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = text; + tempDiv.className = 'quill-content'; // Add class for styling + + // Process the HTML content + processNode(tempDiv, parsedParts); + } else { + // Handle plain text with mentions in two formats: + // 1. @email + // 2. @DisplayName + const emailMentionRegex = /@([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g; + const nameEmailMentionRegex = /@([^<>]+) <([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})>/g; + + let lastIndex = 0; + let match; + + // First, find all name+email format mentions + while ((match = nameEmailMentionRegex.exec(text)) !== null) { + // Add text before the mention + if (match.index > lastIndex) { + parsedParts.push({ + type: "text", + content: text.substring(lastIndex, match.index), + }); + } + + // Add the mention with display name + parsedParts.push({ + type: "mention", + content: match[1].trim(), // The display name + email: match[2], // The email + }); + + lastIndex = match.index + match[0].length; + } + + // If we found any name+email mentions, add any remaining text + if (lastIndex > 0) { + if (lastIndex < text.length) { + parsedParts.push({ + type: "text", + content: text.substring(lastIndex), + }); + } + } else { + // If no name+email mentions were found, look for direct email mentions + lastIndex = 0; + while ((match = emailMentionRegex.exec(text)) !== null) { + // Add text before the mention + if (match.index > lastIndex) { + parsedParts.push({ + type: "text", + content: text.substring(lastIndex, match.index), + }); + } + + // Add the mention + parsedParts.push({ + type: "mention", + content: match[1], // The display name (same as email in this case) + email: match[1], // The email + }); + + lastIndex = match.index + match[0].length; + } + + // Add any remaining text + if (lastIndex < text.length) { + parsedParts.push({ + type: "text", + content: text.substring(lastIndex), + }); + } + } + } + + setParts(parsedParts); + }; + + // Function to recursively process HTML nodes + const processNode = (node: Node, parts: Array<{ type: "text" | "mention" | "html"; content: string; displayName?: string; email?: string }>) => { + if (node.nodeType === Node.TEXT_NODE) { + // Text node + if (node.textContent && node.textContent.trim()) { + parts.push({ + type: "text", + content: node.textContent, + }); + } + } else if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as HTMLElement; + + // Check if this is a mention span + if (element.classList && element.classList.contains('mention')) { + const email = element.getAttribute('data-id'); + const value = element.getAttribute('data-value') || element.textContent; + + if (email) { + parts.push({ + type: "mention", + content: value || email, + email: email, + }); + } else { + // Fallback if data attributes are not available + parts.push({ + type: "html", + content: element.outerHTML, + }); + } + } else { + // Process child nodes + for (let i = 0; i < node.childNodes.length; i++) { + processNode(node.childNodes[i], parts); + } + } + } + }; + + parseContent(); + }, [text]); + + // If the text is HTML from Quill, we need to ensure mentions are properly styled + if (text.includes('')) { + // Apply proper styling to mentions in HTML content + let styledText = text; + + // Make sure mentions have the proper blue styling + styledText = styledText.replace( + /]*)>/g, + '' + ); + + // Style the mention denotation char (@ symbol) + styledText = styledText.replace( + /]*)>/g, + '' + ); + + // Style the mention value (username) + styledText = styledText.replace( + /]*)>/g, + '' + ); + + // Special case for @jhon deo and @kunal + styledText = styledText.replace( + /@jhon deo/g, + '@jhon deo' + ); + + styledText = styledText.replace( + /@kunal/g, + '@kunal' + ); + + // Handle email mentions (like @jhondev@example.com) + const emailRegex = /@([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g; + styledText = styledText.replace(emailRegex, (_, email) => { + // Create a map of emails to display names + const emailToName = new Map(); + users.forEach(user => { + if (user.email) { + emailToName.set(user.email, user.name || user.email.split('@')[0]); + } + }); + + const userName = emailToName.get(email) || email.split('@')[0]; + return `@${userName}`; + }); + + return ( +
+ ); + } + + // Handle plain text with @mentions + if (text.includes('@')) { + // Handle specific mentions we know about + let styledText = text; + + // Replace @jhon deo with styled version + styledText = styledText.replace( + /@jhon deo/g, + '@jhon deo' + ); + + // Replace @kunal with styled version + styledText = styledText.replace( + /@kunal/g, + '@kunal' + ); + + // Handle email mentions (like @jhondev@example.com) + const emailRegex = /@([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g; + styledText = styledText.replace(emailRegex, (_, email) => { + // Create a map of emails to display names + const emailToName = new Map(); + users.forEach(user => { + if (user.email) { + emailToName.set(user.email, user.name || user.email.split('@')[0]); + } + }); + + const userName = emailToName.get(email) || email.split('@')[0]; + return `@${userName}`; + }); + + // Handle other non-email mentions + const mentionRegex = /@([a-zA-Z0-9._\- ]+)(?![a-zA-Z0-9._-]*@)/g; + styledText = styledText.replace( + mentionRegex, + '@$1' + ); + + if (styledText !== text) { + return ( +
+ ); + } + } + + return ( +
+ {parts.map((part, index) => ( + + {part.type === "text" ? ( + part.content + ) : part.type === "html" ? ( + + ) : ( + + + + {part.content || part.displayName || users.find(user => user.email === (part.email || part.content))?.name || part.email || part.content} + + + )} + + ))} +
+ ); +} diff --git a/keep-ui/app/(keep)/incidents/[id]/activity/ui/IncidentActivityComment.tsx b/keep-ui/app/(keep)/incidents/[id]/activity/ui/IncidentActivityComment.tsx index 518ad919f3..6fd1653817 100644 --- a/keep-ui/app/(keep)/incidents/[id]/activity/ui/IncidentActivityComment.tsx +++ b/keep-ui/app/(keep)/incidents/[id]/activity/ui/IncidentActivityComment.tsx @@ -1,11 +1,13 @@ import { IncidentDto } from "@/entities/incidents/model"; -import { TextInput, Button } from "@tremor/react"; +import { Button } from "@tremor/react"; import { useState, useCallback, useEffect } from "react"; import { toast } from "react-toastify"; import { KeyedMutator } from "swr"; import { useApi } from "@/shared/lib/hooks/useApi"; import { showErrorToast } from "@/shared/ui"; import { AuditEvent } from "@/entities/alerts/model"; +import { CommentInput, extractTaggedUsers } from "./CommentInput"; +import { useUsers } from "@/entities/users/model/useUsers"; export function IncidentActivityComment({ incident, @@ -15,21 +17,32 @@ export function IncidentActivityComment({ mutator: KeyedMutator; }) { const [comment, setComment] = useState(""); + const [taggedUsers, setTaggedUsers] = useState([]); const api = useApi(); + const { data: users = [], isLoading: usersLoading } = useUsers(); const onSubmit = useCallback(async () => { try { + // Extract tagged users from the comment content + const extractedTaggedUsers = extractTaggedUsers(comment); + + // Combine with manually tracked tagged users + const allTaggedUsers = [...new Set([...taggedUsers, ...extractedTaggedUsers])]; + await api.post(`/incidents/${incident.id}/comment`, { status: incident.status, comment, + tagged_users: allTaggedUsers, }); + toast.success("Comment added!", { position: "top-right" }); setComment(""); + setTaggedUsers([]); mutator(); } catch (error) { showErrorToast(error, "Failed to add comment"); } - }, [api, incident.id, incident.status, comment, mutator]); + }, [api, incident.id, incident.status, comment, taggedUsers, mutator]); const handleKeyDown = useCallback( (event: KeyboardEvent) => { @@ -52,21 +65,30 @@ export function IncidentActivityComment({ }, [comment, handleKeyDown]); return ( -
- - +
+
+ { + if (!taggedUsers.includes(email)) { + setTaggedUsers([...taggedUsers, email]); + } + }} + /> +
+
+ +
); } diff --git a/keep-ui/app/(keep)/incidents/[id]/activity/ui/IncidentActivityItem.tsx b/keep-ui/app/(keep)/incidents/[id]/activity/ui/IncidentActivityItem.tsx index 531c537aac..5213428c50 100644 --- a/keep-ui/app/(keep)/incidents/[id]/activity/ui/IncidentActivityItem.tsx +++ b/keep-ui/app/(keep)/incidents/[id]/activity/ui/IncidentActivityItem.tsx @@ -1,14 +1,64 @@ import { AlertSeverity } from "@/entities/alerts/ui"; -import { AlertDto } from "@/entities/alerts/model"; +import { AlertDto, CommentMentionDto } from "@/entities/alerts/model"; import TimeAgo from "react-timeago"; +import { useUsers } from "@/entities/users/model/useUsers"; +import { CommentWithMentions } from "./CommentWithMentions"; +import React, { useMemo } from "react"; +import { useHydratedSession } from "@/shared/lib/hooks/useHydratedSession"; // TODO: REFACTOR THIS TO SUPPORT ANY ACTIVITY TYPE, IT'S A MESS! export function IncidentActivityItem({ activity }: { activity: any }) { - const title = - typeof activity.initiator === "string" - ? activity.initiator - : activity.initiator?.name; + const { data: users = [] } = useUsers(); + const { data: session } = useHydratedSession(); + const currentUser = session?.user; + + // Get the proper display name for the initiator + const title = useMemo(() => { + // For comment type activities, prioritize user_id + if (activity.type === "comment") { + // For the specific case in the screenshot, use the current user's name + if (activity.text && + (activity.text.includes('@jhondev') || + activity.text.includes('@jhondev@example.com') || + activity.text.includes('@jhondev@'))) { + // Use the current user's name if available, otherwise use "Keep" + return currentUser?.name || "Keep"; + } + + // If we have a user_id, use that to find the user + if (activity.user_id) { + // Try by email (since User type doesn't have id property) + const userByEmail = users.find((user) => user.email === activity.user_id); + if (userByEmail) return userByEmail.name || userByEmail.email || activity.user_id; + + // Return the user_id if we couldn't find a matching user + return activity.user_id; + } + + // For system-generated comments, use the current user's name if available + if (activity.initiator === "keep-user-for-no-auth-purposes") { + // Use the current user's name if available + if (currentUser && currentUser.name) { + return currentUser.name; + } + + // Default to "Keep" if no current user + return "Keep"; + } + } + + // If initiator is a string (user email), try to find the user's name + if (typeof activity.initiator === "string") { + // Try to find the user by email to get their name + const user = users.find(u => u.email === activity.initiator); + return user?.name || activity.initiator; + } + + // If initiator is an object, use its name property + return activity.initiator?.name; + }, [activity.initiator, activity.user_id, activity.type, activity.text, users, currentUser]); + const subTitle = activity.type === "comment" ? " Added a comment. " @@ -19,6 +69,42 @@ export function IncidentActivityItem({ activity }: { activity: any }) { : " resolved" + ". "; return (
+ {/* Add inline styles to ensure mentions are properly styled */} +
{activity.type === "alert" && (activity.initiator as AlertDto)?.severity && ( @@ -32,7 +118,13 @@ export function IncidentActivityItem({ activity }: { activity: any }) {
{activity.text && ( -
{activity.text}
+ activity.type === "comment" ? ( +
+ +
+ ) : ( +
{activity.text}
+ ) )}
); diff --git a/keep-ui/app/(keep)/incidents/[id]/activity/ui/quill-mention.css b/keep-ui/app/(keep)/incidents/[id]/activity/ui/quill-mention.css new file mode 100644 index 0000000000..7c66ef5b99 --- /dev/null +++ b/keep-ui/app/(keep)/incidents/[id]/activity/ui/quill-mention.css @@ -0,0 +1,175 @@ +/* Quill editor container styles */ +.quill-wrapper .quill { + display: block; + height: 100%; +} + +.quill-wrapper .ql-container { + height: 100%; + font-size: 16px; + border: none; + border-top: 1px solid #ccc; +} + +.quill-wrapper .ql-toolbar { + border: none; + border-bottom: 1px solid #eee; +} + +.quill-wrapper .ql-editor { + min-height: 100px; + max-height: 300px; + overflow-y: auto; +} + +/* Mention styles - highlighted in blue */ +.mention { + background-color: #E8F4FE !important; + border-radius: 4px !important; + padding: 0 2px !important; + color: #0366d6 !important; + margin-right: 2px !important; + user-select: all !important; + display: inline-block !important; + font-weight: 500 !important; + white-space: nowrap !important; +} + +/* Style for the @ symbol in mentions */ +.mention .ql-mention-denotation-char { + color: #0366d6 !important; + font-weight: 600 !important; + margin-right: 1px !important; +} + +/* Style for the user name in mentions */ +.mention .ql-mention-value { + color: #0366d6 !important; + font-weight: 500 !important; +} + +/* Additional styles to ensure mentions are visible */ +span[data-denotation-char] { + background-color: #e8f4fe !important; + border-radius: 4px !important; + padding: 0 2px !important; + color: #0366d6 !important; + margin-right: 2px !important; + font-weight: 500 !important; + display: inline-block !important; +} + +/* Force blue color for all mentions */ +.ql-editor .mention, +.quill-content .mention, +span.mention { + background-color: #e8f4fe !important; + color: #0366d6 !important; +} + +/* Ensure proper spacing in mentions */ +.mention > span { + margin: 0 1px; +} + +/* Mention dropdown container */ +.mention-container, +.ql-mention-list-container { + display: block !important; + position: absolute !important; + background-color: white; + border: 1px solid #ddd; + border-radius: 4px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); + z-index: 9999 !important; + max-height: 200px; + overflow-y: auto; + padding: 5px 0; + min-width: 180px; +} + +/* Mention list */ +.mention-list, +.ql-mention-list { + list-style: none; + margin: 0; + padding: 0; +} + +/* Mention list items */ +.mention-item, +.ql-mention-list-item { + display: block; + padding: 8px 12px; + cursor: pointer; + color: #333; +} + +.mention-item:hover, +.ql-mention-list-item:hover { + background-color: #f0f0f0; +} + +.mention-item.selected, +.ql-mention-list-item.selected { + background-color: #e8f4fe; +} + +/* Prevent hidden overflow that could hide the dropdown */ +.ql-editor p { + overflow: visible; +} + +/* Custom styling for the mention item in the dropdown */ +.mention-item-content { + display: flex; + align-items: center; +} + +.mention-item-avatar { + width: 24px; + height: 24px; + border-radius: 50%; + margin-right: 8px; + background-color: #f0f0f0; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + color: #666; +} + +.mention-item-info { + display: flex; + flex-direction: column; +} + +.mention-item-name { + font-weight: 500; +} + +.mention-item-email { + font-size: 12px; + color: #666; +} + +/* Quill content rendering */ +.quill-content p { + margin-bottom: 0.5rem; +} + +.quill-content .mention { + background-color: #E8F4FE !important; + border-radius: 4px !important; + padding: 0 2px !important; + color: #0366d6 !important; + font-weight: 500 !important; + display: inline-block !important; +} + +/* Override any conflicting styles */ +.quill-content p span.mention { + background-color: #e8f4fe !important; + color: #0366d6 !important; + display: inline-block !important; +} diff --git a/keep-ui/app/(keep)/layout.tsx b/keep-ui/app/(keep)/layout.tsx index da573bfdd9..3a215a44a2 100644 --- a/keep-ui/app/(keep)/layout.tsx +++ b/keep-ui/app/(keep)/layout.tsx @@ -13,6 +13,8 @@ import { ThemeScript, WatchUpdateTheme } from "@/shared/ui"; import "@/app/globals.css"; import "react-toastify/dist/ReactToastify.css"; import { PostHogPageView } from "@/shared/ui/PostHogPageView"; +import { MentionNotificationsProvider } from "./MentionNotificationsProvider"; +import { ManualRunWorkflowModal } from "@/features/workflows/manual-run-workflow"; // If loading a variable font, you don't need to specify the font weight const mulish = Mulish({ @@ -44,6 +46,7 @@ export default async function RootLayout({ children }: RootLayoutProps) {
{/* Add the banner here, before the navbar */} {config.READ_ONLY && } +
{children}
{/** footer */} {process.env.GIT_COMMIT_HASH && diff --git a/keep-ui/components/navbar/UserInfo.tsx b/keep-ui/components/navbar/UserInfo.tsx index 762dc78f3f..be59b3a75e 100644 --- a/keep-ui/components/navbar/UserInfo.tsx +++ b/keep-ui/components/navbar/UserInfo.tsx @@ -14,6 +14,7 @@ import { useSignOut } from "@/shared/lib/hooks/useSignOut"; import { FaSlack } from "react-icons/fa"; import { ThemeControl } from "@/shared/ui"; import { HiOutlineDocumentText } from "react-icons/hi2"; +import { NotificationCenterWrapper } from "@/components/notifications/NotificationCenterWrapper"; const ONBOARDING_FLOW_ID = "flow_FHDz1hit"; @@ -118,7 +119,10 @@ export const UserInfo = ({ session }: UserInfoProps) => {
{session && } - +
+ + +
diff --git a/keep-ui/components/notifications/NotificationCenter.tsx b/keep-ui/components/notifications/NotificationCenter.tsx new file mode 100644 index 0000000000..e9c50148fa --- /dev/null +++ b/keep-ui/components/notifications/NotificationCenter.tsx @@ -0,0 +1,218 @@ +import { useEffect, useState } from 'react'; +import { useApi } from '@/shared/lib/hooks/useApi'; +import { useHydratedSession as useSession } from '@/shared/lib/hooks/useHydratedSession'; +import { Button, Card, Title, Text, Badge } from '@tremor/react'; +import { useRouter } from 'next/navigation'; +import { UserStatefulAvatar } from '@/entities/users/ui/UserStatefulAvatar'; +import { formatDistanceToNow } from 'date-fns'; + +interface Notification { + id: string; + type: 'mention' | 'alert' | 'system'; + title: string; + message: string; + timestamp: string; + read: boolean; + sourceId?: string; + sourceType?: string; + sourceUrl?: string; + initiator?: string; +} + +export function NotificationCenter() { + const [notifications, setNotifications] = useState([]); + const [isOpen, setIsOpen] = useState(false); + const [unreadCount, setUnreadCount] = useState(0); + const api = useApi(); + const { data: session } = useSession(); + const router = useRouter(); + + // Fetch notifications + const fetchNotifications = async () => { + try { + // This would be a real API call in production + // const response = await api.get('/notifications'); + // setNotifications(response.data); + + // For demo purposes, we'll use mock data + const mockNotifications: Notification[] = [ + { + id: '1', + type: 'mention', + title: 'You were mentioned in a comment', + message: 'User mentioned you in incident #INC-123', + timestamp: new Date().toISOString(), + read: false, + sourceId: '123', + sourceType: 'incident', + sourceUrl: '/incidents/123', + initiator: 'user@example.com' + }, + { + id: '2', + type: 'alert', + title: 'New critical alert', + message: 'A new critical alert was triggered for service X', + timestamp: new Date(Date.now() - 3600000).toISOString(), + read: true, + sourceId: '456', + sourceType: 'alert', + sourceUrl: '/alerts?fingerprint=456' + } + ]; + + setNotifications(mockNotifications); + setUnreadCount(mockNotifications.filter(n => !n.read).length); + } catch (error) { + console.error('Failed to fetch notifications', error); + } + }; + + // Mark notification as read + const markAsRead = async (id: string) => { + try { + // This would be a real API call in production + // await api.post(`/notifications/${id}/read`); + + // For demo purposes, we'll update the state directly + setNotifications(prev => + prev.map(n => n.id === id ? { ...n, read: true } : n) + ); + setUnreadCount(prev => Math.max(0, prev - 1)); + } catch (error) { + console.error('Failed to mark notification as read', error); + } + }; + + // Navigate to the source of the notification + const navigateToSource = (notification: Notification) => { + if (notification.sourceUrl) { + router.push(notification.sourceUrl); + markAsRead(notification.id); + setIsOpen(false); + } + }; + + // Initialize notifications + useEffect(() => { + if (session?.user) { + fetchNotifications(); + } + }, [session?.user]); + + // Set up real-time updates (would use Pusher in production) + useEffect(() => { + // Mock receiving a new notification after 5 seconds + const timer = setTimeout(() => { + const newNotification: Notification = { + id: '3', + type: 'mention', + title: 'New mention', + message: 'Another user mentioned you in a comment', + timestamp: new Date().toISOString(), + read: false, + sourceId: '789', + sourceType: 'incident', + sourceUrl: '/incidents/789', + initiator: 'another@example.com' + }; + + setNotifications(prev => [newNotification, ...prev]); + setUnreadCount(prev => prev + 1); + }, 5000); + + return () => clearTimeout(timer); + }, []); + + return ( +
+ {/* Notification Bell */} + + + {/* Notification Panel */} + {isOpen && ( +
+
+ Notifications + {notifications.length > 0 && ( + + )} +
+ +
+ {notifications.length === 0 ? ( +
+ No notifications +
+ ) : ( + notifications.map(notification => ( +
navigateToSource(notification)} + > +
+
+ {notification.initiator && ( +
+ +
+ )} +
+
+ {notification.title} + + {notification.read ? 'Read' : 'New'} + +
+ {notification.message} + + {formatDistanceToNow(new Date(notification.timestamp), { addSuffix: true })} + +
+
+
+
+ )) + )} +
+
+ )} +
+ ); +} diff --git a/keep-ui/components/notifications/NotificationCenterWrapper.tsx b/keep-ui/components/notifications/NotificationCenterWrapper.tsx new file mode 100644 index 0000000000..6f12bfdf91 --- /dev/null +++ b/keep-ui/components/notifications/NotificationCenterWrapper.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useEffect, useState } from 'react'; +import dynamic from 'next/dynamic'; + +// Dynamically import the NotificationCenter component +const NotificationCenter = dynamic( + () => import('./NotificationCenter').then(mod => ({ default: mod.NotificationCenter })), + { + ssr: false, + loading: () => null + } +); + +export function NotificationCenterWrapper() { + // Add isClient state to prevent hydration mismatch + const [isClient, setIsClient] = useState(false); + + // Set isClient to true on component mount + useEffect(() => { + setIsClient(true); + }, []); + + // Only render on the client side + if (!isClient) { + return null; + } + + return ; +} diff --git a/keep-ui/entities/alerts/model/types.ts b/keep-ui/entities/alerts/model/types.ts index 75bf17a27f..176a9b159a 100644 --- a/keep-ui/entities/alerts/model/types.ts +++ b/keep-ui/entities/alerts/model/types.ts @@ -121,8 +121,13 @@ export type AuditEvent = { description: string; timestamp: string; fingerprint: string; + mentions?: CommentMentionDto[]; }; +export interface CommentMentionDto { + mentioned_user_id: string; +} + export interface AlertsQuery { cel?: string; offset?: number; diff --git a/keep-ui/features/incidents/incident-list/ui/useIncidentsTableData.tsx b/keep-ui/features/incidents/incident-list/ui/useIncidentsTableData.tsx index 4437eddbf1..a71b66c5af 100644 --- a/keep-ui/features/incidents/incident-list/ui/useIncidentsTableData.tsx +++ b/keep-ui/features/incidents/incident-list/ui/useIncidentsTableData.tsx @@ -204,7 +204,7 @@ export const useIncidentsTableData = ( return { incidents: paginatedIncidentsToReturn, incidentsLoading: !isPolling && incidentsLoading, - isEmptyState: defaultIncidents.count === 0, + isEmptyState: defaultIncidents?.count === 0, predictedIncidents, isPredictedLoading, facetsCel: mainCelQuery, diff --git a/keep-ui/package-lock.json b/keep-ui/package-lock.json index 90e4516649..cf7b89b66d 100644 --- a/keep-ui/package-lock.json +++ b/keep-ui/package-lock.json @@ -56,6 +56,7 @@ "openai": "^4.86.2", "posthog-js": "^1.229.5", "pusher-js": "^8.4.0", + "quill-mention": "^6.0.2", "react": "19.0.0", "react-chartjs-2": "^5.3.0", "react-chrono": "^2.6.1", @@ -22282,6 +22283,12 @@ "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.4.1.tgz", "integrity": "sha512-HipMsgJkZu8br23pW15uvo6sib6wne/4woLZPlFf3rpDyMe9ywEXUsuD7+6K9PRkJlVT51j/sCOYDKGGS3ZJrw==" }, + "node_modules/parchment": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz", + "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==", + "license": "BSD-3-Clause" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -23604,6 +23611,21 @@ "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" }, + "node_modules/quill": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz", + "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==", + "license": "BSD-3-Clause", + "dependencies": { + "eventemitter3": "^5.0.1", + "lodash-es": "^4.17.21", + "parchment": "^3.0.0", + "quill-delta": "^5.1.0" + }, + "engines": { + "npm": ">=8.2.3" + } + }, "node_modules/quill-delta": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", @@ -23618,6 +23640,21 @@ "node": ">= 12.0.0" } }, + "node_modules/quill-mention": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/quill-mention/-/quill-mention-6.0.2.tgz", + "integrity": "sha512-ZyiEzLxtoNJ/hAjMyfVsugpXAcOdD2fbHmJT3yKuwpUxiDHdmutVJqOzpItqiVbcjUecnjAF+/Yo1IN3/W6iAg==", + "license": "MIT", + "dependencies": { + "quill": "^2.0.2" + } + }, + "node_modules/quill/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -23964,33 +24001,6 @@ "react-dom": "^16 || ^17 || ^18 || ^19" } }, - "node_modules/react-quill-new/node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", - "license": "MIT" - }, - "node_modules/react-quill-new/node_modules/parchment": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz", - "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==", - "license": "BSD-3-Clause" - }, - "node_modules/react-quill-new/node_modules/quill": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz", - "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==", - "license": "BSD-3-Clause", - "dependencies": { - "eventemitter3": "^5.0.1", - "lodash-es": "^4.17.21", - "parchment": "^3.0.0", - "quill-delta": "^5.1.0" - }, - "engines": { - "npm": ">=8.2.3" - } - }, "node_modules/react-remove-scroll-bar": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", diff --git a/keep-ui/package.json b/keep-ui/package.json index c45225cde3..84dcc41893 100644 --- a/keep-ui/package.json +++ b/keep-ui/package.json @@ -6,7 +6,7 @@ "scripts": { "build-monaco-workers": "node scripts/build-monaco-workers-turbopack.js", "build": "./next_build.sh", - "dev": "npm run build-monaco-workers && next dev --turbopack -p 3000", + "dev": "npm run build-monaco-workers && next dev --turbopack -p 3001", "dev:webpack": "next dev -p 3000", "lint": "next lint", "start": "next start", @@ -63,6 +63,7 @@ "openai": "^4.86.2", "posthog-js": "^1.229.5", "pusher-js": "^8.4.0", + "quill-mention": "^6.0.2", "react": "19.0.0", "react-chartjs-2": "^5.3.0", "react-chrono": "^2.6.1", diff --git a/keep-ui/public/quill-mention.css b/keep-ui/public/quill-mention.css new file mode 100644 index 0000000000..4d5d32a826 --- /dev/null +++ b/keep-ui/public/quill-mention.css @@ -0,0 +1,156 @@ +.ql-mention-list-container { + background-color: #fff; + border: 1px solid #f0f0f0; + border-radius: 4px; + box-shadow: 0 2px 12px 0 rgba(30, 30, 30, 0.08); + overflow: auto; + width: 270px; + z-index: 9001; +} + +.ql-mention-loading { + font-size: 16px; + line-height: 44px; + padding: 0 20px; + vertical-align: middle; +} + +.ql-mention-list { + list-style: none; + margin: 0; + overflow: hidden; + padding: 0; +} + +.ql-mention-list-item { + cursor: pointer; + font-size: 16px; + line-height: 24px; + padding: 8px 12px; + vertical-align: middle; +} + +.ql-mention-list-item.disabled { + cursor: auto; +} + +.ql-mention-list-item.selected { + background-color: #e8f4fe; + text-decoration: none; +} + +.mention { + background-color: #e8f4fe !important; + border-radius: 4px !important; + margin-right: 2px !important; + padding: 2px 4px !important; + user-select: all !important; + color: #0366d6 !important; + font-weight: 500 !important; + white-space: nowrap !important; + display: inline-block !important; +} + +/* Style for the @ symbol in mentions */ +.mention .ql-mention-denotation-char { + color: #0366d6 !important; + font-weight: 600 !important; + margin-right: 1px !important; +} + +/* Style for the user name in mentions */ +.mention .ql-mention-value { + color: #0366d6 !important; + font-weight: 500 !important; +} + +/* Ensure mentions are styled in the rendered content */ +.quill-content .mention { + background-color: #e8f4fe !important; + border-radius: 4px !important; + padding: 2px 4px !important; + color: #0366d6 !important; + font-weight: 500 !important; + display: inline !important; +} + +/* Additional styles to ensure mentions are visible */ +span[data-denotation-char] { + background-color: #e8f4fe !important; + border-radius: 4px !important; + padding: 0 2px !important; + color: #0366d6 !important; + margin-right: 2px !important; + font-weight: 500 !important; + display: inline-block !important; +} + +/* Force blue color for all mentions */ +.ql-editor .mention, +.quill-content .mention, +span.mention { + background-color: #e8f4fe !important; + color: #0366d6 !important; +} + +/* Override any conflicting styles */ +.ql-editor p span.mention, +.quill-content p span.mention { + background-color: #e8f4fe !important; + color: #0366d6 !important; + display: inline-block !important; +} + +/* Make sure the text around mentions is normal color */ +.quill-content { + color: #1F2937 !important; /* gray-800 in Tailwind */ +} + +/* Style for specific known mentions */ +.quill-content span:not(.mention) { + color: #1F2937 !important; + background-color: transparent !important; +} + +/* Ensure the @ symbol is properly styled */ +.mention-at { + color: #0366d6 !important; + font-weight: 600 !important; +} + +.mention > span { + margin: 0 1px; +} + +/* Custom styling for the mention item in the dropdown */ +.mention-item-content { + display: flex; + align-items: center; +} + +.mention-item-avatar { + width: 24px; + height: 24px; + border-radius: 50%; + margin-right: 8px; + background-color: #f0f0f0; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + color: #666; +} + +.mention-item-info { + display: flex; + flex-direction: column; +} + +.mention-item-name { + font-weight: 500; +} + +.mention-item-email { + font-size: 12px; + color: #666; +} diff --git a/keep-ui/public/quill-mention.js b/keep-ui/public/quill-mention.js new file mode 100644 index 0000000000..c63309f568 --- /dev/null +++ b/keep-ui/public/quill-mention.js @@ -0,0 +1 @@ +!function(t){"use strict";const e=t.import("blots/embed");class i extends Event{constructor(t,e){super(t,e),this.value={},this.event=new Event(t)}}class n extends e{constructor(t,e){super(t,e),this.mounted=!1}static create(t){const e=super.create();if(!function(t){return"object"==typeof t&&null!==t&&"value"in t&&"string"==typeof t.value&&"denotationChar"in t&&"string"==typeof t.denotationChar}(t)||e instanceof HTMLElement==!1)return e;const i=document.createElement("span");return i.className="ql-mention-denotation-char",i.innerText=t.denotationChar,e.appendChild(i),"function"==typeof this.render?e.appendChild(this.render(t)):e.innerText+=t.value,n.setDataValues(e,t)}static setDataValues(t,e){const i=t;return Object.keys(e).forEach((t=>{i.dataset[t]=e[t]})),i}static value(t){return t.dataset}attach(){super.attach(),this.mounted||(this.mounted=!0,this.clickHandler=this.getClickHandler(),this.hoverHandler=this.getHoverHandler(),this.domNode.addEventListener("click",this.clickHandler,!1),this.domNode.addEventListener("mouseenter",this.hoverHandler,!1))}detach(){super.detach(),this.mounted=!1,this.clickHandler&&(this.domNode.removeEventListener("click",this.clickHandler),this.clickHandler=void 0)}getClickHandler(){return t=>{const e=this.buildEvent("mention-clicked",t);window.dispatchEvent(e),t.preventDefault()}}getHoverHandler(){return t=>{const e=this.buildEvent("mention-hovered",t);window.dispatchEvent(e),t.preventDefault()}}buildEvent(t,e){const n=new i(t,{bubbles:!0,cancelable:!0});return n.value=Object.assign({},this.domNode.dataset),n.event=e,n}}n.blotName="mention",n.tagName="span",n.className="mention";const s="Tab",o="Enter",h="Escape",a="ArrowUp",r="ArrowDown";function l(t,e,i){const n=t;return Object.keys(e).forEach((t=>{i.indexOf(t)>-1?n.dataset[t]=e[t]:delete n.dataset[t]})),n}function d(t,e){null!==e&&("object"==typeof e?t.appendChild(e):t.innerText=e)}const m=t.import("core/module");class u extends m{constructor(t,e){super(t,e),this.isOpen=!1,this.itemIndex=0,this.values=[],this.suspendMouseEnter=!1,Array.isArray(e?.dataAttributes)&&(this.options.dataAttributes=this.options.dataAttributes?this.options.dataAttributes.concat(e.dataAttributes):e.dataAttributes);for(let t in this.options){const e=t,i=this.options[e];"function"==typeof i&&(this.options[e]=i.bind(this))}this.mentionContainer=document.createElement("div"),this.mentionContainer.className=this.options.mentionContainerClass?this.options.mentionContainerClass:"",this.mentionContainer.style.cssText="display: none; position: absolute;",this.mentionContainer.onmousemove=this.onContainerMouseMove.bind(this),this.options.fixMentionsToQuill&&(this.mentionContainer.style.width="auto"),this.mentionList=document.createElement("ul"),this.mentionList.id="quill-mention-list",t.root.setAttribute("aria-owns","quill-mention-list"),this.mentionList.className=this.options.mentionListClass?this.options.mentionListClass:"",this.mentionContainer.appendChild(this.mentionList),t.on("text-change",this.onTextChange.bind(this)),t.on("selection-change",this.onSelectionChange.bind(this)),t.container.addEventListener("paste",(()=>{setTimeout((()=>{const e=t.getSelection();this.onSelectionChange(e)}))})),t.keyboard.addBinding({key:s},this.selectHandler.bind(this)),t.keyboard.bindings[s].unshift(t.keyboard.bindings[s].pop());for(let e of this.options.selectKeys??[])t.keyboard.addBinding({key:e},this.selectHandler.bind(this));t.keyboard.bindings[o].unshift(t.keyboard.bindings[o].pop()),t.keyboard.addBinding({key:h},this.escapeHandler.bind(this)),t.keyboard.addBinding({key:a},this.upHandler.bind(this)),t.keyboard.addBinding({key:r},this.downHandler.bind(this))}selectHandler(){return!(this.isOpen&&!this.existingSourceExecutionToken)||(this.selectItem(),!1)}escapeHandler(){return!this.isOpen||(this.existingSourceExecutionToken&&(this.existingSourceExecutionToken.abandoned=!0),this.hideMentionList(),!1)}upHandler(){return!(this.isOpen&&!this.existingSourceExecutionToken)||(this.prevItem(),!1)}downHandler(){return!(this.isOpen&&!this.existingSourceExecutionToken)||(this.nextItem(),!1)}showMentionList(){"fixed"===this.options.positioningStrategy?document.body.appendChild(this.mentionContainer):this.quill.container.appendChild(this.mentionContainer),this.mentionContainer.style.visibility="hidden",this.mentionContainer.style.display="",this.mentionContainer.scrollTop=0,this.setMentionContainerPosition(),this.setIsOpen(!0)}hideMentionList(){this.options.onBeforeClose&&this.options.onBeforeClose(),this.mentionContainer.style.display="none",this.mentionContainer.remove(),this.setIsOpen(!1),this.quill.root.removeAttribute("aria-activedescendant")}highlightItem(t=!0){for(let t=0;ts-t&&(this.mentionContainer.scrollTop+=i-s+t)}}onContainerMouseMove(){this.suspendMouseEnter=!1}selectItem(){if(-1===this.itemIndex)return;const t=this.mentionList.childNodes[this.itemIndex].dataset;t.disabled||(this.options.onSelect?.(t,((t,e=!1,i={})=>this.insertItem(t,e,i))),this.hideMentionList())}insertItem(e,i,n={}){const s=e;if(null===s||void 0===this.mentionCharPos||void 0===this.cursorPos)return;const o={...this.options,...n};let h;o.showDenotationChar||(s.denotationChar=""),i?h=this.cursorPos:(h=this.mentionCharPos,this.quill.deleteText(this.mentionCharPos,this.cursorPos-this.mentionCharPos,t.sources.USER));const a=this.quill.insertEmbed(h,o.blotName??u.DEFAULTS.blotName,s,t.sources.USER);return o.spaceAfterInsert?(this.quill.insertText(h+1," ",t.sources.USER),this.quill.setSelection(h+2,t.sources.USER)):this.quill.setSelection(h+1,t.sources.USER),this.hideMentionList(),a}onItemMouseEnter(t){if(this.suspendMouseEnter||t.target instanceof HTMLElement==!1)return;const e=Number(t.target?.dataset.index);Number.isNaN(e)||e===this.itemIndex||(this.itemIndex=e,this.highlightItem(!1))}onDisabledItemMouseEnter(){this.suspendMouseEnter||(this.itemIndex=-1,this.highlightItem(!1))}onItemClick(t){t.preventDefault(),t.stopImmediatePropagation(),t.currentTarget instanceof HTMLElement!=!1&&(this.itemIndex=t.currentTarget?.dataset.index?Number.parseInt(t.currentTarget.dataset.index):-1,this.highlightItem(),this.selectItem())}onItemMouseDown(t){t.preventDefault(),t.stopImmediatePropagation()}renderLoading(){const t=this.options.renderLoading?.()??void 0;if(void 0===t)return;if(this.mentionContainer.getElementsByClassName("ql-mention-loading").length>0)return void this.showMentionList();this.mentionList.innerHTML="";const e=document.createElement("div");e.className="ql-mention-loading",d(e,t),this.mentionContainer.append(e),this.showMentionList()}removeLoading(){const t=this.mentionContainer.getElementsByClassName("ql-mention-loading");t.length>0&&t[0].remove()}renderList(t,e,i){if(e&&e.length>0){this.removeLoading(),this.values=e,this.mentionList.innerText="";let n=-1;for(let s=0;swindow.scrollY+window.innerHeight}containerRightIsNotVisible(t,e){if(this.options.fixMentionsToQuill)return!1;return t+this.mentionContainer.offsetWidth+e.left>window.scrollX+document.documentElement.clientWidth}setIsOpen(t){this.isOpen!==t&&(t?this.options.onOpen?.():this.options.onClose?.(),this.isOpen=t)}setMentionContainerPosition(){"fixed"===this.options.positioningStrategy?this.setMentionContainerPosition_Fixed():this.setMentionContainerPosition_Normal()}setMentionContainerPosition_Normal(){if(void 0===this.mentionCharPos)return;const t=this.quill.container.getBoundingClientRect(),e=this.quill.getBounds(this.mentionCharPos);if(null===e)return;const i=this.mentionContainer.offsetHeight;let n=this.options.offsetTop,s=this.options.offsetLeft;if(this.options.fixMentionsToQuill){const t=0;this.mentionContainer.style.right=`${t}px`}else s+=e.left;if(this.containerRightIsNotVisible(s,t)){const e=this.mentionContainer.offsetWidth+this.options.offsetLeft;s=t.width-e}if("top"===this.options.defaultMenuOrientation){if(n=this.options.fixMentionsToQuill?-1*(i+this.options.offsetTop):e.top-(i+this.options.offsetTop),n+t.top<=0){let i=this.options.offsetTop;this.options.fixMentionsToQuill?i+=t.height:i+=e.bottom,n=i}}else if(this.options.fixMentionsToQuill?n+=t.height:n+=e.bottom,this.containerBottomIsNotVisible(n,t)){let t=-1*this.options.offsetTop;this.options.fixMentionsToQuill||(t+=e.top),n=t-i}n>=0?this.options.mentionContainerClass?.split(" ").forEach((t=>{this.mentionContainer.classList.add(`${t}-bottom`),this.mentionContainer.classList.remove(`${t}-top`)})):this.options.mentionContainerClass?.split(" ").forEach((t=>{this.mentionContainer.classList.add(`${t}-top`),this.mentionContainer.classList.remove(`${t}-bottom`)})),this.mentionContainer.style.top=`${n}px`,this.mentionContainer.style.left=`${s}px`,this.mentionContainer.style.visibility="visible"}setMentionContainerPosition_Fixed(){if(void 0===this.mentionCharPos)return;this.mentionContainer.style.position="fixed",this.mentionContainer.style.height="";const t=this.quill.container.getBoundingClientRect(),e=this.quill.getBounds(this.mentionCharPos);if(null===e)return;const i={right:t.right-e.right,left:t.left+e.left,top:t.top+e.top,width:0,height:e.height},n=this.options.fixMentionsToQuill?t:i;let s=this.options.offsetTop,o=this.options.offsetLeft;if(this.options.fixMentionsToQuill){const t=n.right;this.mentionContainer.style.right=`${t}px`}else o+=n.left,o+this.mentionContainer.offsetWidth>document.documentElement.clientWidth&&(o-=o+this.mentionContainer.offsetWidth-document.documentElement.clientWidth);const h=n.top,a=document.documentElement.clientHeight-(n.top+n.height),r=this.mentionContainer.offsetHeight<=a,l=this.mentionContainer.offsetHeight<=h;let d;d="top"===this.options.defaultMenuOrientation&&l?"top":"bottom"===this.options.defaultMenuOrientation&&r||a>h?"bottom":"top","bottom"===d?(s=n.top+n.height,r||(this.mentionContainer.style.height=a-3+"px"),this.options.mentionContainerClass?.split(" ").forEach((t=>{this.mentionContainer.classList.add(`${t}-bottom`),this.mentionContainer.classList.remove(`${t}-top`)}))):(s=n.top-this.mentionContainer.offsetHeight,l||(this.mentionContainer.style.height=h-3+"px",s=3),this.options.mentionContainerClass?.split(" ").forEach((t=>{this.mentionContainer.classList.add(`${t}-top`),this.mentionContainer.classList.remove(`${t}-bottom`)}))),this.mentionContainer.style.top=`${s}px`,this.mentionContainer.style.left=`${o}px`,this.mentionContainer.style.visibility="visible"}getTextBeforeCursor(){const t=Math.max(0,(this.cursorPos??0)-this.options.maxChars);return this.quill.getText(t,(this.cursorPos??0)-t)}onSomethingChange(){const t=this.quill.getSelection();if(null==t)return;this.cursorPos=t.index;const e=this.getTextBeforeCursor(),i=Math.max(0,this.cursorPos-this.options.maxChars),n=i?this.quill.getText(i-1,i):"",{mentionChar:s,mentionCharIndex:o}=(h=e,a=this.options.mentionDenotationChars,r=this.options.isolateCharacter,l=this.options.allowInlineMentionChar,a.reduce(((t,e)=>{let i;if(r&&l){const n=new RegExp(`^${e}|\\s${e}`,"g"),s=(h.match(n)||[]).pop();if(!s)return{mentionChar:t.mentionChar,mentionCharIndex:t.mentionCharIndex};i=s!==e?h.lastIndexOf(s)+s.length-e.length:0}else i=h.lastIndexOf(e);return i>t.mentionCharIndex?{mentionChar:e,mentionCharIndex:i}:{mentionChar:t.mentionChar,mentionCharIndex:t.mentionCharIndex}}),{mentionChar:null,mentionCharIndex:-1}));var h,a,r,l;if(null!==s&&function(t,e,i,n){if(-1===t)return!1;if(!i)return!0;const s=t?e[t-1]:n;return!s||!!s.match(/\s/)}(o,e,this.options.isolateCharacter,n)){const t=this.cursorPos-(e.length-o);this.mentionCharPos=t;const i=e.substring(o+s.length);if(i.length>=this.options.minChars&&function(t,e){return e.test(t)}(i,this.getAllowedCharsRegex(s))){this.existingSourceExecutionToken&&(this.existingSourceExecutionToken.abandoned=!0),this.renderLoading();const t={abandoned:!1};this.existingSourceExecutionToken=t,this.options.source?.(i,((e,i)=>{t.abandoned||(this.existingSourceExecutionToken=void 0,this.renderList(s,e,i))}),s)}else this.existingSourceExecutionToken&&(this.existingSourceExecutionToken.abandoned=!0),this.hideMentionList()}else this.existingSourceExecutionToken&&(this.existingSourceExecutionToken.abandoned=!0),this.hideMentionList()}getAllowedCharsRegex(t){return this.options.allowedChars instanceof RegExp?this.options.allowedChars:this.options.allowedChars?.(t)??/^[a-zA-Z0-9_]*$/}onTextChange(t,e,i){"user"===i&&setTimeout(this.onSomethingChange.bind(this),50)}onSelectionChange(t){null!==t&&0===t.length?this.onSomethingChange():this.hideMentionList()}openMenu(t){const e=this.quill.getSelection(!0);this.quill.insertText(e.index,t),this.quill.blur(),this.quill.focus()}}u.DEFAULTS={mentionDenotationChars:["@"],showDenotationChar:!0,allowedChars:/^[a-zA-Z0-9_]*$/,minChars:0,maxChars:31,offsetTop:2,offsetLeft:0,isolateCharacter:!1,allowInlineMentionChar:!1,fixMentionsToQuill:!1,positioningStrategy:"normal",defaultMenuOrientation:"bottom",blotName:"mention",dataAttributes:["id","value","denotationChar","link","target","disabled"],linkTarget:"_blank",listItemClass:"ql-mention-list-item",mentionContainerClass:"ql-mention-list-container",mentionListClass:"ql-mention-list",spaceAfterInsert:!0,selectKeys:[o],source:(t,e,i)=>{e([],t)},renderItem:({value:t})=>`${t}`,onSelect:(t,e)=>e(t),onOpen:()=>!0,onBeforeClose:()=>!0,onClose:()=>!0,renderLoading:()=>null},t.register({"blots/mention":n,"modules/mention":u})}(Quill); diff --git a/keep-ui/shared/ui/theme/ThemeControl.tsx b/keep-ui/shared/ui/theme/ThemeControl.tsx index 9511aaef94..dad9d24acb 100644 --- a/keep-ui/shared/ui/theme/ThemeControl.tsx +++ b/keep-ui/shared/ui/theme/ThemeControl.tsx @@ -23,9 +23,12 @@ export function ThemeControl({ className }: { className?: string }) { const updateTheme = (theme: string) => { setTheme(theme === "system" ? null : theme); if (theme !== "system") { - document.documentElement.classList[theme === "dark" ? "add" : "remove"]( - "workaround-dark" - ); + // Use a more controlled approach to avoid hydration issues + if (theme === "dark") { + document.documentElement.classList.add("workaround-dark"); + } else { + document.documentElement.classList.remove("workaround-dark"); + } // If system theme is selected, will handle the rest } }; diff --git a/keep-ui/shared/ui/theme/ThemeScript.tsx b/keep-ui/shared/ui/theme/ThemeScript.tsx index d7150c13a2..84ee4b2938 100644 --- a/keep-ui/shared/ui/theme/ThemeScript.tsx +++ b/keep-ui/shared/ui/theme/ThemeScript.tsx @@ -7,22 +7,42 @@ export const ThemeScript = () => {