Fix reactions bar expansion and positioning

This commit is contained in:
2025-10-07 21:29:10 +03:00
Unverified
parent 23ea0f73c4
commit 69fa2c3d3e
7 changed files with 184 additions and 71 deletions
@@ -968,4 +968,17 @@
color: $color-dark-on-surface-variant; color: $color-dark-on-surface-variant;
font-size: 0.9rem; 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;
}
} }
@@ -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 with animations
.context-menu-wrapper { .context-menu-wrapper {
position: relative; position: relative;
@@ -140,9 +149,9 @@
border-radius: 16px; border-radius: 16px;
position: absolute; position: absolute;
bottom: 100%; bottom: 100%;
min-width: 200px;
justify-content: center; justify-content: center;
margin-bottom: 10px; margin-bottom: 10px;
transition: width 0.3s ease-out, height 0.3s ease-out;
&.left { &.left {
left: 0; left: 0;
@@ -153,12 +162,55 @@
right: 0; right: 0;
transform: translateX(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 { .reaction-bar-content {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
transition: opacity 0.3s ease-out;
&.faded {
opacity: 0;
}
} }
.reaction-emoji-button { .reaction-emoji-button {
@@ -82,6 +82,11 @@ button, input {
&.open { &.open {
animation: context-menu-open 0.25s ease; animation: context-menu-open 0.25s ease;
} }
&.faded {
opacity: 0;
transition: opacity 0.3s ease-out;
}
@keyframes context-menu-open { @keyframes context-menu-open {
0% { 0% {
@@ -206,6 +206,7 @@ export function ChatInputWrapper(
onClose={() => setEmojiMenuOpen(false)} onClose={() => setEmojiMenuOpen(false)}
onEmojiSelect={handleEmojiSelect} onEmojiSelect={handleEmojiSelect}
position={emojiMenuPosition} position={emojiMenuPosition}
mode="standalone"
/> />
</div> </div>
); );
@@ -4,7 +4,6 @@ import type { Message as MessageType } from "../../../core/types";
import type { UserProfile } from "../../../core/types"; import type { UserProfile } from "../../../core/types";
import { UserProfileDialog } from "./UserProfileDialog"; import { UserProfileDialog } from "./UserProfileDialog";
import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu"; import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu";
import { EmojiMenu } from "./EmojiMenu";
import { fetchUserProfile } from "../../../api/profileApi"; import { fetchUserProfile } from "../../../api/profileApi";
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { delay } from "../../../utils/utils"; import { delay } from "../../../utils/utils";
@@ -42,16 +41,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [toBeDeleted, setToBeDeleted] = useState<{ id: number; isDm: boolean } | null>(null); 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(() => { useEffect(() => {
if (!deleteDialogOpen) { 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 ( return (
<> <>
@@ -227,7 +195,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
onDelete={handleDelete} onDelete={handleDelete}
onRetry={handleRetry} onRetry={handleRetry}
onReactionClick={handleReactionClick} onReactionClick={handleReactionClick}
onExpandEmojiMenu={handleExpandEmojiMenu}
position={contextMenu.position} position={contextMenu.position}
isOpen={contextMenu.isOpen} isOpen={contextMenu.isOpen}
onOpenChange={handleContextMenuOpenChange} onOpenChange={handleContextMenuOpenChange}
@@ -235,13 +202,6 @@ export function ChatMessages({ messages = [], children, isDm = false, onReplySel
)} )}
{/* Emoji Menu */}
<EmojiMenu
isOpen={emojiMenu.isOpen}
onClose={handleEmojiMenuClose}
onEmojiSelect={handleEmojiSelect}
position={emojiMenu.position}
/>
</> </>
); );
} }
@@ -2,14 +2,26 @@ import { useState, useEffect, useRef, useCallback } from "react";
import { EMOJI_CATEGORIES, getRecentEmojis, addRecentEmoji } from "./emojiData"; import { EMOJI_CATEGORIES, getRecentEmojis, addRecentEmoji } from "./emojiData";
import type { Size2D } from "../../../core/types"; import type { Size2D } from "../../../core/types";
interface EmojiMenuProps { interface BaseEmojiMenuProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onEmojiSelect: (emoji: string) => 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 [activeCategory, setActiveCategory] = useState("recent");
const [recentEmojis, setRecentEmojis] = useState<string[]>([]); const [recentEmojis, setRecentEmojis] = useState<string[]>([]);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@@ -106,13 +118,15 @@ export function EmojiMenu({ isOpen, onClose, onEmojiSelect, position }: EmojiMen
return ( return (
<div <div
ref={menuRef} ref={menuRef}
className={`emoji-menu ${isOpen ? "open" : ""}`} className={`emoji-menu ${isOpen ? "open" : ""} ${mode}`}
style={{ style={mode === "standalone" && position ? {
position: "fixed", position: "fixed",
left: position.x, left: position.x,
bottom: position.y, bottom: position.y,
zIndex: 1000, zIndex: 1000,
pointerEvents: isOpen ? "auto" : "none" pointerEvents: isOpen ? "auto" : "none"
} : {
pointerEvents: isOpen ? "auto" : "none"
}} }}
> >
<div className="emoji-menu-header"> <div className="emoji-menu-header">
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import type { Message, Size2D } from "../../../core/types"; import type { Message, Size2D } from "../../../core/types";
import { EmojiMenu } from "./EmojiMenu";
interface MessageContextMenuProps { interface MessageContextMenuProps {
message: Message; message: Message;
@@ -9,7 +10,6 @@ interface MessageContextMenuProps {
onDelete: (message: Message) => void; onDelete: (message: Message) => void;
onRetry?: (message: Message) => void; onRetry?: (message: Message) => void;
onReactionClick?: (messageId: number, emoji: string) => Promise<void>; onReactionClick?: (messageId: number, emoji: string) => Promise<void>;
onExpandEmojiMenu?: (message: Message) => void;
position: Size2D; position: Size2D;
isOpen: boolean; isOpen: boolean;
onOpenChange: (isOpen: boolean) => void; onOpenChange: (isOpen: boolean) => void;
@@ -29,7 +29,6 @@ export function MessageContextMenu({
onDelete, onDelete,
onRetry, onRetry,
onReactionClick, onReactionClick,
onExpandEmojiMenu,
position, position,
isOpen, isOpen,
onOpenChange onOpenChange
@@ -39,11 +38,16 @@ export function MessageContextMenu({
const [calculatedPosition, setCalculatedPosition] = useState(position); const [calculatedPosition, setCalculatedPosition] = useState(position);
const [animationClass, setAnimationClass] = useState('entering'); const [animationClass, setAnimationClass] = useState('entering');
const [reactionBarPosition, setReactionBarPosition] = useState<'left' | 'right'>('left'); 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<number | null>(null);
// Refs for measuring actual dimensions // Refs for measuring actual dimensions
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
const reactionBarRef = useRef<HTMLDivElement>(null); const reactionBarRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null); const contextMenuRef = useRef<HTMLDivElement>(null);
const emojiMenuRef = useRef<HTMLDivElement>(null);
// Calculate smart positioning when component opens // Calculate smart positioning when component opens
useEffect(() => { useEffect(() => {
@@ -148,6 +152,11 @@ export function MessageContextMenu({
onOpenChange(false); onOpenChange(false);
setIsClosing(false); setIsClosing(false);
setAnimationClass('entering'); // Reset for next opening 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 }, 200); // Match the animation duration from _animations.scss
} }
@@ -215,10 +224,43 @@ export function MessageContextMenu({
} }
function handleExpandClick() { function handleExpandClick() {
if (onExpandEmojiMenu) { if (!reactionBarRef.current || !wrapperRef.current) return;
onExpandEmojiMenu(message);
// 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 && ( return isOpen && (
@@ -234,32 +276,57 @@ export function MessageContextMenu({
onClick={(e) => e.stopPropagation()}> onClick={(e) => e.stopPropagation()}>
{/* Reaction Bar */} {/* Reaction Bar */}
<div <div
ref={reactionBarRef} ref={reactionBarRef}
className={`context-menu-reaction-bar ${reactionBarPosition}`}> className={`context-menu-reaction-bar ${reactionBarPosition} ${isEmojiMenuExpanded ? "expanded" : ""} ${expandUpward ? "expand-upward" : ""}`}
{QUICK_REACTIONS.map((emoji, index) => ( style={isEmojiMenuExpanded && !expandUpward ? {
<button position: 'fixed',
key={index} top: `${(-(contextMenuHeight || 0) + 5)}px`,
className="reaction-emoji-button" width: '320px',
onClick={async () => await handleReactionClick(emoji)} height: '400px',
title={emoji} zIndex: 1001
> } : initialDimensions && !isEmojiMenuExpanded ? {
{emoji} width: `${initialDimensions.width}px`,
</button> height: `${initialDimensions.height}px`
))} } : {}}>
<button {!isEmojiMenuExpanded ? (
className="reaction-expand-button" <div className="reaction-bar-content">
onClick={handleExpandClick} {QUICK_REACTIONS.map((emoji, index) => (
title="More emojis" <button
> key={index}
<span className="material-symbols">add</span> className="reaction-emoji-button"
</button> onClick={async () => await handleReactionClick(emoji)}
title={emoji}
>
{emoji}
</button>
))}
<button
className="reaction-expand-button"
onClick={handleExpandClick}
title="More emojis"
>
<span className="material-symbols">add</span>
</button>
</div>
) : (
<div
ref={emojiMenuRef}
className="emoji-menu-wrapper">
<EmojiMenu
isOpen={true}
onClose={handleEmojiMenuClose}
onEmojiSelect={handleEmojiSelect}
mode="integrated"
/>
</div>
)}
</div> </div>
{/* Context Menu */} {/* Context Menu */}
<div <div
ref={contextMenuRef} ref={contextMenuRef}
className="context-menu"> className={`context-menu ${isEmojiMenuExpanded ? "faded" : ""}`}>
{actions.map((action, i) => ( {actions.map((action, i) => (
action.show && ( action.show && (
<div <div
@@ -273,6 +340,7 @@ export function MessageContextMenu({
) )
))} ))}
</div> </div>
</div> </div>
) )
} }