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;
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 {
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 {
@@ -83,6 +83,11 @@ button, input {
animation: context-menu-open 0.25s ease;
}
&.faded {
opacity: 0;
transition: opacity 0.3s ease-out;
}
@keyframes context-menu-open {
0% {
opacity: 0;
@@ -206,6 +206,7 @@ export function ChatInputWrapper(
onClose={() => setEmojiMenuOpen(false)}
onEmojiSelect={handleEmojiSelect}
position={emojiMenuPosition}
mode="standalone"
/>
</div>
);
@@ -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 */}
<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 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<string[]>([]);
const menuRef = useRef<HTMLDivElement>(null);
@@ -106,13 +118,15 @@ export function EmojiMenu({ isOpen, onClose, onEmojiSelect, position }: EmojiMen
return (
<div
ref={menuRef}
className={`emoji-menu ${isOpen ? "open" : ""}`}
style={{
className={`emoji-menu ${isOpen ? "open" : ""} ${mode}`}
style={mode === "standalone" && position ? {
position: "fixed",
left: position.x,
bottom: position.y,
zIndex: 1000,
pointerEvents: isOpen ? "auto" : "none"
} : {
pointerEvents: isOpen ? "auto" : "none"
}}
>
<div className="emoji-menu-header">
@@ -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<void>;
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<number | null>(null);
// Refs for measuring actual dimensions
const wrapperRef = useRef<HTMLDivElement>(null);
const reactionBarRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
const emojiMenuRef = useRef<HTMLDivElement>(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);
});
}
handleClose();
function handleEmojiMenuClose() {
setIsEmojiMenuExpanded(false);
setInitialDimensions(null);
setExpandUpward(false);
setContextMenuHeight(null);
}
function handleEmojiSelect(emoji: string) {
if (onReactionClick) {
onReactionClick(message.id, emoji);
}
handleEmojiMenuClose();
}
return isOpen && (
@@ -236,7 +278,19 @@ export function MessageContextMenu({
{/* Reaction Bar */}
<div
ref={reactionBarRef}
className={`context-menu-reaction-bar ${reactionBarPosition}`}>
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 ? (
<div className="reaction-bar-content">
{QUICK_REACTIONS.map((emoji, index) => (
<button
key={index}
@@ -255,11 +309,24 @@ export function MessageContextMenu({
<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>
{/* Context Menu */}
<div
ref={contextMenuRef}
className="context-menu">
className={`context-menu ${isEmojiMenuExpanded ? "faded" : ""}`}>
{actions.map((action, i) => (
action.show && (
<div
@@ -273,6 +340,7 @@ export function MessageContextMenu({
)
))}
</div>
</div>
)
}