From 03685584d8151e214547288da172adaf53a21765 Mon Sep 17 00:00:00 2001 From: Matheus Fillipe Date: Mon, 29 Sep 2025 11:23:19 +0200 Subject: [PATCH 1/5] Improved emoji dialog And click again to just add --- package-lock.json | 22 ++++++ package.json | 1 + src/components/layout/ChatArea.tsx | 22 ++++-- src/components/ui/ReactionModal.tsx | 114 ++++++++++------------------ 4 files changed, 79 insertions(+), 80 deletions(-) diff --git a/package-lock.json b/package-lock.json index 76f890f4..42e4a8fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@types/uuid": "^10.0.0", "clsx": "^2.1.1", "daisyui": "^5.0.12", + "emoji-picker-react": "^4.13.3", "gh-pages": "^6.3.0", "react": "^18.3.1", "react-color": "^2.19.3", @@ -3319,6 +3320,21 @@ "integrity": "sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==", "license": "MIT" }, + "node_modules/emoji-picker-react": { + "version": "4.13.3", + "resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.13.3.tgz", + "integrity": "sha512-aZaxCI72oUQfvZtYuQ9RaYLEwmH3GVgAr5SEeB97Y7gWL06zJ4VTuSl8rAMVY7GNmd0tf/EQ1W2SDuXTl0q9AA==", + "license": "MIT", + "dependencies": { + "flairup": "1.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16" + } + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -3776,6 +3792,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/flairup": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz", + "integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==", + "license": "MIT" + }, "node_modules/flat-cache": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", diff --git a/package.json b/package.json index a6c8b353..1c363456 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@types/uuid": "^10.0.0", "clsx": "^2.1.1", "daisyui": "^5.0.12", + "emoji-picker-react": "^4.13.3", "gh-pages": "^6.3.0", "react": "^18.3.1", "react-color": "^2.19.3", diff --git a/src/components/layout/ChatArea.tsx b/src/components/layout/ChatArea.tsx index 8a560c0a..a30fc7db 100644 --- a/src/components/layout/ChatArea.tsx +++ b/src/components/layout/ChatArea.tsx @@ -190,6 +190,7 @@ const MessageItem: React.FC<{ message: MessageType, position: { x: number; y: number }, ) => void; + onDirectReaction: (emoji: string, message: MessageType) => void; users: User[]; }> = ({ message, @@ -202,6 +203,7 @@ const MessageItem: React.FC<{ selectedServerId, onReactionUnreact, onOpenReactionModal, + onDirectReaction, users, }) => { const { currentUser } = useStore(); @@ -513,11 +515,8 @@ const MessageItem: React.FC<{ ) { onReactionUnreact(emoji, message); } else { - // Otherwise, add the reaction - onOpenReactionModal(message, { - x: e.clientX, - y: e.clientY, - }); + // Otherwise, add the reaction directly with the clicked emoji + onDirectReaction(emoji, message); } }} > @@ -1266,6 +1265,18 @@ export const ChatArea: React.FC<{ handleCloseReactionModal(); }; + const handleDirectReaction = (emoji: string, message: MessageType) => { + if (message.msgid && selectedServerId) { + const server = servers.find((s) => s.id === selectedServerId); + const channel = server?.channels.find((c) => c.id === message.channelId); + if (server && channel) { + // Send react message directly + const tagMsg = `@+draft/react=${emoji};+draft/reply=${message.msgid} TAGMSG ${channel.name}`; + ircClient.sendRaw(server.id, tagMsg); + } + } + }; + const handleReactionUnreact = (emoji: string, message: MessageType) => { if (message.msgid && selectedServerId) { const server = servers.find((s) => s.id === selectedServerId); @@ -1437,6 +1448,7 @@ export const ChatArea: React.FC<{ selectedServerId={selectedServerId} onReactionUnreact={handleReactionUnreact} onOpenReactionModal={handleOpenReactionModal} + onDirectReaction={handleDirectReaction} users={selectedChannel?.users || []} /> ); diff --git a/src/components/ui/ReactionModal.tsx b/src/components/ui/ReactionModal.tsx index 9a204848..35483e35 100644 --- a/src/components/ui/ReactionModal.tsx +++ b/src/components/ui/ReactionModal.tsx @@ -1,4 +1,6 @@ +import EmojiPicker, { type EmojiClickData, Theme } from "emoji-picker-react"; import type React from "react"; +import { createPortal } from "react-dom"; interface ReactionModalProps { isOpen: boolean; @@ -6,59 +8,6 @@ interface ReactionModalProps { onSelectEmoji: (emoji: string) => void; } -const emojis = [ - "😀", - "😂", - "😍", - "👍", - "🎉", - "â¤ī¸", - "đŸ”Ĩ", - "😎", - "đŸ’¯", - "đŸŽļ", - "đŸ˜ĸ", - "😡", - "🤔", - "🙄", - "😴", - "🤗", - "🤩", - "đŸĨŗ", - "đŸ¤¯", - "😱", - "👏", - "🙌", - "🤝", - "👌", - "âœŒī¸", - "🤞", - "🤙", - "👊", - "🤛", - "🤜", - "đŸ’Ē", - "đŸĻž", - "đŸĻŋ", - "đŸĻĩ", - "đŸĻļ", - "👂", - "đŸĻģ", - "👃", - "đŸ‘ļ", - "👧", - "🧑", - "👨", - "👩", - "🧓", - "👴", - "đŸ‘ĩ", - "🙍", - "🙎", - "🙅", - "🙆", -]; - const ReactionModal: React.FC = ({ isOpen, onClose, @@ -66,33 +15,48 @@ const ReactionModal: React.FC = ({ }) => { if (!isOpen) return null; - const handleEmojiSelect = (emoji: string) => { - onSelectEmoji(emoji); + const handleEmojiSelect = (emojiData: EmojiClickData) => { + onSelectEmoji(emojiData.emoji); onClose(); }; - return ( -
-
-
- {emojis.map((emoji) => ( - - ))} + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + return createPortal( +
+
+
+ +
+
+
-
-
+
, + document.body, ); }; From 09d04b714ff058e52d5fd839920238a4ecff8fd5 Mon Sep 17 00:00:00 2001 From: Matheus Fillipe Date: Mon, 29 Sep 2025 11:29:00 +0200 Subject: [PATCH 2/5] DRY types in ircclient --- src/lib/ircClient.ts | 104 ++++++++++++++++--------------------------- src/types/index.ts | 34 ++++++++++++++ 2 files changed, 72 insertions(+), 66 deletions(-) diff --git a/src/lib/ircClient.ts b/src/lib/ircClient.ts index 183930b0..311da9b7 100644 --- a/src/lib/ircClient.ts +++ b/src/lib/ircClient.ts @@ -1,5 +1,15 @@ import { v4 as uuidv4 } from "uuid"; -import type { Channel, Server, User } from "../types"; +import type { + Channel, + Server, + User, + BaseIRCEvent, + EventWithTags, + BaseMetadataEvent, + MetadataValueEvent, + BaseMessageEvent, + BaseUserActionEvent +} from "../types"; import { parseIsupport, parseMessageTags, @@ -7,88 +17,50 @@ import { } from "./ircUtils"; export interface EventMap { - ready: { serverId: string; serverName: string; nickname: string }; - NICK: { - serverId: string; - mtags: Record | undefined; + ready: BaseIRCEvent & { serverName: string; nickname: string }; + NICK: EventWithTags & { oldNick: string; newNick: string; }; - QUIT: { serverId: string; username: string; reason: string }; - JOIN: { serverId: string; username: string; channelName: string }; - PART: { - serverId: string; - username: string; + QUIT: BaseUserActionEvent & { reason: string }; + JOIN: BaseUserActionEvent & { channelName: string }; + PART: BaseUserActionEvent & { channelName: string; reason?: string; }; - KICK: { - serverId: string; - mtags: Record | undefined; + KICK: EventWithTags & { username: string; channelName: string; target: string; reason: string; }; - CHANMSG: { - serverId: string; - mtags: Record | undefined; - sender: string; + CHANMSG: BaseMessageEvent & { channelName: string; - message: string; - timestamp: Date; - }; - USERMSG: { - serverId: string; - mtags: Record | undefined; - sender: string; - message: string; - timestamp: Date; }; - TAGMSG: { - serverId: string; - mtags: Record | undefined; + USERMSG: BaseMessageEvent; + TAGMSG: EventWithTags & { sender: string; channelName: string; timestamp: Date; }; - NAMES: { serverId: string; channelName: string; users: User[] }; - "CAP LS": { serverId: string; cliCaps: string }; - "CAP ACK": { serverId: string; cliCaps: string }; - ISUPPORT: { serverId: string; key: string; value: string }; - CAP_ACKNOWLEDGED: { serverId: string; key: string; capabilities: string }; - CAP_END: { serverId: string }; - AUTHENTICATE: { serverId: string; param: string }; - METADATA: { - serverId: string; - target: string; - key: string; - visibility: string; - value: string; - }; - METADATA_WHOIS: { - serverId: string; - target: string; - key: string; - visibility: string; - value: string; - }; - METADATA_KEYVALUE: { - serverId: string; - target: string; - key: string; - visibility: string; - value: string; - }; - METADATA_KEYNOTSET: { serverId: string; target: string; key: string }; - METADATA_SUBOK: { serverId: string; keys: string[] }; - METADATA_UNSUBOK: { serverId: string; keys: string[] }; - METADATA_SUBS: { serverId: string; keys: string[] }; - METADATA_SYNCLATER: { serverId: string; target: string; retryAfter?: number }; - BATCH_START: { serverId: string; batchId: string; type: string }; - BATCH_END: { serverId: string; batchId: string }; - METADATA_FAIL: { - serverId: string; + NAMES: BaseIRCEvent & { channelName: string; users: User[] }; + "CAP LS": BaseIRCEvent & { cliCaps: string }; + "CAP ACK": BaseIRCEvent & { cliCaps: string }; + ISUPPORT: BaseIRCEvent & { key: string; value: string }; + CAP_ACKNOWLEDGED: BaseIRCEvent & { key: string; capabilities: string }; + CAP_END: BaseIRCEvent; + AUTHENTICATE: BaseIRCEvent & { param: string }; + METADATA: MetadataValueEvent; + METADATA_WHOIS: MetadataValueEvent; + METADATA_KEYVALUE: MetadataValueEvent; + METADATA_KEYNOTSET: BaseMetadataEvent; + METADATA_SUBOK: BaseIRCEvent & { keys: string[] }; + METADATA_UNSUBOK: BaseIRCEvent & { keys: string[] }; + METADATA_SUBS: BaseIRCEvent & { keys: string[] }; + METADATA_SYNCLATER: BaseIRCEvent & { target: string; retryAfter?: number }; + BATCH_START: BaseIRCEvent & { batchId: string; type: string }; + BATCH_END: BaseIRCEvent & { batchId: string }; + METADATA_FAIL: BaseIRCEvent & { subcommand: string; code: string; target?: string; diff --git a/src/types/index.ts b/src/types/index.ts index 4c2e998d..4671ea63 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -110,3 +110,37 @@ export type MessageTag = { key: string; value?: string; }; + +// Base event interface that all IRC events extend +export interface BaseIRCEvent { + serverId: string; +} + +// Events that include message tags +export interface EventWithTags extends BaseIRCEvent { + mtags: Record | undefined; +} + +// Base metadata event interface +export interface BaseMetadataEvent extends BaseIRCEvent { + target: string; + key: string; +} + +// Metadata event with visibility and value +export interface MetadataValueEvent extends BaseMetadataEvent { + visibility: string; + value: string; +} + +// Base message event interface +export interface BaseMessageEvent extends EventWithTags { + sender: string; + message: string; + timestamp: Date; +} + +// Base user action event interface +export interface BaseUserActionEvent extends BaseIRCEvent { + username: string; +} From 4d4f60876ec19d2b82573e403a2240d9aa63041e Mon Sep 17 00:00:00 2001 From: Matheus Fillipe Date: Mon, 29 Sep 2025 12:08:30 +0200 Subject: [PATCH 3/5] Split and refactor chat area a little --- src/components/layout/ChatArea.tsx | 613 ++------------------ src/components/message/ActionMessage.tsx | 84 +++ src/components/message/DateSeparator.tsx | 27 + src/components/message/MessageActions.tsx | 34 ++ src/components/message/MessageAvatar.tsx | 72 +++ src/components/message/MessageHeader.tsx | 52 ++ src/components/message/MessageItem.tsx | 195 +++++++ src/components/message/MessageReactions.tsx | 74 +++ src/components/message/MessageReply.tsx | 37 ++ src/components/message/SystemMessage.tsx | 37 ++ src/components/message/index.ts | 8 + src/components/ui/LinkWrapper.tsx | 84 +++ src/lib/ircClient.ts | 12 +- src/lib/ircUrlParser.ts | 145 +++++ src/lib/messageFormatter.ts | 164 ++++++ tests/lib/ircUrlParser.test.ts | 287 +++++++++ tests/lib/messageFormatter.test.ts | 311 ++++++++++ 17 files changed, 1650 insertions(+), 586 deletions(-) create mode 100644 src/components/message/ActionMessage.tsx create mode 100644 src/components/message/DateSeparator.tsx create mode 100644 src/components/message/MessageActions.tsx create mode 100644 src/components/message/MessageAvatar.tsx create mode 100644 src/components/message/MessageHeader.tsx create mode 100644 src/components/message/MessageItem.tsx create mode 100644 src/components/message/MessageReactions.tsx create mode 100644 src/components/message/MessageReply.tsx create mode 100644 src/components/message/SystemMessage.tsx create mode 100644 src/components/message/index.ts create mode 100644 src/components/ui/LinkWrapper.tsx create mode 100644 src/lib/ircUrlParser.ts create mode 100644 src/lib/messageFormatter.ts create mode 100644 tests/lib/ircUrlParser.test.ts create mode 100644 tests/lib/messageFormatter.test.ts diff --git a/src/components/layout/ChatArea.tsx b/src/components/layout/ChatArea.tsx index a30fc7db..9a383878 100644 --- a/src/components/layout/ChatArea.tsx +++ b/src/components/layout/ChatArea.tsx @@ -2,10 +2,6 @@ import { UsersIcon } from "@heroicons/react/24/solid"; import { platform } from "@tauri-apps/plugin-os"; import type * as React from "react"; import { - Children, - cloneElement, - Fragment, - isValidElement, useEffect, useRef, useState, @@ -20,7 +16,6 @@ import { FaHashtag, FaPenAlt, FaPlus, - FaReply, FaSearch, FaTimes, FaUserPlus, @@ -29,14 +24,22 @@ import { v4 as uuidv4 } from "uuid"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import { useTabCompletion } from "../../hooks/useTabCompletion"; import ircClient from "../../lib/ircClient"; -import { getColorStyle, ircColors, mircToHtml } from "../../lib/ircUtils"; +import { parseIrcUrl } from "../../lib/ircUrlParser"; +import { + type FormattingType, + formatMessageForIrc, + getPreviewStyles, + isValidFormattingType, +} from "../../lib/messageFormatter"; import useStore from "../../store"; import type { Message as MessageType, User } from "../../types"; +import { MessageItem } from "../message/MessageItem"; import AutocompleteDropdown from "../ui/AutocompleteDropdown"; import BlankPage from "../ui/BlankPage"; import ColorPicker from "../ui/ColorPicker"; import EmojiSelector from "../ui/EmojiSelector"; import DiscoverGrid from "../ui/HomeScreen"; +import { EnhancedLinkWrapper } from "../ui/LinkWrapper"; import ReactionModal from "../ui/ReactionModal"; import UserContextMenu from "../ui/UserContextMenu"; @@ -93,490 +96,6 @@ export const TypingIndicator: React.FC<{ return
{message}
; }; -const EnhancedLinkWrapper: React.FC<{ - children: React.ReactNode; - onIrcLinkClick?: (url: string) => void; -}> = ({ children, onIrcLinkClick }) => { - // Regular expression to detect HTTP and HTTPS links - const urlRegex = /\b(?:https?|irc|ircs):\/\/[^\s<>"']+/gi; - const parseContent = (content: string): React.ReactNode[] => { - // Split the content based on the URL regex - const parts = content.split(urlRegex); - const matches = content.match(urlRegex) || []; - - return parts.map((part, index) => { - // Generate stable keys based on content and position - const partKey = `text-${part}-${index}`; - const textPart = {part}; - - // If there's a matching link for this part, render it - if (index < matches.length) { - const fragmentKey = `fragment-${matches[index]}-${index}`; - return ( - - {textPart} - { - if ( - (matches[index].startsWith("ircs://") || - matches[index].startsWith("irc://")) && - onIrcLinkClick - ) { - e.preventDefault(); - onIrcLinkClick(matches[index]); - } - }} - > - {matches[index]} - - - ); - } - - return textPart; - }); - }; - - // Since children can be React nodes, we need to process them - const processChildren = (node: React.ReactNode): React.ReactNode[] => { - return ( - Children.map(node, (child) => { - if (typeof child === "string") { - return parseContent(child); // Process string content - } - if (isValidElement(child)) { - // Skip already-linkified anchors to avoid nested - if ((child as React.ReactElement).type === "a") { - return child; - } - // Directly process the children of the React element - const processed = processChildren( - (child as React.ReactElement).props?.children, - ); - return cloneElement( - child as React.ReactElement, - undefined, - processed, - ); - } - // For other types of children, return them as is - return child as React.ReactNode; - }) ?? [] - ); - }; - return <>{processChildren(children)}; -}; - -const MessageItem: React.FC<{ - message: MessageType; - showDate: boolean; - showHeader: boolean; - setReplyTo: (msg: MessageType) => void; - onUsernameContextMenu: ( - e: React.MouseEvent, - username: string, - serverId: string, - avatarElement?: Element | null, - ) => void; - onIrcLinkClick?: (url: string) => void; - onReactClick: (message: MessageType, buttonElement: Element) => void; - selectedServerId: string | null; - onReactionUnreact: (emoji: string, message: MessageType) => void; - onOpenReactionModal: ( - message: MessageType, - position: { x: number; y: number }, - ) => void; - onDirectReaction: (emoji: string, message: MessageType) => void; - users: User[]; -}> = ({ - message, - showDate, - showHeader, - setReplyTo, - onUsernameContextMenu, - onIrcLinkClick, - onReactClick, - selectedServerId, - onReactionUnreact, - onOpenReactionModal, - onDirectReaction, - users, -}) => { - const { currentUser } = useStore(); - const isCurrentUser = currentUser?.id === message.userId; - // Find the user for this message - const messageUser = users.find( - (user) => user.username === message.userId.split("-")[0], - ); - const avatarUrl = messageUser?.metadata?.avatar?.value; - const displayName = messageUser?.metadata?.["display-name"]?.value; - const userColor = messageUser?.metadata?.color?.value; - const userStatus = messageUser?.metadata?.status?.value; - const isSystem = message.type === "system"; - // Convert message content to React elements - const htmlContent = mircToHtml(message.content); - // Format timestamp - const formatTime = (date: Date) => { - return new Intl.DateTimeFormat("en-US", { - hour: "2-digit", - minute: "2-digit", - }).format(date); - }; - - // Format date for message groups - const formatDate = (date: Date) => { - return new Intl.DateTimeFormat("en-US", { - month: "long", - day: "numeric", - year: "numeric", - }).format(date); - }; - - if (isSystem) { - return ( -
-
-
- - {htmlContent} - -
- {formatTime(new Date(message.timestamp))} -
-
-
- ); - } - - const theme = localStorage.getItem("theme") || "discord"; - if (message.content.substring(0, 7) === "\u0001ACTION") { - return ( -
- {showDate && ( -
-
-
- {formatDate(new Date(message.timestamp))} -
-
-
- )} -
-
-
- {avatarUrl ? ( - {message.userId.split("-")[0]} { - // Fallback to initial if image fails to load - e.currentTarget.style.display = "none"; - const parent = e.currentTarget.parentElement; - if (parent) { - parent.textContent = message.userId - .charAt(0) - .toUpperCase(); - } - }} - /> - ) : ( - message.userId.charAt(0).toUpperCase() - )} - {userStatus && ( -
-
- 💡 -
-
-
- {userStatus} -
-
-
- )} -
-
-
-
- - {formatTime(new Date(message.timestamp))} - -
- - {message.userId === "system" - ? "System" - : (displayName || message.userId.split("-")[0]) + - (displayName ? ` (${message.userId.split("-")[0]})` : "") + - message.content.substring(7, message.content.length - 1)} - -
-
-
- ); - } - return ( -
- {showDate && ( -
-
-
{formatDate(new Date(message.timestamp))}
-
-
- )} -
- {showHeader && ( -
{ - if (message.userId !== "system") { - onUsernameContextMenu( - e, - message.userId.split("-")[0], - message.serverId, - e.currentTarget, - ); - } - }} - > -
- {avatarUrl ? ( - {message.userId.split("-")[0]} { - // Fallback to initial if image fails to load - e.currentTarget.style.display = "none"; - const parent = e.currentTarget.parentElement; - if (parent) { - parent.textContent = message.userId - .charAt(0) - .toUpperCase(); - } - }} - /> - ) : ( - message.userId.charAt(0).toUpperCase() - )} - {userStatus && ( -
-
- 💡 -
-
-
- {userStatus} -
-
-
- )} -
-
- )} - {!showHeader && ( -
-
-
- )} -
- {showHeader && ( -
- { - if (message.userId !== "system") { - // Find the avatar element to position menu over it - const messageElement = e.currentTarget.closest(".flex"); - const avatarElement = - messageElement?.querySelector(".mr-4"); - onUsernameContextMenu( - e, - message.userId.split("-")[0], - message.serverId, - avatarElement, - ); - } - }} - > - {message.userId === "system" - ? "System" - : displayName || message.userId.split("-")[0]} - {displayName && ( - - {message.userId.split("-")[0]} - - )} - - - {formatTime(new Date(message.timestamp))} - -
- )} -
- {message.replyMessage && ( -
- ┌ Replying to{" "} - - { - // Find the avatar element to position menu over it - const messageElement = e.currentTarget.closest(".flex"); - const avatarElement = - messageElement?.querySelector(".mr-4"); - onUsernameContextMenu( - e, - message.replyMessage?.userId.split("-")[0] || "", - message.serverId, - avatarElement, - ); - }} - > - {message.replyMessage?.userId.split("-")[0] || ""} - - : - {" "} - - {mircToHtml(message.replyMessage.content)} - -
- )} - - {htmlContent} - -
- {/* Reactions positioned below message content */} - {message.reactions && message.reactions.length > 0 && ( -
- {Object.entries( - message.reactions.reduce( - ( - acc: Record< - string, - { - count: number; - users: string[]; - currentUserReacted: boolean; - } - >, - reaction: { emoji: string; userId: string }, - ) => { - if (!acc[reaction.emoji]) { - acc[reaction.emoji] = { - count: 0, - users: [], - currentUserReacted: false, - }; - } - acc[reaction.emoji].count++; - acc[reaction.emoji].users.push(reaction.userId); - // Check if current user reacted with this emoji - if (reaction.userId === currentUser?.username) { - acc[reaction.emoji].currentUserReacted = true; - } - return acc; - }, - {} as Record< - string, - { - count: number; - users: string[]; - currentUserReacted: boolean; - } - >, - ), - ).map(([emoji, data]) => ( -
{ - // If current user has reacted, clicking removes the reaction - if ( - ( - data as { - count: number; - users: string[]; - currentUserReacted: boolean; - } - ).currentUserReacted - ) { - onReactionUnreact(emoji, message); - } else { - // Otherwise, add the reaction directly with the clicked emoji - onDirectReaction(emoji, message); - } - }} - > - {emoji} - - { - ( - data as { - count: number; - users: string[]; - currentUserReacted: boolean; - } - ).count - } - - {/* Show X button if current user reacted */} - {( - data as { - count: number; - users: string[]; - currentUserReacted: boolean; - } - ).currentUserReacted && ( - - )} -
- ))} -
- )} -
- {/* Hover buttons */} -
- - {message.msgid && ( - - )} -
-
-
- ); -}; export const ChatArea: React.FC<{ onToggleChanList: () => void; @@ -587,7 +106,9 @@ export const ChatArea: React.FC<{ const [isEmojiSelectorOpen, setIsEmojiSelectorOpen] = useState(false); const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); const [selectedColor, setSelectedColor] = useState(null); - const [selectedFormatting, setSelectedFormatting] = useState([]); + const [selectedFormatting, setSelectedFormatting] = useState< + FormattingType[] + >([]); const [isScrolledUp, setIsScrolledUp] = useState(false); const [isFormattingInitialized, setIsFormattingInitialized] = useState(false); const [cursorPosition, setCursorPosition] = useState(0); @@ -636,47 +157,14 @@ export const ChatArea: React.FC<{ const tabCompletion = useTabCompletion(); const handleIrcLinkClick = (rawUrl: string) => { - // Tolerate trailing punctuation in chat text - const sanitized = rawUrl.trim().replace(/[),.;:]+$/, ""); - const urlObj = new URL(sanitized); - const host = urlObj.hostname; - const scheme = urlObj.protocol.replace(":", ""); - const port = urlObj.port - ? Number.parseInt(urlObj.port, 10) - : scheme === "ircs" - ? 443 - : 8000; - - // Channels may be in pathname (/chan1,chan2) or in hash (#chan1,chan2) - const rawChannelStr = - urlObj.pathname.length > 1 - ? urlObj.pathname.slice(1) - : urlObj.hash.startsWith("#") - ? urlObj.hash.slice(1) - : ""; - const channels = rawChannelStr - .split(",") - .filter(Boolean) - .map((c) => decodeURIComponent(c)) - .map((c) => - c.startsWith("#") || - c.startsWith("&") || - c.startsWith("+") || - c.startsWith("!") - ? c - : `#${c}`, - ); - - const nick = - urlObj.searchParams.get("nick") || currentUser?.username || "user"; - const password = urlObj.searchParams.get("password") || undefined; + const parsed = parseIrcUrl(rawUrl, currentUser?.username || "user"); // Open the connect modal with pre-filled server details toggleAddServerModal(true, { - name: host, - host: host, - port: port.toString(), - nickname: nick, + name: parsed.host, + host: parsed.host, + port: parsed.port.toString(), + nickname: parsed.nick || "user", }); }; @@ -693,8 +181,12 @@ export const ChatArea: React.FC<{ try { const parsedFormatting = JSON.parse(savedFormatting); if (Array.isArray(parsedFormatting)) { - console.log("Parsed formatting:", parsedFormatting); - setSelectedFormatting(parsedFormatting); // Apply the saved formatting + // Validate that all items are valid formatting types + const validFormatting = parsedFormatting.filter( + isValidFormattingType, + ); + console.log("Parsed formatting:", validFormatting); + setSelectedFormatting(validFormatting); // Apply the saved formatting setIsFormattingInitialized(true); // Mark formatting as initialized } } catch (error) { @@ -782,12 +274,6 @@ export const ChatArea: React.FC<{ container.removeEventListener("scroll", checkIfScrolledToBottom); }, []); - const getColorCode = (color: string): string => { - const index = - ircColors.indexOf(color) === 99 ? -1 : ircColors.indexOf(color); - return index !== -1 ? `\x03${index < 10 ? `0${index}` : index}` : ""; // Return \x03 followed by the index, or an empty string if not found - }; - const handleSendMessage = () => { if (messageText.trim() === "") return; scrollDown(); @@ -834,31 +320,11 @@ export const ChatArea: React.FC<{ ); } } else { - const colorCode = getColorCode(selectedColor || "inherit"); // Get the IRC color code - - // Apply formatting codes - let formattedText = messageText; - if (selectedFormatting.includes("bold")) { - formattedText = `\x02${formattedText}\x02`; - } - if (selectedFormatting.includes("italic")) { - formattedText = `\x1D${formattedText}\x1D`; - } - if (selectedFormatting.includes("underline")) { - formattedText = `\x1F${formattedText}\x1F`; - } - if (selectedFormatting.includes("strikethrough")) { - formattedText = `\x1E${formattedText}\x1E`; - } - if (selectedFormatting.includes("reverse")) { - formattedText = `\x16${formattedText}\x16`; - } - if (selectedFormatting.includes("monospace")) { - formattedText = `\x11${formattedText}\x11`; - } - - // Prepend the color code - formattedText = `${colorCode}${formattedText}`; + // Format the message with color and styling + const formattedText = formatMessageForIrc(messageText, { + color: selectedColor || "inherit", + formatting: selectedFormatting, + }); // Determine target: channel name or username for private messages const target = @@ -1303,13 +769,13 @@ export const ChatArea: React.FC<{ setIsEmojiSelectorOpen(false); }; - const handleColorSelect = (color: string, formatting: string[]) => { + const handleColorSelect = (color: string, formatting: FormattingType[]) => { setSelectedColor(color); setSelectedFormatting(formatting); setIsColorPickerOpen(false); }; - const toggleFormatting = (format: string) => { + const toggleFormatting = (format: FormattingType) => { setSelectedFormatting((prev) => prev.includes(format) ? prev.filter((f) => f !== format) @@ -1517,23 +983,10 @@ export const ChatArea: React.FC<{ : "Type a message..." } className="bg-transparent border-none outline-none py-3 flex-grow text-discord-text-normal" - style={{ + style={getPreviewStyles({ color: selectedColor || "inherit", - fontWeight: selectedFormatting.includes("bold") - ? "bold" - : "normal", - fontStyle: selectedFormatting.includes("italic") - ? "italic" - : "normal", - textDecoration: selectedFormatting.includes("underline") - ? "underline" - : selectedFormatting.includes("strikethrough") - ? "line-through" - : "none", - fontFamily: selectedFormatting.includes("monospace") - ? "monospace" - : "inherit", - }} + formatting: selectedFormatting, + })} /> + {message.msgid && ( + + )} +
+ ); +}; diff --git a/src/components/message/MessageAvatar.tsx b/src/components/message/MessageAvatar.tsx new file mode 100644 index 00000000..631e7538 --- /dev/null +++ b/src/components/message/MessageAvatar.tsx @@ -0,0 +1,72 @@ +import type React from "react"; + +interface MessageAvatarProps { + userId: string; + avatarUrl?: string; + userStatus?: string; + theme: string; + showHeader: boolean; + onClick?: (e: React.MouseEvent) => void; + isClickable?: boolean; +} + +export const MessageAvatar: React.FC = ({ + userId, + avatarUrl, + userStatus, + theme, + showHeader, + onClick, + isClickable = false, +}) => { + const username = userId.split("-")[0]; + + if (!showHeader) { + return ( +
+
+
+ ); + } + + return ( +
+
+ {avatarUrl ? ( + {username} { + // Fallback to initial if image fails to load + e.currentTarget.style.display = "none"; + const parent = e.currentTarget.parentElement; + if (parent) { + parent.textContent = username.charAt(0).toUpperCase(); + } + }} + /> + ) : ( + username.charAt(0).toUpperCase() + )} + {userStatus && ( +
+
+ 💡 +
+
+
+ {userStatus} +
+
+
+ )} +
+
+ ); +}; diff --git a/src/components/message/MessageHeader.tsx b/src/components/message/MessageHeader.tsx new file mode 100644 index 00000000..51faab67 --- /dev/null +++ b/src/components/message/MessageHeader.tsx @@ -0,0 +1,52 @@ +import type React from "react"; +import { getColorStyle } from "../../lib/ircUtils"; + +interface MessageHeaderProps { + userId: string; + displayName?: string; + userColor?: string; + timestamp: Date; + theme: string; + isClickable?: boolean; + onClick?: (e: React.MouseEvent) => void; +} + +export const MessageHeader: React.FC = ({ + userId, + displayName, + userColor, + timestamp, + theme, + isClickable = false, + onClick, +}) => { + const username = userId.split("-")[0]; + const isSystem = userId === "system"; + + const formatTime = (date: Date) => { + return new Intl.DateTimeFormat("en-US", { + hour: "2-digit", + minute: "2-digit", + }).format(date); + }; + + return ( +
+ + {isSystem ? "System" : displayName || username} + {displayName && ( + + {username} + + )} + + + {formatTime(timestamp)} + +
+ ); +}; diff --git a/src/components/message/MessageItem.tsx b/src/components/message/MessageItem.tsx new file mode 100644 index 00000000..85fb8a69 --- /dev/null +++ b/src/components/message/MessageItem.tsx @@ -0,0 +1,195 @@ +import type React from "react"; +import { mircToHtml } from "../../lib/ircUtils"; +import useStore from "../../store"; +import type { MessageType, User } from "../../types"; +import { EnhancedLinkWrapper } from "../ui/LinkWrapper"; +import { + ActionMessage, + DateSeparator, + MessageActions, + MessageAvatar, + MessageHeader, + MessageReactions, + MessageReply, + SystemMessage, +} from "./index"; + +interface MessageItemProps { + message: MessageType; + showDate: boolean; + showHeader: boolean; + setReplyTo: (msg: MessageType) => void; + onUsernameContextMenu: ( + e: React.MouseEvent, + username: string, + serverId: string, + avatarElement?: Element | null, + ) => void; + onIrcLinkClick?: (url: string) => void; + onReactClick: (message: MessageType, buttonElement: Element) => void; + selectedServerId: string | null; + onReactionUnreact: (emoji: string, message: MessageType) => void; + onOpenReactionModal: ( + message: MessageType, + position: { x: number; y: number }, + ) => void; + onDirectReaction: (emoji: string, message: MessageType) => void; + users: User[]; +} + +export const MessageItem: React.FC = ({ + message, + showDate, + showHeader, + setReplyTo, + onUsernameContextMenu, + onIrcLinkClick, + onReactClick, + selectedServerId, + onReactionUnreact, + onOpenReactionModal, + onDirectReaction, + users, +}) => { + const { currentUser } = useStore(); + const isCurrentUser = currentUser?.id === message.userId; + + // Find the user for this message + const messageUser = users.find( + (user) => user.username === message.userId.split("-")[0], + ); + const avatarUrl = messageUser?.metadata?.avatar?.value; + const displayName = messageUser?.metadata?.["display-name"]?.value; + const userColor = messageUser?.metadata?.color?.value; + const userStatus = messageUser?.metadata?.status?.value; + const isSystem = message.type === "system"; + + // Convert message content to React elements + const htmlContent = mircToHtml(message.content); + const theme = localStorage.getItem("theme") || "discord"; + const username = message.userId.split("-")[0]; + + // Handle system messages + if (isSystem) { + return ; + } + + // Handle ACTION messages + if (message.content.substring(0, 7) === "\u0001ACTION") { + return ( + <> + {showDate && ( + + )} + + + ); + } + + // Handle regular messages + const handleReactionClick = (emoji: string, currentUserReacted: boolean) => { + if (currentUserReacted) { + onReactionUnreact(emoji, message); + } else { + onDirectReaction(emoji, message); + } + }; + + const handleAvatarClick = (e: React.MouseEvent) => { + if (message.userId !== "system") { + onUsernameContextMenu(e, username, message.serverId, e.currentTarget); + } + }; + + const handleUsernameClick = (e: React.MouseEvent) => { + if (message.userId !== "system") { + // Find the avatar element to position menu over it + const messageElement = e.currentTarget.closest(".flex"); + const avatarElement = messageElement?.querySelector(".mr-4"); + onUsernameContextMenu(e, username, message.serverId, avatarElement); + } + }; + + const handleReplyUsernameClick = (e: React.MouseEvent) => { + if (message.replyMessage) { + // Find the avatar element to position menu over it + const messageElement = e.currentTarget.closest(".flex"); + const avatarElement = messageElement?.querySelector(".mr-4"); + onUsernameContextMenu( + e, + message.replyMessage.userId.split("-")[0], + message.serverId, + avatarElement, + ); + } + }; + + const isClickable = + message.userId !== "system" && currentUser?.username !== username; + + return ( +
+ {showDate && ( + + )} + +
+ + +
+ {showHeader && ( + + )} + +
+ {message.replyMessage && ( + + )} + + + {htmlContent} + +
+ + +
+ + setReplyTo(message)} + onReactClick={(buttonElement) => onReactClick(message, buttonElement)} + /> +
+
+ ); +}; diff --git a/src/components/message/MessageReactions.tsx b/src/components/message/MessageReactions.tsx new file mode 100644 index 00000000..426eb26f --- /dev/null +++ b/src/components/message/MessageReactions.tsx @@ -0,0 +1,74 @@ +import type React from "react"; +import type { MessageType } from "../../types"; + +interface ReactionData { + count: number; + users: string[]; + currentUserReacted: boolean; +} + +interface MessageReactionsProps { + reactions: MessageType["reactions"]; + currentUserUsername?: string; + onReactionClick: (emoji: string, currentUserReacted: boolean) => void; +} + +export const MessageReactions: React.FC = ({ + reactions, + currentUserUsername, + onReactionClick, +}) => { + if (!reactions || reactions.length === 0) { + return null; + } + + // Group reactions by emoji + const groupedReactions = reactions.reduce( + (acc: Record, reaction) => { + if (!acc[reaction.emoji]) { + acc[reaction.emoji] = { + count: 0, + users: [], + currentUserReacted: false, + }; + } + acc[reaction.emoji].count++; + acc[reaction.emoji].users.push(reaction.userId); + // Check if current user reacted with this emoji + if (reaction.userId === currentUserUsername) { + acc[reaction.emoji].currentUserReacted = true; + } + return acc; + }, + {}, + ); + + return ( +
+ {Object.entries(groupedReactions).map(([emoji, data]) => ( +
onReactionClick(emoji, data.currentUserReacted)} + > + {emoji} + {data.count} + {/* Show X button if current user reacted */} + {data.currentUserReacted && ( + + )} +
+ ))} +
+ ); +}; diff --git a/src/components/message/MessageReply.tsx b/src/components/message/MessageReply.tsx new file mode 100644 index 00000000..f02dd81f --- /dev/null +++ b/src/components/message/MessageReply.tsx @@ -0,0 +1,37 @@ +import type React from "react"; +import { mircToHtml } from "../../lib/ircUtils"; +import type { MessageType } from "../../types"; +import { EnhancedLinkWrapper } from "../ui/LinkWrapper"; + +interface MessageReplyProps { + replyMessage: MessageType; + theme: string; + onUsernameClick?: (e: React.MouseEvent) => void; + onIrcLinkClick?: (url: string) => void; +} + +export const MessageReply: React.FC = ({ + replyMessage, + theme, + onUsernameClick, + onIrcLinkClick, +}) => { + const replyUsername = replyMessage.userId.split("-")[0]; + + return ( +
+ ┌ Replying to{" "} + + + {replyUsername} + + : + {" "} + + {mircToHtml(replyMessage.content)} + +
+ ); +}; diff --git a/src/components/message/SystemMessage.tsx b/src/components/message/SystemMessage.tsx new file mode 100644 index 00000000..aad35bd9 --- /dev/null +++ b/src/components/message/SystemMessage.tsx @@ -0,0 +1,37 @@ +import type React from "react"; +import { mircToHtml } from "../../lib/ircUtils"; +import type { MessageType } from "../../types"; +import { EnhancedLinkWrapper } from "../ui/LinkWrapper"; + +interface SystemMessageProps { + message: MessageType; + onIrcLinkClick?: (url: string) => void; +} + +export const SystemMessage: React.FC = ({ + message, + onIrcLinkClick, +}) => { + const formatTime = (date: Date) => { + return new Intl.DateTimeFormat("en-US", { + hour: "2-digit", + minute: "2-digit", + }).format(date); + }; + + const htmlContent = mircToHtml(message.content); + + return ( +
+
+
+ + {htmlContent} + +
+ {formatTime(new Date(message.timestamp))} +
+
+
+ ); +}; diff --git a/src/components/message/index.ts b/src/components/message/index.ts new file mode 100644 index 00000000..1b86cd7f --- /dev/null +++ b/src/components/message/index.ts @@ -0,0 +1,8 @@ +export { ActionMessage } from "./ActionMessage"; +export { DateSeparator } from "./DateSeparator"; +export { MessageActions } from "./MessageActions"; +export { MessageAvatar } from "./MessageAvatar"; +export { MessageHeader } from "./MessageHeader"; +export { MessageReactions } from "./MessageReactions"; +export { MessageReply } from "./MessageReply"; +export { SystemMessage } from "./SystemMessage"; diff --git a/src/components/ui/LinkWrapper.tsx b/src/components/ui/LinkWrapper.tsx new file mode 100644 index 00000000..bbb648dc --- /dev/null +++ b/src/components/ui/LinkWrapper.tsx @@ -0,0 +1,84 @@ +import React, { Children, cloneElement, Fragment, isValidElement } from "react"; + +interface EnhancedLinkWrapperProps { + children: React.ReactNode; + onIrcLinkClick?: (url: string) => void; +} + +export const EnhancedLinkWrapper: React.FC = ({ + children, + onIrcLinkClick +}) => { + // Regular expression to detect HTTP and HTTPS links + const urlRegex = /\b(?:https?|irc|ircs):\/\/[^\s<>"']+/gi; + const parseContent = (content: string): React.ReactNode[] => { + // Split the content based on the URL regex + const parts = content.split(urlRegex); + const matches = content.match(urlRegex) || []; + + return parts.map((part, index) => { + // Generate stable keys based on content and position + const partKey = `text-${part}-${index}`; + const textPart = {part}; + + // If there's a matching link for this part, render it + if (index < matches.length) { + const fragmentKey = `fragment-${matches[index]}-${index}`; + return ( + + {textPart} +
{ + if ( + (matches[index].startsWith("ircs://") || + matches[index].startsWith("irc://")) && + onIrcLinkClick + ) { + e.preventDefault(); + onIrcLinkClick(matches[index]); + } + }} + > + {matches[index]} + + + ); + } + + return textPart; + }); + }; + + // Since children can be React nodes, we need to process them + const processChildren = (node: React.ReactNode): React.ReactNode[] => { + return ( + Children.map(node, (child) => { + if (typeof child === "string") { + return parseContent(child); // Process string content + } + if (isValidElement(child)) { + // Skip already-linkified anchors to avoid nested + if ((child as React.ReactElement).type === "a") { + return child; + } + // Directly process the children of the React element + const processed = processChildren( + (child as React.ReactElement).props?.children, + ); + return cloneElement( + child as React.ReactElement, + undefined, + processed, + ); + } + // For other types of children, return them as is + return child as React.ReactNode; + }) ?? [] + ); + }; + return <>{processChildren(children)}; +}; \ No newline at end of file diff --git a/src/lib/ircClient.ts b/src/lib/ircClient.ts index 311da9b7..703e791d 100644 --- a/src/lib/ircClient.ts +++ b/src/lib/ircClient.ts @@ -1,14 +1,14 @@ import { v4 as uuidv4 } from "uuid"; import type { - Channel, - Server, - User, BaseIRCEvent, - EventWithTags, + BaseMessageEvent, BaseMetadataEvent, + BaseUserActionEvent, + Channel, + EventWithTags, MetadataValueEvent, - BaseMessageEvent, - BaseUserActionEvent + Server, + User, } from "../types"; import { parseIsupport, diff --git a/src/lib/ircUrlParser.ts b/src/lib/ircUrlParser.ts new file mode 100644 index 00000000..23560144 --- /dev/null +++ b/src/lib/ircUrlParser.ts @@ -0,0 +1,145 @@ +/** + * Utility functions for parsing IRC URLs (irc:// and ircs://) + * Supports extracting server details, channels, and connection parameters + */ + +export interface ParsedIrcUrl { + host: string; + port: number; + scheme: "irc" | "ircs"; + channels: string[]; + nick?: string; + password?: string; +} + +/** + * Parses an IRC URL and extracts connection details + * + * @param url - The IRC URL to parse (irc:// or ircs://) + * @param defaultNick - Default nickname to use if none specified in URL + * @returns Parsed IRC connection details + * + * @example + * ```typescript + * const parsed = parseIrcUrl('ircs://irc.libera.chat:6697/#channel1,channel2?nick=user&password=pass'); + * // Returns: { + * // host: 'irc.libera.chat', + * // port: 6697, + * // scheme: 'ircs', + * // channels: ['#channel1', '#channel2'], + * // nick: 'user', + * // password: 'pass' + * // } + * ``` + */ +export function parseIrcUrl(url: string, defaultNick = "user"): ParsedIrcUrl { + // Sanitize URL by removing trailing punctuation commonly found in chat text + const sanitizedUrl = url.trim().replace(/[),.;:]+$/, ""); + + const urlObj = new URL(sanitizedUrl); + const host = urlObj.hostname; + const scheme = urlObj.protocol.replace(":", "") as "irc" | "ircs"; + + // Determine port with sensible defaults + const port = urlObj.port + ? Number.parseInt(urlObj.port, 10) + : scheme === "ircs" + ? 443 + : 8000; + + // Parse channels from pathname (/chan1,chan2) or hash (#chan1,chan2) + const rawChannelStr = + urlObj.pathname.length > 1 + ? urlObj.pathname.slice(1) // Remove leading / + : urlObj.hash.startsWith("#") + ? urlObj.hash.slice(1) // Remove leading # + : ""; + + const channels = rawChannelStr + .split(",") + .filter(Boolean) + .map((c) => decodeURIComponent(c)) + .map((c) => normalizeChannelName(c)); + + // Extract connection parameters + const nick = urlObj.searchParams.get("nick") || defaultNick; + const password = urlObj.searchParams.get("password") || undefined; + + return { + host, + port, + scheme, + channels, + nick, + password, + }; +} + +/** + * Normalizes a channel name by ensuring it has the correct prefix + * Adds '#' prefix if the channel doesn't start with a valid IRC channel prefix + * + * @param channelName - The channel name to normalize + * @returns Normalized channel name with proper prefix + */ +export function normalizeChannelName(channelName: string): string { + const validPrefixes = ["#", "&", "+", "!"]; + const hasValidPrefix = validPrefixes.some((prefix) => + channelName.startsWith(prefix), + ); + + return hasValidPrefix ? channelName : `#${channelName}`; +} + +/** + * Validates if a URL is a valid IRC URL + * + * @param url - The URL to validate + * @returns true if the URL is a valid IRC URL + */ +export function isValidIrcUrl(url: string): boolean { + try { + const urlObj = new URL(url); + return ( + ["irc:", "ircs:"].includes(urlObj.protocol) && urlObj.hostname !== "" + ); + } catch { + return false; + } +} + +/** + * Constructs an IRC URL from connection details + * + * @param details - The IRC connection details + * @returns Formatted IRC URL string + */ +export function constructIrcUrl(details: ParsedIrcUrl): string { + let url = `${details.scheme}://${details.host}:${details.port}`; + + if (details.channels.length > 0) { + // Remove # prefixes from channels for clean URL construction + const cleanChannels = details.channels + .map((channel) => + channel.startsWith("#") ? channel.substring(1) : channel, + ) + .join(","); + url += `/${cleanChannels}`; + } else { + url += "/"; + } + + const params = new URLSearchParams(); + if (details.nick) { + params.set("nick", details.nick); + } + if (details.password) { + params.set("password", details.password); + } + + if (params.toString()) { + url += `?${params.toString()}`; + } + + return url; +} diff --git a/src/lib/messageFormatter.ts b/src/lib/messageFormatter.ts new file mode 100644 index 00000000..ef930f5f --- /dev/null +++ b/src/lib/messageFormatter.ts @@ -0,0 +1,164 @@ +/** + * Utility functions for formatting IRC messages with colors and styling + * Handles IRC formatting codes for bold, italic, underline, etc. + */ + +import { ircColors } from "./ircUtils"; + +export type FormattingType = + | "bold" + | "italic" + | "underline" + | "strikethrough" + | "reverse" + | "monospace"; + +export interface MessageFormatting { + color?: string; + formatting: FormattingType[]; +} + +/** + * IRC formatting control codes + */ +export const IRC_FORMATTING_CODES = { + bold: "\x02", + italic: "\x1D", + underline: "\x1F", + strikethrough: "\x1E", + reverse: "\x16", + monospace: "\x11", + color: "\x03", + reset: "\x0F", +} as const; + +/** + * Gets the IRC color code for a given color name + * + * @param color - The color name or 'inherit' for no color + * @returns IRC color code string (e.g., '\x0301' for black) + * + * @example + * ```typescript + * getIrcColorCode('red') // Returns '\x0304' + * getIrcColorCode('inherit') // Returns '' + * ``` + */ +export function getIrcColorCode(color: string): string { + if (color === "inherit") return ""; + + const index = ircColors.indexOf(color); + if (index === -1 || index === 99) return ""; + + // Format as two-digit number (e.g., 01, 02, 10) + const formattedIndex = index < 10 ? `0${index}` : `${index}`; + return `${IRC_FORMATTING_CODES.color}${formattedIndex}`; +} + +/** + * Applies IRC formatting codes to text + * + * @param text - The text to format + * @param formatting - Array of formatting types to apply + * @returns Text wrapped with IRC formatting codes + * + * @example + * ```typescript + * applyIrcFormatting('Hello', ['bold', 'italic']) + * // Returns '\x02\x1DHello\x1D\x02' + * ``` + */ +export function applyIrcFormatting( + text: string, + formatting: FormattingType[], +): string { + let formattedText = text; + + // Apply each formatting type + for (const format of formatting) { + const code = IRC_FORMATTING_CODES[format]; + if (code) { + formattedText = `${code}${formattedText}${code}`; + } + } + + return formattedText; +} + +/** + * Formats a message with color and styling for IRC transmission + * + * @param text - The message text + * @param options - Formatting options (color and styling) + * @returns Formatted message ready for IRC transmission + * + * @example + * ```typescript + * formatMessageForIrc('Hello world', { + * color: 'red', + * formatting: ['bold', 'underline'] + * }) + * // Returns '\x0304\x02\x1FHello world\x1F\x02' + * ``` + */ +export function formatMessageForIrc( + text: string, + options: MessageFormatting, +): string { + const { color, formatting } = options; + + // Apply formatting codes + let formattedText = applyIrcFormatting(text, formatting); + + // Prepend color code if specified + const colorCode = color ? getIrcColorCode(color) : ""; + if (colorCode) { + formattedText = `${colorCode}${formattedText}`; + } + + return formattedText; +} + +/** + * Gets CSS styles for preview based on formatting options + * + * @param options - Formatting options + * @returns CSS style object + */ +export function getPreviewStyles( + options: MessageFormatting, +): React.CSSProperties { + const { color, formatting } = options; + + return { + color: color && color !== "inherit" ? color : "inherit", + fontWeight: formatting.includes("bold") ? "bold" : "normal", + fontStyle: formatting.includes("italic") ? "italic" : "normal", + textDecoration: formatting.includes("underline") + ? "underline" + : formatting.includes("strikethrough") + ? "line-through" + : "none", + fontFamily: formatting.includes("monospace") ? "monospace" : "inherit", + }; +} + +/** + * Validates if a formatting type is supported + * + * @param format - The formatting type to validate + * @returns true if the formatting type is supported + */ +export function isValidFormattingType( + format: string, +): format is FormattingType { + const validTypes: FormattingType[] = [ + "bold", + "italic", + "underline", + "strikethrough", + "reverse", + "monospace", + ]; + return validTypes.includes(format as FormattingType); +} diff --git a/tests/lib/ircUrlParser.test.ts b/tests/lib/ircUrlParser.test.ts new file mode 100644 index 00000000..14906ef8 --- /dev/null +++ b/tests/lib/ircUrlParser.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it } from "vitest"; +import { + constructIrcUrl, + isValidIrcUrl, + normalizeChannelName, + type ParsedIrcUrl, + parseIrcUrl, +} from "../../src/lib/ircUrlParser"; + +describe("ircUrlParser", () => { + describe("parseIrcUrl", () => { + it("should parse basic IRC URL with default port", () => { + const result = parseIrcUrl("irc://irc.libera.chat/"); + + expect(result).toEqual({ + host: "irc.libera.chat", + port: 8000, + scheme: "irc", + channels: [], + nick: "user", + password: undefined, + }); + }); + + it("should parse IRCS URL with default port", () => { + const result = parseIrcUrl("ircs://irc.libera.chat/"); + + expect(result).toEqual({ + host: "irc.libera.chat", + port: 443, + scheme: "ircs", + channels: [], + nick: "user", + password: undefined, + }); + }); + + it("should parse URL with custom port", () => { + const result = parseIrcUrl("ircs://irc.libera.chat:6697/"); + + expect(result.port).toBe(6697); + }); + + it("should parse URL with single channel in pathname", () => { + const result = parseIrcUrl("irc://irc.libera.chat/channel1"); + + expect(result.channels).toEqual(["#channel1"]); + }); + + it("should parse URL with multiple channels in pathname", () => { + const result = parseIrcUrl( + "irc://irc.libera.chat/channel1,channel2,channel3", + ); + + expect(result.channels).toEqual(["#channel1", "#channel2", "#channel3"]); + }); + + it("should parse URL with channels in hash", () => { + const result = parseIrcUrl("irc://irc.libera.chat/#channel1,channel2"); + + expect(result.channels).toEqual(["#channel1", "#channel2"]); + }); + + it("should handle URL-encoded channel names", () => { + const result = parseIrcUrl("irc://irc.libera.chat/my%20channel"); + + expect(result.channels).toEqual(["#my channel"]); + }); + + it("should parse URL with nick parameter", () => { + const result = parseIrcUrl("irc://irc.libera.chat/?nick=testuser"); + + expect(result.nick).toBe("testuser"); + }); + + it("should parse URL with password parameter", () => { + const result = parseIrcUrl("irc://irc.libera.chat/?password=secret123"); + + expect(result.password).toBe("secret123"); + }); + + it("should parse complex URL with all parameters", () => { + const result = parseIrcUrl( + "ircs://irc.libera.chat:6697/channel1,channel2?nick=testuser&password=secret", + ); + + expect(result).toEqual({ + host: "irc.libera.chat", + port: 6697, + scheme: "ircs", + channels: ["#channel1", "#channel2"], + nick: "testuser", + password: "secret", + }); + }); + + it("should use custom default nick when provided", () => { + const result = parseIrcUrl("irc://irc.libera.chat/", "customnick"); + + expect(result.nick).toBe("customnick"); + }); + + it("should override default nick with URL parameter", () => { + const result = parseIrcUrl( + "irc://irc.libera.chat/?nick=urlnick", + "defaultnick", + ); + + expect(result.nick).toBe("urlnick"); + }); + + it("should sanitize URLs with trailing punctuation", () => { + const result = parseIrcUrl("irc://irc.libera.chat/channel1,channel2."); + + expect(result.channels).toEqual(["#channel1", "#channel2"]); + }); + + it("should handle various trailing punctuation marks", () => { + const testCases = [ + "irc://irc.libera.chat/test)", + "irc://irc.libera.chat/test,", + "irc://irc.libera.chat/test.", + "irc://irc.libera.chat/test;", + "irc://irc.libera.chat/test:", + "irc://irc.libera.chat/test).,;:", + ]; + + testCases.forEach((url) => { + const result = parseIrcUrl(url); + expect(result.channels).toEqual(["#test"]); + }); + }); + + it("should handle channels with various prefixes", () => { + const result = parseIrcUrl( + "irc://irc.libera.chat/#chan1,&chan2,+chan3,!chan4", + ); + + expect(result.channels).toEqual(["#chan1", "&chan2", "+chan3", "!chan4"]); + }); + }); + + describe("normalizeChannelName", () => { + it("should add # prefix to channel without prefix", () => { + expect(normalizeChannelName("channel")).toBe("#channel"); + }); + + it("should preserve # prefix", () => { + expect(normalizeChannelName("#channel")).toBe("#channel"); + }); + + it("should preserve & prefix", () => { + expect(normalizeChannelName("&channel")).toBe("&channel"); + }); + + it("should preserve + prefix", () => { + expect(normalizeChannelName("+channel")).toBe("+channel"); + }); + + it("should preserve ! prefix", () => { + expect(normalizeChannelName("!channel")).toBe("!channel"); + }); + + it("should handle empty string", () => { + expect(normalizeChannelName("")).toBe("#"); + }); + }); + + describe("isValidIrcUrl", () => { + it("should return true for valid IRC URLs", () => { + expect(isValidIrcUrl("irc://irc.libera.chat/")).toBe(true); + expect(isValidIrcUrl("ircs://irc.libera.chat:6697/")).toBe(true); + }); + + it("should return false for non-IRC URLs", () => { + expect(isValidIrcUrl("http://example.com")).toBe(false); + expect(isValidIrcUrl("https://example.com")).toBe(false); + expect(isValidIrcUrl("ftp://example.com")).toBe(false); + }); + + it("should return false for invalid URLs", () => { + expect(isValidIrcUrl("not-a-url")).toBe(false); + expect(isValidIrcUrl("")).toBe(false); + expect(isValidIrcUrl("irc://")).toBe(false); + }); + }); + + describe("constructIrcUrl", () => { + it("should construct basic IRC URL", () => { + const details: ParsedIrcUrl = { + host: "irc.libera.chat", + port: 8000, + scheme: "irc", + channels: [], + nick: "testuser", + }; + + const result = constructIrcUrl(details); + expect(result).toBe("irc://irc.libera.chat:8000/?nick=testuser"); + }); + + it("should construct IRCS URL with channels", () => { + const details: ParsedIrcUrl = { + host: "irc.libera.chat", + port: 6697, + scheme: "ircs", + channels: ["#channel1", "#channel2"], + nick: "testuser", + password: "secret", + }; + + const result = constructIrcUrl(details); + expect(result).toBe( + "ircs://irc.libera.chat:6697/channel1,channel2?nick=testuser&password=secret", + ); + }); + + it("should construct URL without optional parameters", () => { + const details: ParsedIrcUrl = { + host: "irc.libera.chat", + port: 443, + scheme: "ircs", + channels: ["#test"], + }; + + const result = constructIrcUrl(details); + expect(result).toBe("ircs://irc.libera.chat:443/test"); + }); + }); + + describe("round-trip compatibility", () => { + it("should parse and reconstruct URLs correctly", () => { + const testCases = [ + { + url: "irc://irc.libera.chat:8000/", + expected: { + host: "irc.libera.chat", + port: 8000, + scheme: "irc" as const, + channels: [], + nick: "user", + }, + }, + { + url: "ircs://irc.libera.chat:6697/channel1,channel2?nick=testuser", + expected: { + host: "irc.libera.chat", + port: 6697, + scheme: "ircs" as const, + channels: ["#channel1", "#channel2"], + nick: "testuser", + }, + }, + { + url: "irc://irc.freenode.net/?nick=testuser", + expected: { + host: "irc.freenode.net", + port: 8000, + scheme: "irc" as const, + channels: [], + nick: "testuser", + }, + }, + ]; + + testCases.forEach(({ url, expected }) => { + const parsed = parseIrcUrl(url); + + expect(parsed.host).toBe(expected.host); + expect(parsed.port).toBe(expected.port); + expect(parsed.scheme).toBe(expected.scheme); + expect(parsed.channels).toEqual(expected.channels); + expect(parsed.nick).toBe(expected.nick); + + // Test reconstruction produces parseable URLs + const reconstructed = constructIrcUrl(parsed); + const reParsed = parseIrcUrl(reconstructed); + + expect(reParsed.host).toBe(parsed.host); + expect(reParsed.port).toBe(parsed.port); + expect(reParsed.scheme).toBe(parsed.scheme); + expect(reParsed.channels).toEqual(parsed.channels); + expect(reParsed.nick).toBe(parsed.nick); + }); + }); + }); +}); diff --git a/tests/lib/messageFormatter.test.ts b/tests/lib/messageFormatter.test.ts new file mode 100644 index 00000000..5ec149aa --- /dev/null +++ b/tests/lib/messageFormatter.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from "vitest"; +import { + applyIrcFormatting, + type FormattingType, + formatMessageForIrc, + getIrcColorCode, + getPreviewStyles, + IRC_FORMATTING_CODES, + isValidFormattingType, + type MessageFormatting, +} from "../../src/lib/messageFormatter"; + +describe("messageFormatter", () => { + describe("getIrcColorCode", () => { + it("should return empty string for inherit color", () => { + expect(getIrcColorCode("inherit")).toBe(""); + }); + + it("should return color code for valid colors", () => { + // Test with actual hex colors from ircColors array + expect(getIrcColorCode("#FFFFFF")).toBe("\x0300"); // White - index 0 + expect(getIrcColorCode("#000000")).toBe("\x0301"); // Black - index 1 + expect(getIrcColorCode("#FF0000")).toBe("\x0304"); // Red - index 4 + }); + + it("should return empty string for invalid colors", () => { + expect(getIrcColorCode("invalidcolor")).toBe(""); + expect(getIrcColorCode("")).toBe(""); + }); + + it("should format single-digit indices with leading zero", () => { + // White is index 0, should be formatted as 00 + const result = getIrcColorCode("#FFFFFF"); + expect(result).toBe("\x0300"); + expect(result.length).toBe(3); // \x03 + 2 digits + }); + }); + + describe("applyIrcFormatting", () => { + it("should apply bold formatting", () => { + const result = applyIrcFormatting("test", ["bold"]); + expect(result).toBe("\x02test\x02"); + }); + + it("should apply italic formatting", () => { + const result = applyIrcFormatting("test", ["italic"]); + expect(result).toBe("\x1Dtest\x1D"); + }); + + it("should apply underline formatting", () => { + const result = applyIrcFormatting("test", ["underline"]); + expect(result).toBe("\x1Ftest\x1F"); + }); + + it("should apply strikethrough formatting", () => { + const result = applyIrcFormatting("test", ["strikethrough"]); + expect(result).toBe("\x1Etest\x1E"); + }); + + it("should apply reverse formatting", () => { + const result = applyIrcFormatting("test", ["reverse"]); + expect(result).toBe("\x16test\x16"); + }); + + it("should apply monospace formatting", () => { + const result = applyIrcFormatting("test", ["monospace"]); + expect(result).toBe("\x11test\x11"); + }); + + it("should apply multiple formatting types", () => { + const result = applyIrcFormatting("test", ["bold", "italic"]); + // Formatting is applied in array order, so bold first, then italic wraps it + expect(result).toBe("\x1D\x02test\x02\x1D"); + }); + + it("should handle empty formatting array", () => { + const result = applyIrcFormatting("test", []); + expect(result).toBe("test"); + }); + + it("should handle empty text", () => { + const result = applyIrcFormatting("", ["bold"]); + expect(result).toBe("\x02\x02"); + }); + }); + + describe("formatMessageForIrc", () => { + it("should format message with color only", () => { + const options: MessageFormatting = { + color: "#FF0000", // Red + formatting: [], + }; + const result = formatMessageForIrc("hello", options); + + // Should start with color code for red (index 4) + expect(result).toBe("\x0304hello"); + }); + + it("should format message with formatting only", () => { + const options: MessageFormatting = { + formatting: ["bold", "italic"], + }; + const result = formatMessageForIrc("hello", options); + + expect(result).toBe("\x1D\x02hello\x02\x1D"); + }); + + it("should format message with both color and formatting", () => { + const options: MessageFormatting = { + color: "#00007F", // Blue (Navy) + formatting: ["bold"], + }; + const result = formatMessageForIrc("hello", options); + + // Should have color code for blue (index 2) followed by formatted text + expect(result).toBe("\x0302\x02hello\x02"); + }); + + it("should handle inherit color with formatting", () => { + const options: MessageFormatting = { + color: "inherit", + formatting: ["underline"], + }; + const result = formatMessageForIrc("hello", options); + + expect(result).toBe("\x1Fhello\x1F"); + }); + + it("should handle no color with formatting", () => { + const options: MessageFormatting = { + formatting: ["italic"], + }; + const result = formatMessageForIrc("hello", options); + + expect(result).toBe("\x1Dhello\x1D"); + }); + + it("should handle empty message", () => { + const options: MessageFormatting = { + color: "#FF0000", // Red + formatting: ["bold"], + }; + const result = formatMessageForIrc("", options); + + expect(result).toBe("\x0304\x02\x02"); + }); + }); + + describe("getPreviewStyles", () => { + it("should return styles for color only", () => { + const options: MessageFormatting = { + color: "red", + formatting: [], + }; + const styles = getPreviewStyles(options); + + expect(styles).toEqual({ + color: "red", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + fontFamily: "inherit", + }); + }); + + it("should return styles for inherit color", () => { + const options: MessageFormatting = { + color: "inherit", + formatting: [], + }; + const styles = getPreviewStyles(options); + + expect(styles.color).toBe("inherit"); + }); + + it("should return styles for bold formatting", () => { + const options: MessageFormatting = { + formatting: ["bold"], + }; + const styles = getPreviewStyles(options); + + expect(styles.fontWeight).toBe("bold"); + }); + + it("should return styles for italic formatting", () => { + const options: MessageFormatting = { + formatting: ["italic"], + }; + const styles = getPreviewStyles(options); + + expect(styles.fontStyle).toBe("italic"); + }); + + it("should return styles for underline formatting", () => { + const options: MessageFormatting = { + formatting: ["underline"], + }; + const styles = getPreviewStyles(options); + + expect(styles.textDecoration).toBe("underline"); + }); + + it("should return styles for strikethrough formatting", () => { + const options: MessageFormatting = { + formatting: ["strikethrough"], + }; + const styles = getPreviewStyles(options); + + expect(styles.textDecoration).toBe("line-through"); + }); + + it("should return styles for monospace formatting", () => { + const options: MessageFormatting = { + formatting: ["monospace"], + }; + const styles = getPreviewStyles(options); + + expect(styles.fontFamily).toBe("monospace"); + }); + + it("should prioritize underline over strikethrough", () => { + const options: MessageFormatting = { + formatting: ["underline", "strikethrough"], + }; + const styles = getPreviewStyles(options); + + expect(styles.textDecoration).toBe("underline"); + }); + + it("should handle multiple formatting types", () => { + const options: MessageFormatting = { + color: "blue", + formatting: ["bold", "italic", "monospace"], + }; + const styles = getPreviewStyles(options); + + expect(styles).toEqual({ + color: "blue", + fontWeight: "bold", + fontStyle: "italic", + textDecoration: "none", + fontFamily: "monospace", + }); + }); + }); + + describe("isValidFormattingType", () => { + it("should return true for valid formatting types", () => { + const validTypes: FormattingType[] = [ + "bold", + "italic", + "underline", + "strikethrough", + "reverse", + "monospace", + ]; + + validTypes.forEach((type) => { + expect(isValidFormattingType(type)).toBe(true); + }); + }); + + it("should return false for invalid formatting types", () => { + const invalidTypes = ["invalid", "blink", "shadow", "", "BOLD", "Bold"]; + + invalidTypes.forEach((type) => { + expect(isValidFormattingType(type)).toBe(false); + }); + }); + }); + + describe("IRC_FORMATTING_CODES", () => { + it("should contain all expected formatting codes", () => { + expect(IRC_FORMATTING_CODES.bold).toBe("\x02"); + expect(IRC_FORMATTING_CODES.italic).toBe("\x1D"); + expect(IRC_FORMATTING_CODES.underline).toBe("\x1F"); + expect(IRC_FORMATTING_CODES.strikethrough).toBe("\x1E"); + expect(IRC_FORMATTING_CODES.reverse).toBe("\x16"); + expect(IRC_FORMATTING_CODES.monospace).toBe("\x11"); + expect(IRC_FORMATTING_CODES.color).toBe("\x03"); + expect(IRC_FORMATTING_CODES.reset).toBe("\x0F"); + }); + }); + + describe("integration tests", () => { + it("should format complex message correctly", () => { + const options: MessageFormatting = { + color: "#FF0000", // Red + formatting: ["bold", "underline"], + }; + + const result = formatMessageForIrc("Hello World!", options); + + // Should have color code + underline wrapping bold + expect(result).toBe("\x0304\x1F\x02Hello World!\x02\x1F"); + }); + + it("should provide consistent preview styles for formatted message", () => { + const options: MessageFormatting = { + color: "#009300", // Green + formatting: ["bold", "italic"], + }; + + const styles = getPreviewStyles(options); + + expect(styles.color).toBe("#009300"); + expect(styles.fontWeight).toBe("bold"); + expect(styles.fontStyle).toBe("italic"); + }); + }); +}); From 5138fd570409b9dd5c106c28683492851256bd81 Mon Sep 17 00:00:00 2001 From: Matheus Fillipe Date: Mon, 29 Sep 2025 12:10:22 +0200 Subject: [PATCH 4/5] lint fix --- src/components/layout/ChatArea.tsx | 8 +-- src/components/message/MessageReactions.tsx | 58 ++++++++++++--------- src/components/ui/ColorPicker.tsx | 7 +-- src/components/ui/LinkWrapper.tsx | 7 +-- src/types/index.ts | 3 ++ 5 files changed, 45 insertions(+), 38 deletions(-) diff --git a/src/components/layout/ChatArea.tsx b/src/components/layout/ChatArea.tsx index 9a383878..6a0ff239 100644 --- a/src/components/layout/ChatArea.tsx +++ b/src/components/layout/ChatArea.tsx @@ -1,11 +1,7 @@ import { UsersIcon } from "@heroicons/react/24/solid"; import { platform } from "@tauri-apps/plugin-os"; import type * as React from "react"; -import { - useEffect, - useRef, - useState, -} from "react"; +import { useEffect, useRef, useState } from "react"; import { FaArrowDown, FaAt, @@ -39,7 +35,6 @@ import BlankPage from "../ui/BlankPage"; import ColorPicker from "../ui/ColorPicker"; import EmojiSelector from "../ui/EmojiSelector"; import DiscoverGrid from "../ui/HomeScreen"; -import { EnhancedLinkWrapper } from "../ui/LinkWrapper"; import ReactionModal from "../ui/ReactionModal"; import UserContextMenu from "../ui/UserContextMenu"; @@ -96,7 +91,6 @@ export const TypingIndicator: React.FC<{ return
{message}
; }; - export const ChatArea: React.FC<{ onToggleChanList: () => void; isChanListVisible: boolean; diff --git a/src/components/message/MessageReactions.tsx b/src/components/message/MessageReactions.tsx index 426eb26f..a4f266f6 100644 --- a/src/components/message/MessageReactions.tsx +++ b/src/components/message/MessageReactions.tsx @@ -24,7 +24,10 @@ export const MessageReactions: React.FC = ({ // Group reactions by emoji const groupedReactions = reactions.reduce( - (acc: Record, reaction) => { + ( + acc: Record, + reaction: { emoji: string; userId: string }, + ) => { if (!acc[reaction.emoji]) { acc[reaction.emoji] = { count: 0, @@ -45,30 +48,35 @@ export const MessageReactions: React.FC = ({ return (
- {Object.entries(groupedReactions).map(([emoji, data]) => ( -
onReactionClick(emoji, data.currentUserReacted)} - > - {emoji} - {data.count} - {/* Show X button if current user reacted */} - {data.currentUserReacted && ( - - )} -
- ))} + {Object.entries(groupedReactions).map(([emoji, data]) => { + const reactionData = data as ReactionData; + return ( +
+ onReactionClick(emoji, reactionData.currentUserReacted) + } + > + {emoji} + {reactionData.count} + {/* Show X button if current user reacted */} + {reactionData.currentUserReacted && ( + + )} +
+ ); + })}
); }; diff --git a/src/components/ui/ColorPicker.tsx b/src/components/ui/ColorPicker.tsx index 4c3e0eb0..f8dda87e 100644 --- a/src/components/ui/ColorPicker.tsx +++ b/src/components/ui/ColorPicker.tsx @@ -1,13 +1,14 @@ import type React from "react"; import { FaTimes } from "react-icons/fa"; import { ircColors } from "../../lib/ircUtils"; +import type { FormattingType } from "../../lib/messageFormatter"; const ColorPicker: React.FC<{ - onSelect: (color: string, formatting: string[]) => void; + onSelect: (color: string, formatting: FormattingType[]) => void; onClose: () => void; selectedColor: string | null; - selectedFormatting: string[]; - toggleFormatting: (format: string) => void; + selectedFormatting: FormattingType[]; + toggleFormatting: (format: FormattingType) => void; }> = ({ onSelect, onClose, diff --git a/src/components/ui/LinkWrapper.tsx b/src/components/ui/LinkWrapper.tsx index bbb648dc..ad86d4cb 100644 --- a/src/components/ui/LinkWrapper.tsx +++ b/src/components/ui/LinkWrapper.tsx @@ -1,4 +1,5 @@ -import React, { Children, cloneElement, Fragment, isValidElement } from "react"; +import type React from "react"; +import { Children, cloneElement, Fragment, isValidElement } from "react"; interface EnhancedLinkWrapperProps { children: React.ReactNode; @@ -7,7 +8,7 @@ interface EnhancedLinkWrapperProps { export const EnhancedLinkWrapper: React.FC = ({ children, - onIrcLinkClick + onIrcLinkClick, }) => { // Regular expression to detect HTTP and HTTPS links const urlRegex = /\b(?:https?|irc|ircs):\/\/[^\s<>"']+/gi; @@ -81,4 +82,4 @@ export const EnhancedLinkWrapper: React.FC = ({ ); }; return <>{processChildren(children)}; -}; \ No newline at end of file +}; diff --git a/src/types/index.ts b/src/types/index.ts index 4671ea63..49823181 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -76,6 +76,9 @@ export interface Message { mentioned: string[]; } +// Alias for backwards compatibility +export type MessageType = Message; + export interface SocketResponse { event: string; data: unknown; From 8efc84026b72f9011535bec3d44c70ca50fb8505 Mon Sep 17 00:00:00 2001 From: Matheus Fillipe Date: Mon, 29 Sep 2025 22:06:08 +0200 Subject: [PATCH 5/5] Use same emoji picker everywhere --- src/components/layout/ChatArea.tsx | 82 +++++++++++++++-------------- src/components/ui/EmojiSelector.tsx | 34 ------------ 2 files changed, 43 insertions(+), 73 deletions(-) delete mode 100644 src/components/ui/EmojiSelector.tsx diff --git a/src/components/layout/ChatArea.tsx b/src/components/layout/ChatArea.tsx index 6a0ff239..137e9d8b 100644 --- a/src/components/layout/ChatArea.tsx +++ b/src/components/layout/ChatArea.tsx @@ -1,7 +1,9 @@ import { UsersIcon } from "@heroicons/react/24/solid"; import { platform } from "@tauri-apps/plugin-os"; +import EmojiPicker, { type EmojiClickData, Theme } from "emoji-picker-react"; import type * as React from "react"; import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { FaArrowDown, FaAt, @@ -33,7 +35,6 @@ import { MessageItem } from "../message/MessageItem"; import AutocompleteDropdown from "../ui/AutocompleteDropdown"; import BlankPage from "../ui/BlankPage"; import ColorPicker from "../ui/ColorPicker"; -import EmojiSelector from "../ui/EmojiSelector"; import DiscoverGrid from "../ui/HomeScreen"; import ReactionModal from "../ui/ReactionModal"; import UserContextMenu from "../ui/UserContextMenu"; @@ -41,32 +42,6 @@ import UserContextMenu from "../ui/UserContextMenu"; const EMPTY_ARRAY: User[] = []; let lastTypingTime = 0; -export const OptionsDropdown: React.FC<{ - isOpen: boolean; - onClose: () => void; -}> = ({ isOpen, onClose }) => { - if (!isOpen) return null; - - return ( -
-
- - -
-
- ); -}; - export const TypingIndicator: React.FC<{ serverId: string; channelId: string; @@ -758,11 +733,17 @@ export const ChatArea: React.FC<{ }); }; - const handleEmojiSelect = (emoji: string) => { - setMessageText((prev) => prev + emoji); + const handleEmojiSelect = (emojiData: EmojiClickData) => { + setMessageText((prev) => prev + emojiData.emoji); setIsEmojiSelectorOpen(false); }; + const handleEmojiModalBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + setIsEmojiSelectorOpen(false); + } + }; + const handleColorSelect = (color: string, formatting: FormattingType[]) => { setSelectedColor(color); setSelectedFormatting(formatting); @@ -936,10 +917,6 @@ export const ChatArea: React.FC<{ {/* Input area */} {(selectedChannel || selectedPrivateChat) && (
- setIsEmojiSelectorOpen(false)} - />
- {isEmojiSelectorOpen && ( - setIsEmojiSelectorOpen(false)} - /> - )} + {isEmojiSelectorOpen && + createPortal( +
+
+
+ +
+
+ +
+
+
, + document.body, + )} {isColorPickerOpen && ( void; - onClose: () => void; -} - -const emojis = ["😀", "😂", "😍", "👍", "🎉", "â¤ī¸", "đŸ”Ĩ", "😎", "đŸ’¯", "đŸŽļ"]; - -const EmojiSelector: React.FC = ({ onSelect, onClose }) => { - return ( -
-
- {emojis.map((emoji) => ( - - ))} -
- -
- ); -}; - -export default EmojiSelector;