diff --git a/frontend/src/pages/app/resources/css/_chat.scss b/frontend/src/pages/app/resources/css/_chat.scss index 016fda1..a333156 100644 --- a/frontend/src/pages/app/resources/css/_chat.scss +++ b/frontend/src/pages/app/resources/css/_chat.scss @@ -968,4 +968,17 @@ color: $color-dark-on-surface-variant; font-size: 0.9rem; } + + // Integrated mode styles (inside reaction bar) + &.integrated { + position: relative !important; + width: 320px !important; + height: 400px !important; + transform: none !important; + opacity: 1 !important; + box-shadow: none; + border: none; + background: $color-dark-surface-container; + overflow: visible; + } } diff --git a/frontend/src/pages/app/resources/css/_reactions.scss b/frontend/src/pages/app/resources/css/_reactions.scss index cee538f..f64402d 100644 --- a/frontend/src/pages/app/resources/css/_reactions.scss +++ b/frontend/src/pages/app/resources/css/_reactions.scss @@ -74,6 +74,15 @@ } } +// Emoji menu wrapper inside reaction bar +.emoji-menu-wrapper { + width: 320px; + height: 400px; + display: flex; + align-items: center; + justify-content: center; +} + // Context menu wrapper with animations .context-menu-wrapper { position: relative; @@ -140,9 +149,9 @@ border-radius: 16px; position: absolute; bottom: 100%; - min-width: 200px; justify-content: center; margin-bottom: 10px; + transition: width 0.3s ease-out, height 0.3s ease-out; &.left { left: 0; @@ -153,12 +162,55 @@ right: 0; transform: translateX(0); } + + &.expanded { + padding: 0; + overflow: hidden; + width: 320px; + height: 400px; + border-radius: 16px; + + // Default: expand downward from the reaction bar's bottom edge + position: absolute; + bottom: auto; + top: 0; + left: 0; + transform: translateY(0); + + &.expand-upward { + // Expand upward from the reaction bar's top edge + bottom: 100%; + top: auto; + margin-bottom: 10px; + margin-top: 0; + transform: translateY(0); + } + + .emoji-menu-wrapper { + animation: emojiMenuEnter 0.5s ease; + } + } +} + +@keyframes emojiMenuEnter { + from { + opacity: 0; + } + + to { + opacity: 1; + } } .reaction-bar-content { display: flex; align-items: center; gap: 4px; + transition: opacity 0.3s ease-out; + + &.faded { + opacity: 0; + } } .reaction-emoji-button { diff --git a/frontend/src/pages/app/resources/css/common/_components.scss b/frontend/src/pages/app/resources/css/common/_components.scss index 4005247..4567775 100644 --- a/frontend/src/pages/app/resources/css/common/_components.scss +++ b/frontend/src/pages/app/resources/css/common/_components.scss @@ -82,6 +82,11 @@ button, input { &.open { animation: context-menu-open 0.25s ease; } + + &.faded { + opacity: 0; + transition: opacity 0.3s ease-out; + } @keyframes context-menu-open { 0% { diff --git a/frontend/src/pages/app/ui/components/chat/ChatInputWrapper.tsx b/frontend/src/pages/app/ui/components/chat/ChatInputWrapper.tsx index 502d932..1f12f82 100644 --- a/frontend/src/pages/app/ui/components/chat/ChatInputWrapper.tsx +++ b/frontend/src/pages/app/ui/components/chat/ChatInputWrapper.tsx @@ -206,6 +206,7 @@ export function ChatInputWrapper( onClose={() => setEmojiMenuOpen(false)} onEmojiSelect={handleEmojiSelect} position={emojiMenuPosition} + mode="standalone" /> ); diff --git a/frontend/src/pages/app/ui/components/chat/ChatMessages.tsx b/frontend/src/pages/app/ui/components/chat/ChatMessages.tsx index 865dcff..efdf97c 100644 --- a/frontend/src/pages/app/ui/components/chat/ChatMessages.tsx +++ b/frontend/src/pages/app/ui/components/chat/ChatMessages.tsx @@ -4,7 +4,6 @@ import type { Message as MessageType } from "../../../core/types"; import type { UserProfile } from "../../../core/types"; import { UserProfileDialog } from "./UserProfileDialog"; import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu"; -import { EmojiMenu } from "./EmojiMenu"; import { fetchUserProfile } from "../../../api/profileApi"; import { useEffect, useState, type ReactNode } from "react"; import { delay } from "../../../utils/utils"; @@ -42,16 +41,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [toBeDeleted, setToBeDeleted] = useState<{ id: number; isDm: boolean } | null>(null); - // Emoji menu state (for expanded emoji picker) - const [emojiMenu, setEmojiMenu] = useState<{ - isOpen: boolean; - message: MessageType | null; - position: { x: number; y: number }; - }>({ - isOpen: false, - message: null, - position: { x: 0, y: 0 } - }); useEffect(() => { if (!deleteDialogOpen) { @@ -157,27 +146,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel } - function handleEmojiMenuClose() { - setEmojiMenu(prev => ({ ...prev, isOpen: false })); - } - - function handleExpandEmojiMenu(message: MessageType) { - const messageElement = document.querySelector(`[data-id="${message.id}"]`); - if (messageElement) { - const rect = messageElement.getBoundingClientRect(); - setEmojiMenu({ - isOpen: true, - message, - position: { x: rect.left + rect.width / 2, y: rect.bottom + 10 } - }); - } - } - - function handleEmojiSelect(emoji: string) { - if (emojiMenu.message) { - handleReactionClick(emojiMenu.message.id, emoji); - } - } return ( <> @@ -227,7 +195,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel onDelete={handleDelete} onRetry={handleRetry} onReactionClick={handleReactionClick} - onExpandEmojiMenu={handleExpandEmojiMenu} position={contextMenu.position} isOpen={contextMenu.isOpen} onOpenChange={handleContextMenuOpenChange} @@ -235,13 +202,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel )} - {/* Emoji Menu */} - ); } diff --git a/frontend/src/pages/app/ui/components/chat/EmojiMenu.tsx b/frontend/src/pages/app/ui/components/chat/EmojiMenu.tsx index 076b099..c701c4c 100644 --- a/frontend/src/pages/app/ui/components/chat/EmojiMenu.tsx +++ b/frontend/src/pages/app/ui/components/chat/EmojiMenu.tsx @@ -2,14 +2,26 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { EMOJI_CATEGORIES, getRecentEmojis, addRecentEmoji } from "./emojiData"; import type { Size2D } from "../../../core/types"; -interface EmojiMenuProps { +interface BaseEmojiMenuProps { isOpen: boolean; onClose: () => void; onEmojiSelect: (emoji: string) => void; - position: Size2D; } -export function EmojiMenu({ isOpen, onClose, onEmojiSelect, position }: EmojiMenuProps) { +interface StandaloneEmojiMenuProps extends BaseEmojiMenuProps { + position: Size2D; + mode: "standalone"; +} + +interface IntegratedEmojiMenuProps extends BaseEmojiMenuProps { + mode: "integrated"; +} + +type EmojiMenuProps = StandaloneEmojiMenuProps | IntegratedEmojiMenuProps; + +export function EmojiMenu(props: EmojiMenuProps) { + const { isOpen, onClose, onEmojiSelect, mode } = props; + const position = mode === "standalone" ? props.position : undefined; const [activeCategory, setActiveCategory] = useState("recent"); const [recentEmojis, setRecentEmojis] = useState([]); const menuRef = useRef(null); @@ -106,13 +118,15 @@ export function EmojiMenu({ isOpen, onClose, onEmojiSelect, position }: EmojiMen return (
diff --git a/frontend/src/pages/app/ui/components/chat/MessageContextMenu.tsx b/frontend/src/pages/app/ui/components/chat/MessageContextMenu.tsx index 8b7750e..2a3b8ae 100644 --- a/frontend/src/pages/app/ui/components/chat/MessageContextMenu.tsx +++ b/frontend/src/pages/app/ui/components/chat/MessageContextMenu.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from "react"; import type { Message, Size2D } from "../../../core/types"; +import { EmojiMenu } from "./EmojiMenu"; interface MessageContextMenuProps { message: Message; @@ -9,7 +10,6 @@ interface MessageContextMenuProps { onDelete: (message: Message) => void; onRetry?: (message: Message) => void; onReactionClick?: (messageId: number, emoji: string) => Promise; - onExpandEmojiMenu?: (message: Message) => void; position: Size2D; isOpen: boolean; onOpenChange: (isOpen: boolean) => void; @@ -29,7 +29,6 @@ export function MessageContextMenu({ onDelete, onRetry, onReactionClick, - onExpandEmojiMenu, position, isOpen, onOpenChange @@ -39,11 +38,16 @@ export function MessageContextMenu({ const [calculatedPosition, setCalculatedPosition] = useState(position); const [animationClass, setAnimationClass] = useState('entering'); const [reactionBarPosition, setReactionBarPosition] = useState<'left' | 'right'>('left'); + const [isEmojiMenuExpanded, setIsEmojiMenuExpanded] = useState(false); + const [initialDimensions, setInitialDimensions] = useState<{ width: number; height: number } | null>(null); + const [expandUpward, setExpandUpward] = useState(false); + const [contextMenuHeight, setContextMenuHeight] = useState(null); // Refs for measuring actual dimensions const wrapperRef = useRef(null); const reactionBarRef = useRef(null); const contextMenuRef = useRef(null); + const emojiMenuRef = useRef(null); // Calculate smart positioning when component opens useEffect(() => { @@ -148,6 +152,11 @@ export function MessageContextMenu({ onOpenChange(false); setIsClosing(false); setAnimationClass('entering'); // Reset for next opening + // Reset emoji menu state after context menu animation completes + setIsEmojiMenuExpanded(false); + setInitialDimensions(null); + setExpandUpward(false); + setContextMenuHeight(null); }, 200); // Match the animation duration from _animations.scss } @@ -215,10 +224,43 @@ export function MessageContextMenu({ } function handleExpandClick() { - if (onExpandEmojiMenu) { - onExpandEmojiMenu(message); + if (!reactionBarRef.current || !wrapperRef.current) return; + + // Measure the actual dimensions of the reaction bar content + const reactionBarRect = reactionBarRef.current.getBoundingClientRect(); + const wrapperRect = wrapperRef.current.getBoundingClientRect(); + + setInitialDimensions({ width: reactionBarRect.width, height: reactionBarRect.height }); + setContextMenuHeight(wrapperRect.height); + + // Check if expanding downward would cause overflow + // Calculate space from the reaction bar's bottom edge downward + const viewportHeight = window.innerHeight; + const spaceBelow = viewportHeight - reactionBarRect.bottom; + const emojiMenuHeight = 400; + + // Only expand upward if there's not enough space below for the emoji menu + const shouldExpandUpward = spaceBelow < emojiMenuHeight; + setExpandUpward(shouldExpandUpward); + + // Use requestAnimationFrame to ensure the dimensions are applied before expansion + requestAnimationFrame(() => { + setIsEmojiMenuExpanded(true); + }); + } + + function handleEmojiMenuClose() { + setIsEmojiMenuExpanded(false); + setInitialDimensions(null); + setExpandUpward(false); + setContextMenuHeight(null); + } + + function handleEmojiSelect(emoji: string) { + if (onReactionClick) { + onReactionClick(message.id, emoji); } - handleClose(); + handleEmojiMenuClose(); } return isOpen && ( @@ -234,32 +276,57 @@ export function MessageContextMenu({ onClick={(e) => e.stopPropagation()}> {/* Reaction Bar */} -
- {QUICK_REACTIONS.map((emoji, index) => ( - - ))} - + className={`context-menu-reaction-bar ${reactionBarPosition} ${isEmojiMenuExpanded ? "expanded" : ""} ${expandUpward ? "expand-upward" : ""}`} + style={isEmojiMenuExpanded && !expandUpward ? { + position: 'fixed', + top: `${(-(contextMenuHeight || 0) + 5)}px`, + width: '320px', + height: '400px', + zIndex: 1001 + } : initialDimensions && !isEmojiMenuExpanded ? { + width: `${initialDimensions.width}px`, + height: `${initialDimensions.height}px` + } : {}}> + {!isEmojiMenuExpanded ? ( +
+ {QUICK_REACTIONS.map((emoji, index) => ( + + ))} + +
+ ) : ( +
+ +
+ )}
{/* Context Menu */}
+ className={`context-menu ${isEmojiMenuExpanded ? "faded" : ""}`}> {actions.map((action, i) => ( action.show && (
+
) }